Compare commits

..

2 Commits

Author SHA1 Message Date
Nikhil Sonti
8cd7c985e3 fix: address review feedback for PR #891 2026-04-30 12:54:44 -07:00
Nikhil Sonti
548c9dd996 feat(dev): bootstrap setup from dev watch 2026-04-30 12:49:25 -07:00
88 changed files with 504 additions and 4307 deletions

View File

@@ -1,152 +0,0 @@
---
name: ask-internal
description: Answer questions about BrowserOS internal stuff (setup, features, architecture, design decisions) by reading the private internal-docs submodule and the codebase. Use for "how do I X", "where is Y", "what is the deal with Z", or any question that mixes ops/setup knowledge with code knowledge. Can execute steps with per-command confirmation.
allowed-tools: Bash, Read, Grep, Glob, Edit, Write
---
# Ask Internal
Answer team-internal questions by reading `.internal-docs/` and the codebase, synthesizing a direct answer with file:line citations, and optionally running surfaced commands with confirmation.
**Announce at start:** "I'm using the ask-internal skill to answer this from internal-docs and the codebase."
## When to use
- "How do I reset my dogfood profile?"
- "What's the deal with the OpenClaw VM startup?"
- "Where do we configure release signing?"
- Any question whose answer lives in setup runbooks, feature notes, architecture docs, or the code that produced them.
## Hard rules — never do these
- NEVER execute a state-mutating command without per-command `y` confirmation from the user.
- NEVER edit BrowserOS code in response to an ask-internal question. The skill answers; it does not modify code. Use `/document-internal` for writes.
- NEVER guess. If grep finds nothing useful in docs or code, say so plainly.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER cite a file or line number you have not actually read.
## Voice rules
Apply the same voice rules as `document-internal` to the synthesized answer:
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences. Active voice. No em dashes.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- No filler intros.
## Workflow
### Step 0: Pre-flight
```bash
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] && [ -n "$(ls -A .internal-docs 2>/dev/null)" ] || {
echo ".internal-docs/ missing or empty. Submodule not configured?"
exit 0
}
```
### Step 1: Parse the question
Pull the keywords from the user's question. Drop stop words. Identify intent:
- **Setup-question** ("how do I", "how to", "where do I configure"): bias the search toward `setup/`.
- **Feature-question** ("what is X", "why does X work this way"): bias toward `features/` and `architecture/`.
- **Free-form** ("anything about Y"): search all categories.
### Step 2: Multi-source search
Run grep in parallel across two sources.
**Internal docs:**
```bash
grep -rni --include='*.md' '<keyword>' .internal-docs/
```
Search each keyword separately. Collect top hits by relevance (more keyword matches = higher).
**Codebase (skip vendored Chromium and `node_modules`):**
```bash
grep -rni --include='*.ts' --include='*.tsx' --include='*.js' --include='*.json' --include='*.sh' \
--exclude-dir=node_modules --exclude-dir=chromium --exclude-dir=.grove \
'<keyword>' packages/ scripts/ .config/ .github/
```
Read the top 3-5 doc hits and top 3-5 code hits. Do not skim — read the relevant section fully so citations are accurate.
### Step 3: Synthesize answer
Structure the response:
1. **Direct answer.** First sentence answers the question. No preamble.
2. **Steps if applicable.** Numbered list with exact commands.
3. **Citations.** Every factual claim references `path/to/file.md:42` or `path/to/code.ts:117`. Run the voice self-check before printing.
If multiple docs cover the topic at different layers (e.g., a setup runbook and a feature note both mention dogfood profiles), reconcile them in the answer rather than dumping both.
### Step 4: Offer execution (only if commands surfaced)
If Step 3 produced executable commands the user could run, ask:
> Run these for you? (y / n / dry-run)
- **y:** Execute one at a time. For any command that mutates state (writes a file, modifies config, kills a process, deletes anything), ask "run this? <command>" before each. Read-only commands (`ls`, `cat`, `git status`) run without per-command confirmation but still print before running.
- **n:** Skip. Done.
- **dry-run:** Print the full sequence as a `bash` block. Do not execute.
### Step 5: Doc-not-found path
If Step 2 returned nothing useful (no doc hits AND no clear code answer):
1. Tell the user: "No doc covers this. Tangentially relevant files: <list>."
2. Ask: "Draft a new doc and open a PR to internal-docs?"
3. On yes: invoke the full `/document-internal` flow (four sharp questions, draft, voice check, PR), forced to `setup/` doc type, with the code-grep findings handed in as initial context.
### Step 6: Completion status
Report one of:
- **DONE** — answer delivered, citations verified.
- **DONE_WITH_CONCERNS** — answered, but flag uncertainty (e.g., docs and code disagreed; user should reconcile).
- **BLOCKED** — submodule missing or other pre-flight failure.
- **NEEDS_CONTEXT** — question too vague to search effectively. Ask one clarifying question.
## Citation discipline
Every "X is at Y" claim in the answer must point to a file:line that the skill actually read. Do not approximate. If you didn't read it, don't cite it.
If a doc says one thing and the code says another, surface the conflict explicitly:
> The setup runbook (`setup/dogfood-profile.md:23`) says to delete `~/.cache/browseros/dogfood`, but the actual code path in `packages/cli/src/cleanup.ts:47` removes `~/.local/share/browseros/dogfood`. The doc looks stale. Recommend updating it.
## Common Mistakes
**Skimming and then citing**
- **Problem:** Citation points to a line that doesn't actually contain the claim.
- **Fix:** Read the section fully before citing. If you didn't read line 117, don't cite line 117.
**Executing without per-command confirmation for mutations**
- **Problem:** User says "y" to "run all", skill blasts through `rm -rf`-style commands.
- **Fix:** "y" means "run this sequence with per-mutation confirmations". Per-command y is required for writes.
**Searching only docs, not code**
- **Problem:** Doc says X but code does Y; answer is wrong.
- **Fix:** Always grep both sources in Step 2.
## Red Flags
**Never:**
- Cite a file:line you haven't read.
- Run mutations without per-command confirmation.
- Modify BrowserOS code from this skill (use `/document-internal` for writes).
**Always:**
- Pre-flight check before any search.
- Reconcile doc vs code conflicts in the answer, don't hide them.
- Plain "no doc covers this" when grep is empty — never invent.

View File

@@ -1,208 +0,0 @@
---
name: document-internal
description: Draft a 1-page internal doc (feature, architecture, or design) for the private browseros-ai/internal-docs repo. Use when wrapping up a feature on a branch, after the PR is open or about to be opened. Skill drafts from the diff, asks four sharp questions, enforces voice rules, and opens a PR to internal-docs.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob
---
# Document Internal
Draft a 1-page internal doc (feature note, architecture note, or design spec) from the current branch's diff and open a PR to `browseros-ai/internal-docs`.
**Announce at start:** "I'm using the document-internal skill to draft a doc for internal-docs."
## When to use
After finishing implementation on a feature branch, when the work is doc-worthy (a major feature, a new subsystem, a setup runbook for something internal, or a design decision that future engineers need to know).
## Hard rules — never do these
- NEVER `git add -A` or `git add .` inside the tmp clone of internal-docs. Always specific paths.
- NEVER write outside the tmp clone (no spillover into the OSS repo's working tree).
- NEVER fabricate filler content for empty template sections. Empty stays empty.
- NEVER touch the OSS repo's `.gitmodules` or submodule pointer — the sync workflow handles that.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER push to `internal-docs/main` directly. Always a feature branch + PR.
## Voice rules — enforced by Step 4
The skill MUST follow these and refuse to draft otherwise. After generation, scan for violations and regenerate offending sentences (max 3 attempts).
- Lead with the point. First sentence answers "what is this?"
- Concrete nouns. Name files, functions, commands. Not "the system" or "the component".
- Short sentences. Average <20 words. No deeply nested clauses.
- Active voice. "X does Y" not "Y is done by X".
- No em dashes. Use commas, periods, or rephrase.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- "110 IQ" target. Write for a smart engineer who has not seen this code yet.
- No filler intros ("This document describes..."). Start with the substance.
- Empty sections stay empty. Do not write "N/A" or fabricate content.
## Workflow
### Step 0: Pre-flight
Bail with a clear message on any failure.
```bash
# Submodule must be initialized
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] || { echo ".internal-docs/ missing. Submodule not configured?"; exit 0; }
# Must be on a feature branch
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "dev" ]; then
echo "On $BRANCH. Run from a feature branch."
exit 0
fi
# Determine base branch (default: dev for this repo, fall back to main).
# Suppress rev-parse's SHA output on stdout so it doesn't get captured into BASE.
BASE=$(git rev-parse --verify origin/dev >/dev/null 2>&1 && echo dev || echo main)
# Gather context
git log "$BASE..HEAD" --oneline
git diff "$BASE...HEAD" --stat
gh pr view --json body -q .body 2>/dev/null # may be empty if no PR yet
```
### Step 1: Identify the doc
Ask the user for three things in one prompt:
1. **Doc type:** `feature` (default for `feat/*` branches), `architecture`, or `design`
2. **Slug:** kebab-case, short (e.g., `cowork-mcp`, `auto-skill-suggest`)
3. **Owner:** GitHub handle (default = `git config user.name` or current `gh api user --jq .login`)
### Step 2: Decision brief — four sharp questions
Ask one question at a time. Each answer constrains the next. These force compression before drafting.
1. "In one sentence: what can someone now DO that they could not before?"
2. "What is the one design decision a future engineer needs to know?"
3. "Which 3-5 files are the heart of this change?" (suggest candidates from the diff)
4. "Any sharp edges or gotchas? (or 'none')"
Skip any question that is N/A for the doc type. Architecture notes don't need question 1; design specs don't need question 4.
### Step 3: Draft from the template
Read the matching template from `.internal-docs/_templates/`:
- `feature` `feature-note.md`
- `architecture` `architecture-note.md`
- `design` `design-spec.md`
If `.internal-docs/_templates/` does not exist (first run, before seeding), fall back to the seeds bundled with this skill at `.claude/skills/document-internal/seeds/_templates/`.
Generate the 1-pager from the template, the four answers, and the diff context.
### Step 4: Voice self-check
Scan the draft for violations:
- Em dash present (`—`).
- Any banned word from the list.
- Average sentence length > 20 words.
- Body line count > 60 (feature notes only — architecture/design have no cap).
If any violation found, regenerate the offending sentences in place. Max 3 attempts. If still failing after 3 attempts, stop and report which rules are violated.
If the body is over 60 lines for a feature note, ask: "This is N lines, target is 60. Trim, or promote to `architecture/` (no length cap)?"
### Step 5: Show + iterate
Print the full draft. Ask:
> Edit needed? Paste any changes, or say "looks good".
Apply user edits with the Edit tool. Re-run Step 4. Loop until the user approves.
### Step 6: Open PR to internal-docs
Use a tmp clone. Never the user's `.internal-docs` checkout — keeps the user's submodule clean.
```bash
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT # cleans up even if any step below fails
git clone -b main git@github.com:browseros-ai/internal-docs.git "$TMP"
cd "$TMP"
git checkout -b "docs/<slug>"
# Write the doc
mkdir -p "<type>" # features, architecture, designs, or setup
cat > "<type>/$(date -u +%Y-%m)-<slug>.md" <<'DOC'
<draft content>
DOC
# Update the root README index — insert one line under the matching section
# Use Edit tool to add: "- [<title>](<type>/YYYY-MM-<slug>.md) — <one-line description>"
git add "<type>/$(date -u +%Y-%m)-<slug>.md" README.md
git commit -m "docs(<type>): <slug>"
git push -u origin "docs/<slug>"
PR_URL=$(gh pr create -R browseros-ai/internal-docs --base main \
--head "docs/<slug>" \
--title "docs(<type>): <slug>" \
--body "$(cat <<'BODY'
## Summary
<one-line of what this doc covers>
## Source
- BrowserOS branch: <branch>
- Related PR: <#NNN if any>
BODY
)")
cd -
echo "PR opened: $PR_URL"
# trap above cleans up $TMP on EXIT
```
If the slug contains characters that won't shell-escape cleanly, sanitize before substitution.
### Step 7: Completion status
Report one of:
- **DONE** — file written, branch pushed, PR opened. Print PR URL.
- **DONE_WITH_CONCERNS** — same as DONE but list concerns (e.g., voice check needed multiple regens, user skipped a question).
- **BLOCKED** — submodule missing, auth fail, or template missing. State exactly what's needed.
## Doc type defaults
| Branch pattern | Default doc type | Default location |
|----------------|------------------|------------------|
| `feat/*` | feature | `features/` |
| `arch/*` or refactor branches with >10 files in `packages/` | architecture | `architecture/` |
| `rfc/*` or `design/*` | design | `designs/` |
| Otherwise | ask | ask |
## Common Mistakes
**Drafting before asking the four questions**
- **Problem:** Output is generic filler that says nothing concrete.
- **Fix:** Always ask Step 2 first, even if the diff "looks obvious".
**Touching `.internal-docs/` directly**
- **Problem:** User's submodule HEAD moves, parent repo shows dirty state.
- **Fix:** Always use the tmp clone in Step 6.
**Skipping voice check on user edits**
- **Problem:** User pastes prose with em dashes or filler; ships as-is.
- **Fix:** Re-run Step 4 after every user edit.
## Red Flags
**Never:**
- Push to `internal-docs/main`. Always branch + PR.
- Modify the OSS repo's `.gitmodules` or submodule pointer.
- Fabricate content for empty template sections.
**Always:**
- Pre-flight check before doing any work.
- One-pager rule for feature notes (60-line body cap).
- File:line citations when referencing code.

View File

@@ -1,51 +0,0 @@
# BrowserOS Internal Docs
Private team docs for `browseros-ai`. Mounted as a submodule into the public OSS repo at `.internal-docs/`.
If you are reading this from a public clone of BrowserOS without team access — this submodule is for the BrowserOS internal team. Nothing here is required to build or use BrowserOS.
## How to find what you need
- Setup task ("how do I X locally") → look in [`setup/`](setup/)
- Recently shipped feature → look in [`features/`](features/)
- Cross-cutting subsystem → look in [`architecture/`](architecture/)
- A design decision or RFC → look in [`designs/`](designs/)
Or run `/ask-internal "<your question>"` from any BrowserOS checkout. The skill greps these docs and the codebase, then synthesizes an answer with citations.
## How to add a doc
Run `/document-internal` from a feature branch. The skill drafts a 1-pager from your branch's diff, asks four sharp questions, enforces voice rules, and opens a PR back to this repo.
## Index
### Setup
<!-- one line per setup runbook: -->
<!-- - [Dev environment](setup/dev-environment.md): first-time machine setup -->
### Features
<!-- one line per shipped feature, newest first: -->
<!-- - [Cowork MCP](features/2026-04-cowork-mcp.md): bring outside MCPs into the BrowserOS agent -->
### Architecture
<!-- one line per cross-cutting subsystem: -->
<!-- - [Chrome fork overview](architecture/chrome-fork-overview.md): what we patched and why -->
### Designs
<!-- one line per design spec, newest first: -->
<!-- - [Internal docs submodule](designs/2026-04-30-internal-docs-submodule.md): this system -->
## Templates
When `/document-internal` runs, it reads from [`_templates/`](_templates/). Edit the templates here when the team's preferred shape changes.
## Voice
Docs in this repo follow these rules. The `/document-internal` skill enforces them; humans editing by hand should match.
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences, active voice, no em dashes.
- No filler words: delve, crucial, robust, comprehensive, nuanced, multifaceted, leverage, utilize, etc.
- Empty sections stay empty. Do not write "N/A" or fake content.
- Feature notes target one screen, body 60 lines max.

View File

@@ -1,31 +0,0 @@
---
title: <subsystem name>
owner: <github handle>
status: current | deprecated
date: YYYY-MM-DD
related-features: [feature-slug-1, feature-slug-2]
---
# <subsystem name>
## What this subsystem does
<1-2 paragraphs. The top-level responsibility. Boundaries.>
## Architecture
<Diagram (ASCII or mermaid) plus prose. Components and how they talk.>
## Constraints
<Hard rules the design enforces. "X must never call Y" type statements.>
## Decisions made
<Numbered list of non-obvious decisions and the reason for each.>
## Key files
- `path/to/file.ts` — role
- `path/to/dir/` — what lives here
## How to evolve this
<Where to add things. Which tests to expect to update. What NOT to touch.>
## Open questions
<What is still being figured out. Empty if none.>

View File

@@ -1,34 +0,0 @@
---
title: <design name>
owner: <github handle>
status: proposed | accepted | rejected | superseded
date: YYYY-MM-DD
supersedes: <design-slug or none>
---
# <design name>
## Goal
<2-4 sentences. What this design is trying to accomplish.>
## Context
<1-2 paragraphs. The current state, what is failing, why this needs to change.>
## Selected Approach
<The chosen design at a high level. Architecture, components, data flow.>
## Alternatives Considered
### 1. <name>
<2-3 sentences on what this would look like, then pro/con and why rejected (or deferred).>
### 2. <name>
<Same shape.>
## Out of Scope
<What this design does NOT cover. Defer references.>
## Rollout
<Numbered steps from "nothing exists" to "fully shipped".>
## Open Questions
<Resolved during design? Empty. Unresolved? List with owner.>

View File

@@ -1,29 +0,0 @@
---
title: <feature name>
owner: <github handle>
status: shipped | wip | deprecated
date: YYYY-MM-DD
prs: ["#NNN"]
tags: [agent, browser, mcp]
---
# <feature name>
## What it does
<2-3 sentences. What can someone now do that they could not before. Lead with user-facing impact, not implementation.>
## Why we built it
<1-2 sentences. Motivation. What pain it removed or what unlocked.>
## How it works
<3-6 sentences. The flow at a high level. Name the key files.>
## Key files
- `path/to/file.ts` — what it does
- `path/to/other.ts` — what it does
## How to run / test it locally
<bullet list of commands. Empty section if N/A do not fake.>
## Gotchas
<known sharp edges. "If you see X, that's why." Empty if N/A.>

View File

@@ -1,62 +0,0 @@
name: Sync internal-docs submodule
on:
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
jobs:
sync:
name: Bump internal-docs submodule pointer on dev
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Rewrite SSH submodule URL to HTTPS-with-token
env:
TOKEN: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
run: |
git config --global "url.https://x-access-token:${TOKEN}@github.com/.insteadOf" "git@github.com:"
- uses: actions/checkout@v4
with:
token: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
submodules: true
ref: dev
fetch-depth: 50
- name: Open auto-merge PR if internal-docs has new commits
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
# Skip if submodule not yet configured (handoff window before someone adds it)
if ! git config --file .gitmodules --get-regexp '^submodule\..internal-docs\.path$' >/dev/null 2>&1; then
echo "internal-docs submodule not yet configured in .gitmodules. Skipping."
exit 0
fi
git submodule update --remote --merge .internal-docs
if git diff --quiet .internal-docs; then
echo "No internal-docs changes to sync."
exit 0
fi
BRANCH="bot/sync-internal-docs-$(date -u +%Y%m%d-%H%M%S)"
git config user.name "browseros-bot"
git config user.email "bot@browseros.ai"
git checkout -b "$BRANCH"
git add .internal-docs
git commit -m "chore: sync internal-docs submodule"
git push -u origin "$BRANCH"
PR_URL=$(gh pr create \
--base dev \
--head "$BRANCH" \
--title "chore: sync internal-docs submodule" \
--body "Automated bump of the \`.internal-docs\` submodule pointer. Auto-merging.")
gh pr merge "$PR_URL" --auto --squash --delete-branch

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule ".internal-docs"]
path = .internal-docs
url = git@github.com:browseros-ai/internal-docs.git
branch = main

Submodule .internal-docs deleted from 590799ae1c

View File

@@ -1,25 +1,20 @@
import { ArrowLeft } from 'lucide-react'
import { ArrowLeft, Bot, Home } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import type {
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import {
cancelHarnessTurn,
useAgentAdapters,
useEnqueueHarnessMessage,
useHarnessAgents,
useRemoveHarnessQueuedMessage,
useUpdateHarnessAgent,
} from '@/entrypoints/app/agents/useAgents'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { AgentRail } from './AgentRail'
import {
type AgentEntry,
getModelDisplayName,
} from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
import { useAgentCommandData } from './agent-command-layout'
import { ClawChat } from './ClawChat'
import { ConversationHeader } from './ConversationHeader'
import { ConversationInput } from './ConversationInput'
import {
buildChatHistoryFromClawMessages,
@@ -30,6 +25,162 @@ import { QueuePanel } from './QueuePanel'
import { useAgentConversation } from './useAgentConversation'
import { useHarnessChatHistory } from './useHarnessChatHistory'
function StatusBadge({ status }: { status: string }) {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
<span
className={cn(
'size-1.5 rounded-full',
status === 'Working on your request'
? 'bg-amber-500'
: status === 'Ready'
? 'bg-emerald-500'
: status === 'Offline'
? 'bg-muted-foreground/50'
: 'bg-[var(--accent-orange)]',
)}
/>
<span>{status}</span>
</div>
)
}
function AgentIdentity({
name,
meta,
className,
}: {
name: string
meta: string
className?: string
}) {
return (
<div className={cn('min-w-0', className)}>
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
<div className="truncate text-muted-foreground text-xs leading-5">
{meta}
</div>
</div>
)
}
function ConversationHeader({
agentName,
agentMeta,
status,
backLabel,
backTarget,
onGoHome,
}: {
agentName: string
agentMeta: string
status: string
backLabel: string
backTarget: 'home' | 'page'
onGoHome: () => void
}) {
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
return (
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
className="size-8 rounded-xl lg:hidden"
title={backLabel}
>
<BackIcon className="size-4" />
</Button>
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<Bot className="size-4" />
</div>
<AgentIdentity name={agentName} meta={agentMeta} />
</div>
<StatusBadge status={status} />
</div>
)
}
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
return (
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
className="size-8 rounded-xl"
title="Back to home"
>
<ArrowLeft className="size-4" />
</Button>
<div className="truncate font-semibold text-[15px] leading-5">
Agents
</div>
</div>
</div>
)
}
function AgentRailList({
activeAgentId,
agents,
onSelectAgent,
}: {
activeAgentId: string
agents: AgentEntry[]
onSelectAgent: (entry: AgentEntry) => void
}) {
return (
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
{agents.map((entry) => {
const active = entry.agentId === activeAgentId
const modelName = getAgentEntryMeta(entry)
return (
<button
key={entry.agentId}
type="button"
onClick={() => onSelectAgent(entry)}
className={cn(
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
active
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'flex size-9 items-center justify-center rounded-xl',
active
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
: 'bg-muted text-muted-foreground',
)}
>
<Bot className="size-4" />
</div>
<AgentIdentity name={entry.name} meta={modelName} />
</div>
</button>
)
})}
</div>
</aside>
)
}
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
if (agent?.source === 'agent-harness') {
return getModelDisplayName(agent.model) ?? 'ACP agent'
}
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
}
function AgentConversationController({
agentId,
initialMessage,
@@ -138,7 +289,7 @@ function AgentConversationController({
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-col overflow-hidden">
<ClawChat
agentName={agentName}
historyMessages={historyMessages}
@@ -217,22 +368,6 @@ interface AgentCommandConversationProps {
createAgentPath?: string
}
function inferAdapterFromEntry(
entry: AgentEntry | undefined,
): HarnessAgentAdapter | 'unknown' {
if (!entry) return 'unknown'
if (entry.source === 'agent-harness') {
// Harness entries don't carry the adapter on AgentEntry; the rail
// / header read the harness record directly. This branch only runs
// before the harness query resolves, so 'unknown' is correct — the
// tile's bot fallback renders until data arrives.
return 'unknown'
}
// OpenClaw-only entries (no harness shadow) are deprecated in
// practice but the rail still tolerates them.
return 'openclaw'
}
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
variant = 'command',
backPath = '/home',
@@ -243,110 +378,60 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { agents } = useAgentCommandData()
const { harnessAgents } = useHarnessAgents()
const { adapters } = useAgentAdapters()
const updateAgent = useUpdateHarnessAgent()
const shouldRedirectHome = !agentId
const resolvedAgentId = agentId ?? ''
const harnessAgent = harnessAgents.find(
(entry) => entry.id === resolvedAgentId,
)
const entry = agents.find((item) => item.agentId === resolvedAgentId)
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
const fallbackAdapter = inferAdapterFromEntry(entry)
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
const agentName = agent?.name || resolvedAgentId || 'Agent'
const agentMeta = getAgentEntryMeta(agent)
const initialMessage = searchParams.get('q')
const isPageVariant = variant === 'page'
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
const adapterId = harnessAgent?.adapter
if (!adapterId) return null
const descriptor = adapters.find((item) => item.id === adapterId)
if (!descriptor?.health) return null
return {
healthy: descriptor.health.healthy,
reason: descriptor.health.reason,
}
}, [adapters, harnessAgent?.adapter])
if (shouldRedirectHome) {
return <Navigate to="/home" replace />
}
const handleSelectHarnessAgent = (target: HarnessAgent) => {
navigate(`${agentPathPrefix}/${target.id}`)
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`${agentPathPrefix}/${entry.agentId}`)
}
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
if (!target) return
updateAgent.mutate({
agentId: target.id,
patch: { pinned: next },
})
}
// Every visible agent runs through the harness now, so per-agent
// runtime status doesn't gate chat the way OpenClaw's legacy
// gateway lifecycle did. Show "Ready" once the agent record is
// resolved from the rail, "Setup" otherwise.
const statusCopy = agent ? 'Ready' : 'Setup'
return (
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
{/* Shared top band — the rail's "Agents" header and the chat
header live on one row so they're aligned by construction. */}
<div className="flex shrink-0 items-stretch border-border/50 border-b">
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(backPath)}
className="size-8 rounded-xl"
title="Back to home"
>
<ArrowLeft className="size-4" />
</Button>
<div className="truncate font-semibold text-[15px] leading-5">
Agents
</div>
</div>
<div className="min-w-0 flex-1">
<ConversationHeader
agent={harnessAgent ?? null}
fallbackName={fallbackName}
fallbackAdapter={fallbackAdapter}
adapterHealth={adapterHealth}
backLabel={backLabel}
backTarget={isPageVariant ? 'page' : 'home'}
onGoHome={() => navigate(backPath)}
onPinToggle={(next) =>
handlePinToggle(harnessAgent ?? null, next)
}
/>
</div>
</div>
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
<AgentRailHeader onGoHome={() => navigate(backPath)} />
{/* Body grid: rail list + chat. Both columns share the same
top edge (the band above) so headers can never drift. */}
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
<AgentRail
agents={harnessAgents}
adapters={adapters}
activeAgentId={resolvedAgentId}
onSelectAgent={handleSelectHarnessAgent}
onPinToggle={(target, next) => handlePinToggle(target, next)}
/>
<ConversationHeader
agentName={agentName}
agentMeta={agentMeta}
status={statusCopy}
backLabel={backLabel}
backTarget={isPageVariant ? 'page' : 'home'}
onGoHome={() => navigate(backPath)}
/>
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<AgentConversationController
key={resolvedAgentId}
agentId={resolvedAgentId}
agents={agents}
initialMessage={initialMessage}
onInitialMessageConsumed={() =>
setSearchParams({}, { replace: true })
}
agentPathPrefix={agentPathPrefix}
createAgentPath={createAgentPath}
/>
</div>
</div>
<AgentRailList
activeAgentId={resolvedAgentId}
agents={agents}
onSelectAgent={handleSelectAgent}
/>
<AgentConversationController
key={resolvedAgentId}
agentId={resolvedAgentId}
agents={agents}
initialMessage={initialMessage}
onInitialMessageConsumed={() =>
setSearchParams({}, { replace: true })
}
agentPathPrefix={agentPathPrefix}
createAgentPath={createAgentPath}
/>
</div>
</div>
)

View File

@@ -1,65 +0,0 @@
import { type FC, useMemo } from 'react'
import type {
HarnessAdapterDescriptor,
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import { orderAgentsByPinThenRecency } from '@/entrypoints/app/agents/agents-list-order'
import { AgentRailRow } from './AgentRailRow'
interface AgentRailProps {
agents: HarnessAgent[]
adapters: HarnessAdapterDescriptor[]
activeAgentId: string
onSelectAgent: (agent: HarnessAgent) => void
onPinToggle: (agent: HarnessAgent, next: boolean) => void
}
/**
* Left-column scrollable list of agents. The "Agents" label + back
* button live in the shared top band above (so the rail header and
* the chat header sit on a single aligned strip rather than as two
* separately-sized headers per column). Sort matches `/agents`:
* pinned-first → recency, so the rail doesn't reshuffle as turns
* transition every 5 s.
*/
export const AgentRail: FC<AgentRailProps> = ({
agents,
adapters,
activeAgentId,
onSelectAgent,
onPinToggle,
}) => {
const adapterHealth = useMemo(() => {
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
for (const adapter of adapters) {
if (adapter.health) {
map.set(adapter.id, {
healthy: adapter.health.healthy,
reason: adapter.health.reason,
})
}
}
return map
}, [adapters])
const ordered = useMemo(() => orderAgentsByPinThenRecency(agents), [agents])
return (
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
<div className="styled-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 py-3">
{ordered.map((agent) => (
<AgentRailRow
key={agent.id}
agent={agent}
active={agent.id === activeAgentId}
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
onSelect={() => onSelectAgent(agent)}
onPinToggle={(next) => onPinToggle(agent, next)}
/>
))}
</div>
</aside>
)
}

View File

@@ -1,102 +0,0 @@
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
import { cn } from '@/lib/utils'
interface AgentRailRowProps {
agent: HarnessAgent
active: boolean
adapterHealth: AgentAdapterHealth | null
onSelect: () => void
onPinToggle: (next: boolean) => void
}
/**
* Compact rail row for the chat-screen sidebar. Slims `<AgentRowCard>`
* down to the essentials that fit a ~280 px rail: tile + name + status
* badge + pin star, with the adapter / model / reasoning chips on a
* second line. Token totals, sparkline, last-message preview all stay
* on the `/agents` page where rows are full-width.
*/
export const AgentRailRow: FC<AgentRailRowProps> = ({
agent,
active,
adapterHealth,
onSelect,
onPinToggle,
}) => {
const status = agent.status ?? 'unknown'
const lastUsedAt = agent.lastUsedAt ?? null
const pinned = agent.pinned ?? false
return (
<button
type="button"
onClick={onSelect}
className={cn(
'group w-full rounded-2xl border px-3 py-3 text-left transition-colors',
active
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8'
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
)}
>
<div className="flex min-w-0 items-start gap-3">
<AgentTile
adapter={agent.adapter}
status={status}
lastUsedAt={lastUsedAt}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-semibold text-[14px] leading-5">
{agent.name}
</span>
{status === 'working' && (
<Badge
variant="secondary"
className="h-5 bg-amber-50 px-1.5 text-[10px] text-amber-900 hover:bg-amber-50"
>
Working
</Badge>
)}
{status === 'asleep' && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] text-muted-foreground"
>
Asleep
</Badge>
)}
{status === 'error' && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
Attention
</Badge>
)}
<div className="ml-auto">
<PinToggle pinned={pinned} onToggle={onPinToggle} />
</div>
</div>
<AgentSummaryChips
adapter={agent.adapter}
modelLabel={agent.modelId ?? null}
reasoningEffort={agent.reasoningEffort ?? null}
adapterHealth={adapterHealth}
/>
</div>
</div>
</button>
)
}
/**
* Tooltip-only label helper kept exported in case the tile row needs to
* show "Codex agent" or similar in a future state. Inlined fallback for
* the rare `unknown` adapter rendering path.
*/
export function railRowAdapterLabel(agent: HarnessAgent): string {
return adapterLabel(agent.adapter)
}

View File

@@ -1,179 +0,0 @@
import { ArrowLeft, Home } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
import { cn } from '@/lib/utils'
interface ConversationHeaderProps {
agent: HarnessAgent | null
fallbackName: string
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
adapterHealth: AgentAdapterHealth | null
backLabel: string
backTarget: 'home' | 'page'
onGoHome: () => void
onPinToggle: (next: boolean) => void
}
/**
* Strip above the chat. Mirrors the `/agents` row card's title row +
* summary chips so the user gets adapter health, pin state, and status
* at a glance — but adds the meta line (last used · lifetime tokens ·
* queued) that's specific to this surface.
*
* The mobile `lg:hidden` Back button is preserved so the small-screen
* collapse keeps a navigable header without a sidebar.
*/
export const ConversationHeader: FC<ConversationHeaderProps> = ({
agent,
fallbackName,
fallbackAdapter,
adapterHealth,
backLabel,
backTarget,
onGoHome,
onPinToggle,
}) => {
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
const adapter = agent?.adapter ?? fallbackAdapter
const status: AgentLiveness = agent?.status ?? 'unknown'
const lastUsedAt = agent?.lastUsedAt ?? null
const pinned = agent?.pinned ?? false
const queueCount = agent?.queue?.length ?? 0
const tokens = agent?.tokens ?? null
const lifetimeTotal = tokens
? tokens.cumulative.input + tokens.cumulative.output
: 0
const metaParts: string[] = []
if (lastUsedAt !== null) metaParts.push(formatRelativeTime(lastUsedAt))
if (lifetimeTotal > 0) metaParts.push(`${formatTokens(lifetimeTotal)} tokens`)
if (queueCount > 0) {
metaParts.push(queueCount === 1 ? '1 queued' : `${queueCount} queued`)
}
return (
<div className="flex min-h-[60px] shrink-0 items-center justify-between gap-4 px-5 py-2.5">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
className="size-8 shrink-0 rounded-xl lg:hidden"
title={backLabel}
>
<BackIcon className="size-4" />
</Button>
<div className="group min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-[15px] leading-6">
{agent?.name || fallbackName}
</span>
{agent ? (
<PinToggle pinned={pinned} onToggle={onPinToggle} />
) : null}
</div>
<div className="mt-0.5 flex items-center gap-2">
<AgentSummaryChips
adapter={adapter}
modelLabel={agent?.modelId ?? null}
reasoningEffort={agent?.reasoningEffort ?? null}
adapterHealth={adapterHealth}
/>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StatusPill
status={status}
hasActiveTurn={Boolean(agent?.activeTurnId)}
/>
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
<span className="truncate">
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
</span>
</div>
</div>
</div>
)
}
interface StatusPillProps {
status: AgentLiveness
hasActiveTurn: boolean
}
/**
* Working / Asleep / Attention all get distinctive styling; idle keeps
* the legacy emerald `Ready` pill so the default state is visually
* calm. Defensive working: `idle + activeTurnId` falls through to the
* working pill since the server says a turn is in flight.
*/
const StatusPill: FC<StatusPillProps> = ({ status, hasActiveTurn }) => {
const effective: AgentLiveness =
status === 'idle' && hasActiveTurn ? 'working' : status
const base =
'inline-flex items-center gap-2 rounded-full border px-3 py-0.5 text-[11px] uppercase tracking-[0.18em]'
if (effective === 'working') {
return (
<Badge
variant="secondary"
className={cn(
base,
'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-50',
)}
>
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
Working
</Badge>
)
}
if (effective === 'asleep') {
return (
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
Asleep
</Badge>
)
}
if (effective === 'error') {
return (
<Badge
variant="destructive"
className={cn(base, 'border-destructive/30')}
>
<span className="size-1.5 rounded-full bg-destructive-foreground" />
Attention
</Badge>
)
}
if (effective === 'idle') {
return (
<Badge
variant="outline"
className={cn(
base,
'border-emerald-200 bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
)}
>
<span className="size-1.5 rounded-full bg-emerald-500" />
Ready
</Badge>
)
}
return (
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
Setup
</Badge>
)
}

View File

@@ -11,7 +11,6 @@ import type {
AgentAdapterHealth,
AgentRowData,
} from './agent-row/agent-row.types'
import { compareAgentsByPinThenRecency } from './agents-list-order'
import type { AgentListItem } from './agents-page-types'
import type { AgentLiveness } from './LivenessDot'
@@ -57,18 +56,31 @@ export const AgentList: FC<AgentListProps> = ({
return map
}, [adapters])
// Sort: pinned rows first, then most recently used, then never-used
// agents in id-stable order. The gateway's `main` agent stays
// pinned-to-top when never touched so a fresh install has an
// obvious starting point.
const ordered = useMemo(() => {
const withMeta = agents.map((agent) => {
const harness = harnessAgentLookup?.get(agent.agentId)
return {
agent,
id: agent.agentId,
pinned: harness?.pinned ?? false,
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
}
})
return withMeta
.sort(compareAgentsByPinThenRecency)
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
const aSeed = a.agent.agentId === 'main' && a.lastUsedAt === null
const bSeed = b.agent.agentId === 'main' && b.lastUsedAt === null
if (aSeed && !bSeed) return -1
if (!aSeed && bSeed) return 1
const aValue = a.lastUsedAt ?? -Infinity
const bValue = b.lastUsedAt ?? -Infinity
if (aValue !== bValue) return bValue - aValue
return a.agent.agentId.localeCompare(b.agent.agentId)
})
.map((entry) => entry.agent)
}, [activity, agents, harnessAgentLookup])

View File

@@ -1,104 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { HarnessAgent } from './agent-harness-types'
import {
compareAgentsByPinThenRecency,
orderAgentsByPinThenRecency,
} from './agents-list-order'
function makeAgent(input: {
id: string
pinned?: boolean
lastUsedAt?: number | null
}): HarnessAgent {
return {
id: input.id,
name: input.id,
adapter: 'codex',
permissionMode: 'approve-all',
sessionKey: 'session',
createdAt: 0,
updatedAt: 0,
pinned: input.pinned,
lastUsedAt: input.lastUsedAt,
}
}
describe('orderAgentsByPinThenRecency', () => {
it('floats pinned agents to the top regardless of recency', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'a', pinned: false, lastUsedAt: 1_000 }),
makeAgent({ id: 'b', pinned: true, lastUsedAt: 100 }),
makeAgent({ id: 'c', pinned: false, lastUsedAt: 500 }),
])
expect(result.map((entry) => entry.id)).toEqual(['b', 'a', 'c'])
})
it('sorts by lastUsedAt desc within each pin group', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'older-pin', pinned: true, lastUsedAt: 100 }),
makeAgent({ id: 'newer-pin', pinned: true, lastUsedAt: 200 }),
makeAgent({ id: 'older', pinned: false, lastUsedAt: 50 }),
makeAgent({ id: 'newer', pinned: false, lastUsedAt: 80 }),
])
expect(result.map((entry) => entry.id)).toEqual([
'newer-pin',
'older-pin',
'newer',
'older',
])
})
it('seed-pins the gateway main agent above other never-used agents', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'main', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'zzz', pinned: false, lastUsedAt: null }),
])
expect(result.map((entry) => entry.id)).toEqual(['main', 'aaa', 'zzz'])
})
it('drops the main seed-pin once the agent has been used', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: 999 }),
makeAgent({ id: 'main', pinned: false, lastUsedAt: 1 }),
])
expect(result.map((entry) => entry.id)).toEqual(['aaa', 'main'])
})
it('puts never-used agents below recently-used ones', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'fresh', pinned: false, lastUsedAt: null }),
makeAgent({ id: 'used', pinned: false, lastUsedAt: 100 }),
])
expect(result.map((entry) => entry.id)).toEqual(['used', 'fresh'])
})
it('id-stable tiebreaks two agents with identical lastUsedAt', () => {
const result = orderAgentsByPinThenRecency([
makeAgent({ id: 'b', pinned: false, lastUsedAt: 100 }),
makeAgent({ id: 'a', pinned: false, lastUsedAt: 100 }),
])
expect(result.map((entry) => entry.id)).toEqual(['a', 'b'])
})
})
describe('compareAgentsByPinThenRecency', () => {
it('produces the same order as the harness-shape helper', () => {
const items = [
{ id: 'older', pinned: false, lastUsedAt: 50 },
{ id: 'newer', pinned: false, lastUsedAt: 80 },
{ id: 'pinned', pinned: true, lastUsedAt: 1 },
]
const sorted = [...items].sort(compareAgentsByPinThenRecency)
expect(sorted.map((item) => item.id)).toEqual(['pinned', 'newer', 'older'])
})
it('seeds the main agent above other never-used rows', () => {
const items = [
{ id: 'zzz', pinned: false, lastUsedAt: null },
{ id: 'main', pinned: false, lastUsedAt: null },
]
const sorted = [...items].sort(compareAgentsByPinThenRecency)
expect(sorted.map((item) => item.id)).toEqual(['main', 'zzz'])
})
})

View File

@@ -1,59 +0,0 @@
import type { HarnessAgent } from './agent-harness-types'
/**
* Stable ordering for index-shaped agent surfaces (the `/agents` rail
* and the chat-screen rail at `/agents/:agentId`). Pinned rows float
* to the top, then recency desc, with never-used agents falling to
* the bottom in id-stable order. The gateway's `main` agent gets
* seed-pinned to the top of the never-used group so a fresh install
* has an obvious starting point even before the user has used it.
*
* NOT the same rule as the home grid (`orderHomeAgents`): home is
* action-shaped — active-turn floats to the top — so users can
* resume what's running. The chat rail keeps recency stable so it
* doesn't reshuffle as turns transition every 5s.
*/
export function orderAgentsByPinThenRecency(
agents: HarnessAgent[],
): HarnessAgent[] {
return [...agents].sort((a, b) => {
const aPinned = a.pinned ?? false
const bPinned = b.pinned ?? false
if (aPinned !== bPinned) return aPinned ? -1 : 1
const aSeed = a.id === 'main' && (a.lastUsedAt ?? null) === null
const bSeed = b.id === 'main' && (b.lastUsedAt ?? null) === null
if (aSeed && !bSeed) return -1
if (!aSeed && bSeed) return 1
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
if (aValue !== bValue) return bValue - aValue
return a.id.localeCompare(b.id)
})
}
/**
* Same comparator, but operates over arbitrary records that carry
* `pinned`, `lastUsedAt`, and an `id`-equivalent key. Used by the
* `/agents` `AgentList` which pivots `AgentListItem` + harness
* lookup into a sortable shape; both surfaces stay on identical
* sort semantics through this adapter.
*/
export function compareAgentsByPinThenRecency<
T extends { pinned: boolean; lastUsedAt: number | null; id: string },
>(a: T, b: T): number {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
const aSeed = a.id === 'main' && a.lastUsedAt === null
const bSeed = b.id === 'main' && b.lastUsedAt === null
if (aSeed && !bSeed) return -1
if (!aSeed && bSeed) return 1
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
if (aValue !== bValue) return bValue - aValue
return a.id.localeCompare(b.id)
}

View File

@@ -38,8 +38,8 @@ browseros-cli install # downloads BrowserOS for your platform
# If BrowserOS is installed but not running
browseros-cli launch # opens BrowserOS, waits for server
# Configure the CLI with the Server URL from BrowserOS settings
browseros-cli init http://127.0.0.1:9000/mcp
# Configure the CLI (auto-discovers running BrowserOS)
browseros-cli init --auto # detects server URL and saves config
# Verify connection
browseros-cli health
@@ -52,7 +52,7 @@ browseros-cli init <url> # non-interactive — pass URL directly
browseros-cli init # interactive — prompts for URL
```
Config is saved to `~/.config/browseros-cli/config.yaml`. If `browseros-cli health` cannot connect, copy the current Server URL from BrowserOS Settings > BrowserOS MCP and run `browseros-cli init <Server URL>` again.
Config is saved to `~/.config/browseros-cli/config.yaml`. The CLI also auto-discovers the server from `~/.browseros/server.json` (written by BrowserOS on startup).
### CLI updates
@@ -126,9 +126,9 @@ To connect Claude Code, Gemini CLI, or any MCP client, see the [MCP setup guide]
| `--debug` | `BOS_DEBUG=1` | Debug output |
| `--timeout, -t` | | Request timeout (default: 2m) |
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init <Server URL>`.
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
## Testing
@@ -179,7 +179,7 @@ apps/cli/
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
├── cmd/
│ ├── root.go # Root command, global flags
│ ├── init.go # Server URL configuration (URL arg or interactive)
│ ├── init.go # Server URL configuration (URL arg, --auto, interactive)
│ ├── install.go # install (download BrowserOS for current platform)
│ ├── launch.go # launch (find and start BrowserOS, wait for server)
│ ├── open.go # open (new_page / new_hidden_page)

View File

@@ -17,6 +17,8 @@ import (
)
func init() {
var autoDiscover bool
cmd := &cobra.Command{
Use: "init [url]",
Short: "Configure the BrowserOS server connection",
@@ -32,8 +34,9 @@ You can provide the full URL or just the port number:
browseros-cli init http://127.0.0.1:9000/mcp
browseros-cli init 9000
Modes:
Three modes:
browseros-cli init <url> Non-interactive (full URL or port number)
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
browseros-cli init Interactive prompt`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.MaximumNArgs(1),
@@ -46,9 +49,22 @@ Modes:
switch {
case len(args) == 1:
// Non-interactive: URL provided as argument
input = args[0]
case autoDiscover:
// Auto-discover: server.json → config → probe common ports
discovered := probeRunningServer()
if discovered == "" {
output.Error("auto-discovery failed: no running BrowserOS found.\n\n"+
" If not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", 1)
}
input = discovered
fmt.Printf("Auto-discovered server at %s\n", input)
default:
// Interactive prompt (original behavior)
fmt.Println()
bold.Println("BrowserOS CLI Setup")
fmt.Println()
@@ -79,14 +95,12 @@ Modes:
output.Errorf(1, "invalid URL: %s", input)
}
// Verify connectivity
fmt.Printf("Checking connection to %s ...\n", baseURL)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
output.Errorf(1, "cannot connect to %s: %v\n\n"+
"Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n"+
"Then run: browseros-cli init <Server URL>\n"+
"Example: browseros-cli init http://127.0.0.1:9000/mcp", baseURL, err)
output.Errorf(1, "cannot connect to %s: %v\nIs BrowserOS running?", baseURL, err)
}
resp.Body.Close()
@@ -107,5 +121,6 @@ Modes:
},
}
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
rootCmd.AddCommand(cmd)
}

View File

@@ -28,7 +28,7 @@ Linux: Downloads AppImage (or .deb with --deb flag)
After installation:
browseros-cli launch # start BrowserOS
browseros-cli init <url> # configure the CLI with the Server URL`,
browseros-cli init --auto # configure the CLI`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
@@ -81,7 +81,7 @@ After installation:
fmt.Println()
bold.Println("Next steps:")
dim.Println(" browseros-cli launch # start BrowserOS")
dim.Println(" browseros-cli init <url> # use the Server URL from BrowserOS settings")
dim.Println(" browseros-cli init --auto # configure the CLI")
},
}

View File

@@ -1,7 +1,6 @@
package cmd
import (
"encoding/json"
"fmt"
"net/http"
"os"
@@ -39,7 +38,6 @@ If BrowserOS is already running, reports the server URL.`,
if url := probeRunningServer(); url != "" {
green.Printf("BrowserOS is already running at %s\n", url)
dim.Printf("Next: browseros-cli init %s\n", mcpEndpointURL(url))
return
}
@@ -65,7 +63,7 @@ If BrowserOS is already running, reports the server URL.`,
green.Printf("BrowserOS is ready at %s\n", url)
fmt.Println()
dim.Printf("Next: browseros-cli init %s\n", mcpEndpointURL(url))
dim.Println("Next: browseros-cli init --auto")
},
}
@@ -77,77 +75,39 @@ If BrowserOS is already running, reports the server URL.`,
// Server probing
// ---------------------------------------------------------------------------
var commonBrowserOSPorts = []int{9100, 9200, 9300}
// probeRunningServer checks launch discovery, explicit config, and common ports for a running server.
// probeRunningServer checks server.json, config, and common ports for a running server.
func probeRunningServer() string {
client := &http.Client{Timeout: 2 * time.Second}
check := func(baseURL string) bool {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
if url := loadBrowserosServerURL(); url != "" && checkServerHealth(client, url) {
// 1. server.json — written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" && check(url) {
return url
}
if url := defaultServerURL(); url != "" && checkServerHealth(client, url) {
// 2. Saved config / env var
if url := defaultServerURL(); url != "" && check(url) {
return url
}
return probeCommonServerPorts(client)
}
func checkServerHealth(client *http.Client, baseURL string) bool {
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
func probeCommonServerPorts(client *http.Client) string {
for _, port := range commonBrowserOSPorts {
// 3. Probe common BrowserOS ports as last resort
for _, port := range []int{9100, 9200, 9300} {
url := fmt.Sprintf("http://127.0.0.1:%d", port)
if checkServerHealth(client, url) {
if check(url) {
return url
}
}
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
// loadBrowserosServerURL reads BrowserOS's runtime discovery file for launch readiness only.
//
// Normal command resolution must not call this because it can override a URL the
// user explicitly saved with `browseros-cli init <Server URL>`.
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func mcpEndpointURL(baseURL string) string {
return strings.TrimSuffix(baseURL, "/") + "/mcp"
}
// ---------------------------------------------------------------------------
// Platform-native installation detection
// ---------------------------------------------------------------------------
@@ -157,8 +117,7 @@ func mcpEndpointURL(baseURL string) string {
// macOS: `open -Ra "BrowserOS"` — queries Launch Services (finds apps anywhere)
// Linux: checks /usr/bin/browseros (.deb), browseros.desktop, or AppImage files
// Windows: checks executable at %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
//
// and registry uninstall key (per-user Chromium install pattern)
// and registry uninstall key (per-user Chromium install pattern)
func isBrowserOSInstalled() bool {
switch runtime.GOOS {
case "darwin":
@@ -312,11 +271,14 @@ func waitForServer(maxWait time.Duration) (string, bool) {
for time.Now().Before(deadline) {
// server.json is written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" && checkServerHealth(client, url) {
return url, true
}
if url := probeCommonServerPorts(client); url != "" {
return url, true
if url := loadBrowserosServerURL(); url != "" {
resp, err := client.Get(url + "/health")
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return url, true
}
}
}
fmt.Print(".")
time.Sleep(1 * time.Second)

View File

@@ -1,99 +0,0 @@
package cmd
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"browseros-cli/config"
)
func TestProbeRunningServerUsesDiscoveryBeforeConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
discoveredServer := newHealthyServer(t)
configServer := newHealthyServer(t)
serverDir := filepath.Join(home, ".browseros")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
data := []byte(fmt.Sprintf(`{"url":%q}`, discoveredServer.URL))
if err := os.WriteFile(filepath.Join(serverDir, "server.json"), data, 0644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
if err := config.Save(&config.Config{ServerURL: configServer.URL}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := probeRunningServer()
if got != normalizeServerURL(discoveredServer.URL) {
t.Fatalf("probeRunningServer() = %q, want %q", got, normalizeServerURL(discoveredServer.URL))
}
}
func TestWaitForServerUsesCommonPortFallback(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
server := newHealthyServer(t)
port := serverPort(t, server.URL)
originalPorts := commonBrowserOSPorts
commonBrowserOSPorts = []int{port}
t.Cleanup(func() {
commonBrowserOSPorts = originalPorts
})
got, ok := waitForServer(100 * time.Millisecond)
if !ok {
t.Fatal("waitForServer() ok = false, want true")
}
if got != normalizeServerURL(server.URL) {
t.Fatalf("waitForServer() = %q, want %q", got, normalizeServerURL(server.URL))
}
}
func newHealthyServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
return server
}
func serverPort(t *testing.T, rawURL string) int {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
_, portText, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatalf("net.SplitHostPort() error = %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("strconv.Atoi() error = %v", err)
}
return port
}

View File

@@ -2,8 +2,10 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -287,15 +289,18 @@ func drainAutomaticUpdateCheckWithTimeout(done <-chan struct{}, timeout time.Dur
}
}
// defaultServerURL returns the implicit target from user-controlled settings only.
//
// BrowserOS writes a discovery file at runtime, but normal commands intentionally
// ignore it so a saved URL is not silently overridden by another running server.
func defaultServerURL() string {
// 1. Explicit env var always wins
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {
return env
}
// 2. Live discovery file from running BrowserOS (most current)
if url := loadBrowserosServerURL(); url != "" {
return url
}
// 3. Saved config (may be stale if port changed)
cfg, err := config.Load()
if err == nil {
if url := normalizeServerURL(cfg.ServerURL); url != "" {
@@ -306,6 +311,33 @@ func defaultServerURL() string {
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func normalizeServerURL(raw string) string {
normalized := strings.TrimSpace(raw)
@@ -337,10 +369,8 @@ func validateServerURL(raw string) (string, error) {
return "", fmt.Errorf(
"BrowserOS server URL is not configured.\n\n" +
" Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n" +
" Save it with: browseros-cli init <Server URL>\n" +
" Example: browseros-cli init http://127.0.0.1:9000/mcp\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install",
" If BrowserOS is running: browseros-cli init --auto\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install",
)
}

View File

@@ -1,13 +1,8 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"browseros-cli/config"
)
func TestSetVersionUpdatesRootCommand(t *testing.T) {
@@ -105,76 +100,6 @@ func TestShouldSkipAutomaticUpdates(t *testing.T) {
}
}
func TestDefaultServerURLUsesEnvBeforeConfig(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "http://127.0.0.1:9115/mcp")
if err := config.Save(&config.Config{ServerURL: "http://127.0.0.1:9000/mcp"}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := defaultServerURL()
if got != "http://127.0.0.1:9115" {
t.Fatalf("defaultServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestDefaultServerURLUsesSavedConfig(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
if err := config.Save(&config.Config{ServerURL: "http://127.0.0.1:9115/mcp"}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := defaultServerURL()
if got != "http://127.0.0.1:9115" {
t.Fatalf("defaultServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestDefaultServerURLIgnoresBrowserOSServerJSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
serverDir := filepath.Join(home, ".browseros")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
data := []byte(`{"url":"http://127.0.0.1:9999"}`)
if err := os.WriteFile(filepath.Join(serverDir, "server.json"), data, 0644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
if got := defaultServerURL(); got != "" {
t.Fatalf("defaultServerURL() = %q, want empty", got)
}
}
func TestNormalizeServerURLAcceptsMCPEndpoint(t *testing.T) {
got := normalizeServerURL(" http://127.0.0.1:9115/mcp ")
if got != "http://127.0.0.1:9115" {
t.Fatalf("normalizeServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestValidateServerURLExplainsManualInit(t *testing.T) {
_, err := validateServerURL("")
if err == nil {
t.Fatal("validateServerURL() error = nil, want setup instructions")
}
msg := err.Error()
if !strings.Contains(msg, "browseros-cli init <Server URL>") {
t.Fatalf("validateServerURL() error = %q, want manual init instructions", msg)
}
if strings.Contains(msg, "init --auto") {
t.Fatalf("validateServerURL() error = %q, should not mention init --auto", msg)
}
}
func TestDrainAutomaticUpdateCheckWithTimeoutWaitsForCompletion(t *testing.T) {
done := make(chan struct{})
returned := make(chan struct{})

View File

@@ -44,7 +44,10 @@ func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) {
session, err := sdkClient.Connect(ctx, transport, nil)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w%s", c.BaseURL, err, connectionSetupInstructions())
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
}
return session, nil
}
@@ -184,7 +187,10 @@ func (c *Client) Status() (map[string]any, error) {
func (c *Client) restGET(path string) (map[string]any, error) {
resp, err := c.HTTPClient.Get(c.BaseURL + path)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w%s", c.BaseURL, err, connectionSetupInstructions())
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
}
defer resp.Body.Close()
@@ -199,14 +205,3 @@ func (c *Client) restGET(path string) (map[string]any, error) {
}
return data, nil
}
// connectionSetupInstructions explains how to recover from a stale or missing server URL.
func connectionSetupInstructions() string {
return "\n\n" +
" Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n" +
" Save it with: browseros-cli init <Server URL>\n" +
" Example: browseros-cli init http://127.0.0.1:9000/mcp\n" +
" Run once with: browseros-cli --server <Server URL> health\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install"
}

View File

@@ -31,8 +31,8 @@ browseros-cli install
# Start BrowserOS
browseros-cli launch
# Configure MCP settings with the Server URL from BrowserOS settings
browseros-cli init http://127.0.0.1:9000/mcp
# Auto-configure MCP settings for your AI tools
browseros-cli init --auto
# Verify everything is working
browseros-cli health

View File

@@ -9,7 +9,6 @@ Evaluation framework for BrowserOS browser automation agents. Runs tasks from st
- **BrowserOS binary** at `/Applications/BrowserOS.app` (macOS) or `BROWSEROS_BINARY` pointing at it
- **Bun** runtime
- **API keys** for your LLM provider (and `CLAUDE_CODE_OAUTH_TOKEN` if you use `performance_grader`)
- **Python 3.10+ with `agisdk`** for AGI SDK / REAL Bench grading. Set `BROWSEROS_EVAL_PYTHON` if your default `python3` is older.
## Quick Start
@@ -68,7 +67,7 @@ This lets us run the same suite against multiple model setups without copying th
```txt
agisdk-daily-10 + kimi-fireworks
agisdk-daily-10 + claude-opus
agisdk-daily-10 + claude-sonnet
agisdk-daily-10 + clado-action-000159
```
@@ -80,7 +79,6 @@ For `orchestrator-executor` suites, there can also be an executor model/backend.
|------|-------------|
| `single` | Single LLM agent driven by the BrowserOS tool loop (CDP) |
| `orchestrator-executor` | High-level orchestrator + per-step executor (LLM or Clado visual model) |
| `claude-code` | External Claude Code CLI driven through BrowserOS MCP |
### Single agent
@@ -121,24 +119,6 @@ The orchestrator works with any LLM provider. The executor can be another LLM, o
}
```
### Claude Code
Claude Code runs as an external `claude -p` subprocess. The eval runner passes a task-scoped MCP config that points Claude Code at the active worker's BrowserOS MCP endpoint, while the eval capture layer still saves messages, screenshots, trajectory metadata, and grader outputs.
```json
{
"agent": {
"type": "claude-code",
"model": "opus"
}
}
```
```bash
BROWSEROS_EVAL_PYTHON=/path/to/python3 bun run eval run --config configs/legacy/claude-code-agisdk-real.json
bun run eval suite --config configs/legacy/claude-code-agisdk-real.json --publish r2
```
## Graders
| Name | Description |
@@ -171,7 +151,6 @@ The `apiKey` field supports two formats:
| `CLADO_ACTION_MODEL`, `CLADO_ACTION_API_KEY`, `CLADO_ACTION_BASE_URL` | Clado executor defaults |
| `BROWSEROS_BINARY` | BrowserOS binary path in CI/local smoke runs |
| `BROWSEROS_SERVER_URL` | Optional grader MCP URL override |
| `BROWSEROS_EVAL_PYTHON` | Optional Python interpreter for JSON graders such as `agisdk_state_diff` |
| `WEBARENA_INFINITY_DIR` | Local WebArena-Infinity checkout for Infinity tasks |
| `NOPECHA_API_KEY` | CAPTCHA solver extension |
| `EVAL_R2_ACCOUNT_ID`, `EVAL_R2_ACCESS_KEY_ID`, `EVAL_R2_SECRET_ACCESS_KEY`, `EVAL_R2_BUCKET`, `EVAL_R2_CDN_BASE_URL` | R2 upload and viewer URL |
@@ -215,7 +194,7 @@ Published runs are available at `EVAL_R2_CDN_BASE_URL/viewer.html?run=<run-id>`.
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"headless": true
}
```

View File

@@ -7,7 +7,7 @@
"baseUrl": "https://openrouter.ai/api/v1",
"supportsImages": true
},
"dataset": "../../data/agisdk-real.jsonl",
"dataset": "../../data/webbench-2of4-50.jsonl",
"num_workers": 10,
"restart_server_per_task": true,
"browseros": {
@@ -21,6 +21,6 @@
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["agisdk_state_diff"],
"graders": ["performance_grader"],
"timeout_ms": 1800000
}

View File

@@ -23,7 +23,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"headless": true
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"

View File

@@ -1,22 +0,0 @@
{
"agent": {
"type": "claude-code",
"model": "opus"
},
"dataset": "../../data/agisdk-real.jsonl",
"num_workers": 1,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["agisdk_state_diff"],
"timeout_ms": 1800000
}

View File

@@ -14,7 +14,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"headless": true
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"

View File

@@ -1,238 +0,0 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { DEFAULT_TIMEOUT_MS } from '../../constants'
import type { ClaudeCodeAgentConfig, UIMessageStreamEvent } from '../../types'
import { withEvalTimeout } from '../../utils/with-eval-timeout'
import type { AgentContext, AgentEvaluator, AgentResult } from '../types'
import {
type ClaudeCodeProcessRunner,
createClaudeCodeProcessRunner,
} from './process-runner'
import {
ClaudeCodeStreamParser,
shouldCaptureScreenshotForTool,
} from './stream-parser'
export interface ClaudeCodeEvaluatorDeps {
processRunner?: ClaudeCodeProcessRunner
}
export class ClaudeCodeEvaluator implements AgentEvaluator {
private processRunner: ClaudeCodeProcessRunner
constructor(
private ctx: AgentContext,
deps: ClaudeCodeEvaluatorDeps = {},
) {
this.processRunner = deps.processRunner ?? createClaudeCodeProcessRunner()
}
async execute(): Promise<AgentResult> {
const { config, task, capture, taskOutputDir } = this.ctx
const startTime = Date.now()
const timeoutMs = config.timeout_ms ?? DEFAULT_TIMEOUT_MS
await capture.messageLogger.logUser(task.query)
if (config.agent.type !== 'claude-code') {
throw new Error('ClaudeCodeEvaluator only supports claude-code config')
}
const agentConfig = config.agent
const mcpConfigPath = join(taskOutputDir, 'claude-code-mcp.json')
await writeFile(
mcpConfigPath,
JSON.stringify(
buildClaudeCodeMcpConfig(config.browseros.server_url),
null,
2,
),
)
const parser = new ClaudeCodeStreamParser()
const toolNamesById = new Map<string, string>()
const prompt = buildClaudeCodePrompt(task.query)
const args = buildClaudeCodeArgs({
prompt,
mcpConfigPath,
config: agentConfig,
})
const { terminationReason } = await withEvalTimeout(
timeoutMs,
capture,
async (signal) => {
const runResult = await this.processRunner.run({
executable: agentConfig.claudePath,
args,
cwd: taskOutputDir,
signal,
onStdoutLine: async (line) => {
const events = parser.pushLine(line)
for (const event of events) {
await this.handleStreamEvent(event, toolNamesById)
}
},
})
if (runResult.exitCode !== 0) {
const message =
runResult.stderr.trim() ||
`Claude Code exited with status ${runResult.exitCode}`
capture.addError('agent_execution', message, {
exitCode: runResult.exitCode,
})
if (!parser.getLastText()) {
throw new Error(message)
}
}
for (const error of runResult.streamErrors ?? []) {
capture.addWarning(
'message_logging',
`Claude Code stream event processing failed: ${error}`,
)
}
return runResult
},
)
const endTime = Date.now()
const finalAnswer = parser.getLastText() ?? capture.getLastAssistantText()
const metadata = {
query_id: task.query_id,
dataset: task.dataset,
query: task.query,
started_at: new Date(startTime).toISOString(),
completed_at: new Date(endTime).toISOString(),
total_duration_ms: endTime - startTime,
total_steps: parser.getToolCallCount() || capture.getScreenshotCount(),
termination_reason: terminationReason,
final_answer: finalAnswer,
errors: capture.getErrors(),
warnings: capture.getWarnings(),
device_pixel_ratio: capture.screenshot.getDevicePixelRatio(),
agent_config: {
type: 'claude-code' as const,
model: agentConfig.model,
},
grader_results: {},
}
await capture.trajectorySaver.saveMetadata(metadata)
return {
metadata,
messages: capture.getMessages(),
finalAnswer,
}
}
private async handleStreamEvent(
event: UIMessageStreamEvent,
toolNamesById: Map<string, string>,
): Promise<void> {
const { capture, task } = this.ctx
let screenshot: number | undefined
if (event.type === 'tool-input-available') {
toolNamesById.set(event.toolCallId, event.toolName)
if (isPageInput(event.input)) {
capture.setActivePageId(event.input.page)
}
}
if (
event.type === 'tool-output-available' ||
event.type === 'tool-output-error'
) {
const toolName = toolNamesById.get(event.toolCallId)
if (toolName && shouldCaptureScreenshotForTool(toolName)) {
screenshot = await this.captureScreenshot()
}
}
await capture.messageLogger.logStreamEvent(event, screenshot)
capture.emitEvent(task.query_id, {
...event,
...(screenshot !== undefined && { screenshot }),
})
}
private async captureScreenshot(): Promise<number | undefined> {
const { capture, task } = this.ctx
try {
const screenshot = await capture.screenshot.capture(
capture.getActivePageId(),
)
capture.emitEvent(task.query_id, {
type: 'screenshot-captured',
screenshot,
})
return screenshot
} catch {
return undefined
}
}
}
function isPageInput(input: unknown): input is { page: number } {
return (
typeof input === 'object' &&
input !== null &&
'page' in input &&
typeof input.page === 'number'
)
}
function buildClaudeCodePrompt(taskQuery: string): string {
return [
'You are running inside BrowserOS eval.',
'Use the BrowserOS MCP tools to interact with the already-open browser and complete the user task.',
'When the task is complete, respond with the final answer only.',
'If blocked, explain the blocker clearly.',
'',
`Task: ${taskQuery}`,
].join('\n')
}
function buildClaudeCodeArgs({
prompt,
mcpConfigPath,
config,
}: {
prompt: string
mcpConfigPath: string
config: ClaudeCodeAgentConfig
}): string[] {
const args = [
'-p',
prompt,
'--mcp-config',
mcpConfigPath,
'--strict-mcp-config',
'--output-format',
'stream-json',
'--verbose',
]
if (config.model) args.push('--model', config.model)
args.push(...config.extraArgs)
return args
}
function buildClaudeCodeMcpConfig(serverUrl: string) {
const trimmed = serverUrl.replace(/\/$/, '')
const url = trimmed.endsWith('/mcp') ? trimmed : `${trimmed}/mcp`
return {
mcpServers: {
browseros: {
type: 'http',
url,
headers: { 'X-BrowserOS-Source': 'sdk-internal' },
},
},
}
}

View File

@@ -1,114 +0,0 @@
export interface ClaudeCodeRunOptions {
executable: string
args: string[]
cwd: string
signal?: AbortSignal
onStdoutLine: (line: string) => Promise<void>
}
export interface ClaudeCodeRunResult {
exitCode: number
stderr: string
streamErrors?: string[]
}
export interface ClaudeCodeProcessRunner {
run(options: ClaudeCodeRunOptions): Promise<ClaudeCodeRunResult>
}
export interface SpawnOptions {
cwd: string
signal?: AbortSignal
onStdoutLine: (line: string) => Promise<void>
}
export interface CreateClaudeCodeProcessRunnerDeps {
spawn?: (cmd: string[], options: SpawnOptions) => Promise<ClaudeCodeRunResult>
}
export function createClaudeCodeProcessRunner(
deps: CreateClaudeCodeProcessRunnerDeps = {},
): ClaudeCodeProcessRunner {
const spawn = deps.spawn ?? spawnClaudeCode
return {
run: async ({ executable, args, cwd, signal, onStdoutLine }) =>
spawn([executable, ...args], { cwd, signal, onStdoutLine }),
}
}
async function spawnClaudeCode(
cmd: string[],
options: SpawnOptions,
): Promise<ClaudeCodeRunResult> {
const proc = Bun.spawn({
cmd,
cwd: options.cwd,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
})
const abort = () => {
try {
proc.kill('SIGTERM')
} catch {
// Process may already have exited.
}
}
options.signal?.addEventListener('abort', abort, { once: true })
try {
const streamErrors: string[] = []
const stdoutPromise = readLines(
proc.stdout,
options.onStdoutLine,
streamErrors,
)
const stderrPromise = new Response(proc.stderr).text()
const exitCode = await proc.exited
await stdoutPromise
const stderr = await stderrPromise
return { exitCode, stderr, streamErrors }
} finally {
options.signal?.removeEventListener('abort', abort)
}
}
async function readLines(
stream: ReadableStream<Uint8Array>,
onLine: (line: string) => Promise<void>,
streamErrors: string[],
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
await emitLine(line, onLine, streamErrors)
}
}
buffer += decoder.decode()
if (buffer.length > 0) {
await emitLine(buffer, onLine, streamErrors)
}
}
async function emitLine(
line: string,
onLine: (line: string) => Promise<void>,
streamErrors: string[],
): Promise<void> {
try {
await onLine(line)
} catch (error) {
streamErrors.push(error instanceof Error ? error.message : String(error))
}
}

View File

@@ -1,142 +0,0 @@
import { randomUUID } from 'node:crypto'
import type { UIMessageStreamEvent } from '../../types'
type JsonObject = Record<string, unknown>
export class ClaudeCodeStreamParser {
private lastText: string | null = null
private toolCallCount = 0
pushLine(line: string): UIMessageStreamEvent[] {
const trimmed = line.trim()
if (!trimmed) return []
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
return []
}
if (!isObject(parsed)) return []
if (parsed.type === 'assistant') {
return this.parseAssistantMessage(parsed)
}
if (parsed.type === 'user') {
return this.parseUserMessage(parsed)
}
if (parsed.type === 'result' && typeof parsed.result === 'string') {
this.lastText = parsed.result
}
return []
}
getLastText(): string | null {
return this.lastText
}
getToolCallCount(): number {
return this.toolCallCount
}
private parseAssistantMessage(message: JsonObject): UIMessageStreamEvent[] {
const content = contentBlocks(message)
const events: UIMessageStreamEvent[] = []
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
const id = randomUUID()
this.lastText = block.text
events.push(
{ type: 'text-start', id },
{ type: 'text-delta', id, delta: block.text },
{ type: 'text-end', id },
)
} else if (
block.type === 'tool_use' &&
typeof block.id === 'string' &&
typeof block.name === 'string'
) {
this.toolCallCount++
events.push({
type: 'tool-input-available',
toolCallId: block.id,
toolName: block.name,
input: block.input,
})
}
}
return events
}
private parseUserMessage(message: JsonObject): UIMessageStreamEvent[] {
const content = contentBlocks(message)
const events: UIMessageStreamEvent[] = []
for (const block of content) {
if (
block.type !== 'tool_result' ||
typeof block.tool_use_id !== 'string'
) {
continue
}
if (block.is_error === true) {
events.push({
type: 'tool-output-error',
toolCallId: block.tool_use_id,
errorText: stringifyToolContent(block.content),
})
} else {
events.push({
type: 'tool-output-available',
toolCallId: block.tool_use_id,
output: normalizeToolContent(block.content),
})
}
}
return events
}
}
export function shouldCaptureScreenshotForTool(toolName: string): boolean {
if (!toolName.startsWith('mcp__browseros__')) return false
return !toolName.endsWith('__take_screenshot')
}
function contentBlocks(message: JsonObject): JsonObject[] {
const inner = isObject(message.message) ? message.message : message
return Array.isArray(inner.content) ? inner.content.filter(isObject) : []
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null
}
function normalizeToolContent(content: unknown): unknown {
if (!Array.isArray(content)) return content
return content.map((item) => {
if (
isObject(item) &&
item.type === 'text' &&
typeof item.text === 'string'
) {
return item.text
}
return item
})
}
function stringifyToolContent(content: unknown): string {
const normalized = normalizeToolContent(content)
if (typeof normalized === 'string') return normalized
try {
return JSON.stringify(normalized)
} catch {
return String(normalized)
}
}

View File

@@ -1,4 +1,3 @@
import { ClaudeCodeEvaluator } from './claude-code'
import { OrchestratorExecutorEvaluator } from './orchestrator-executor'
import { SingleAgentEvaluator } from './single-agent'
import type { AgentContext, AgentEvaluator } from './types'
@@ -9,8 +8,6 @@ export function createAgent(context: AgentContext): AgentEvaluator {
return new SingleAgentEvaluator(context)
case 'orchestrator-executor':
return new OrchestratorExecutorEvaluator(context)
case 'claude-code':
return new ClaudeCodeEvaluator(context)
}
}

View File

@@ -105,10 +105,7 @@ export class TrajectorySaver {
errors: [],
warnings: [],
agent_config: {
type: agentConfig.type as
| 'single'
| 'orchestrator-executor'
| 'claude-code',
type: agentConfig.type as 'single' | 'orchestrator-executor',
model: agentConfig.model,
},
grader_results: {},

View File

@@ -82,16 +82,6 @@ function suiteToEvalConfig(
})
}
if (suite.agent.type === 'claude-code') {
return EvalConfigSchema.parse({
...base,
agent: {
type: 'claude-code',
...(variant.agent.model && { model: variant.agent.model }),
},
})
}
const executorBackend = suite.agent.executorBackend ?? 'tool-loop'
const executor =
executorBackend === 'clado'
@@ -145,10 +135,7 @@ export async function resolveSuiteCommand(
const loaded = await loadSuite(options.suitePath)
const variant = resolveVariant({
variantId: options.variantId,
provider:
loaded.suite.agent.type === 'claude-code'
? 'claude-code'
: options.provider,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,

View File

@@ -2,7 +2,6 @@ export interface PythonEvaluatorOptions {
scriptPath: string
input: unknown
timeoutMs: number
pythonPath?: string
}
export interface PythonEvaluatorResult<T> {
@@ -16,9 +15,7 @@ export interface PythonEvaluatorResult<T> {
export async function runPythonJsonEvaluator<T>(
options: PythonEvaluatorOptions,
): Promise<PythonEvaluatorResult<T>> {
const pythonPath =
options.pythonPath || process.env.BROWSEROS_EVAL_PYTHON || 'python3'
const proc = Bun.spawn([pythonPath, options.scriptPath], {
const proc = Bun.spawn(['python3', options.scriptPath], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',

View File

@@ -33,13 +33,6 @@ function variantSource(config: EvalConfig): {
baseUrl?: string
supportsImages?: boolean
} {
if (config.agent.type === 'claude-code') {
return {
provider: 'claude-code',
model: config.agent.model ?? 'default',
}
}
const agent =
config.agent.type === 'single' ? config.agent : config.agent.orchestrator
if (!agent.model) {
@@ -83,7 +76,10 @@ export async function adaptEvalConfigFile(
suite: {
id,
dataset: evalConfig.dataset,
agent: suiteAgent(evalConfig, backend),
agent:
evalConfig.agent.type === 'single'
? { type: 'tool-loop' }
: { type: 'orchestrated', executorBackend: backend ?? 'tool-loop' },
graders: evalConfig.graders ?? [],
workers: evalConfig.num_workers,
restartBrowserPerTask: evalConfig.restart_server_per_task,
@@ -103,17 +99,3 @@ export async function adaptEvalConfigFile(
}),
}
}
function suiteAgent(
config: EvalConfig,
backend: ReturnType<typeof executorBackend>,
): EvalSuite['agent'] {
switch (config.agent.type) {
case 'single':
return { type: 'tool-loop' }
case 'orchestrator-executor':
return { type: 'orchestrated', executorBackend: backend ?? 'tool-loop' }
case 'claude-code':
return { type: 'claude-code' }
}
}

View File

@@ -57,30 +57,10 @@ export function resolveVariant(
options: ResolveVariantOptions = {},
): EvalVariant {
const env = options.env ?? process.env
const id = options.variantId ?? env.EVAL_VARIANT ?? 'default'
const provider =
options.provider ?? env.EVAL_AGENT_PROVIDER ?? 'openai-compatible'
const model = options.model ?? env.EVAL_AGENT_MODEL
if (provider === 'claude-code') {
const id = options.variantId ?? env.EVAL_VARIANT ?? 'claude-code'
return {
id,
agent: {
provider,
model: model ?? '',
},
publicMetadata: {
id,
agent: {
provider,
model: model || 'default',
apiKeyConfigured: false,
},
},
}
}
const id = options.variantId ?? env.EVAL_VARIANT ?? 'default'
const apiKey = options.apiKey ?? env.EVAL_AGENT_API_KEY
const apiKeyEnv =
options.apiKeyEnv ?? (options.apiKey ? undefined : 'EVAL_AGENT_API_KEY')

View File

@@ -8,7 +8,6 @@ export const SuiteAgentSchema = z
'single',
'orchestrated',
'orchestrator-executor',
'claude-code',
]),
executorBackend: z.enum(['tool-loop', 'clado']).optional(),
})

View File

@@ -19,19 +19,9 @@ export const OrchestratorExecutorConfigSchema = z.object({
}),
})
export const ClaudeCodeAgentConfigSchema = z
.object({
type: z.literal('claude-code'),
model: z.string().min(1).optional(),
claudePath: z.string().min(1).default('claude'),
extraArgs: z.array(z.string()).default([]),
})
.strict()
export const AgentConfigSchema = z.discriminatedUnion('type', [
SingleAgentConfigSchema,
OrchestratorExecutorConfigSchema,
ClaudeCodeAgentConfigSchema,
])
export const EvalConfigSchema = z.object({
@@ -63,6 +53,5 @@ export type SingleAgentConfig = z.infer<typeof SingleAgentConfigSchema>
export type OrchestratorExecutorConfig = z.infer<
typeof OrchestratorExecutorConfigSchema
>
export type ClaudeCodeAgentConfig = z.infer<typeof ClaudeCodeAgentConfigSchema>
export type AgentConfig = z.infer<typeof AgentConfigSchema>
export type EvalConfig = z.infer<typeof EvalConfigSchema>

View File

@@ -2,8 +2,6 @@
export {
type AgentConfig,
AgentConfigSchema,
type ClaudeCodeAgentConfig,
ClaudeCodeAgentConfigSchema,
type EvalConfig,
EvalConfigSchema,
type OrchestratorExecutorConfig,

View File

@@ -13,7 +13,7 @@ export const GraderResultSchema = z.object({
// Agent config in metadata
const AgentConfigMetaSchema = z
.object({
type: z.enum(['single', 'orchestrator-executor', 'claude-code']),
type: z.enum(['single', 'orchestrator-executor']),
model: z.string().optional(),
})
.passthrough()

View File

@@ -59,7 +59,7 @@ export async function validateConfig(
) {
envVarsToCheck.push(config.agent.apiKey)
}
} else if (config.agent.type === 'orchestrator-executor') {
} else {
const { orchestrator, executor } = config.agent
if (orchestrator.apiKey && isEnvVarName(orchestrator.apiKey)) {
envVarsToCheck.push(orchestrator.apiKey)

View File

@@ -1,268 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createAgent } from '../../src/agents'
import { ClaudeCodeEvaluator } from '../../src/agents/claude-code'
import { CaptureContext } from '../../src/capture/context'
import {
AgentConfigSchema,
type EvalConfig,
EvalConfigSchema,
type Task,
TaskMetadataSchema,
} from '../../src/types'
function config(): EvalConfig {
return {
agent: {
type: 'claude-code',
model: 'opus',
claudePath: 'claude',
extraArgs: [],
},
dataset: 'data/test.jsonl',
num_workers: 1,
restart_server_per_task: false,
browseros: {
server_url: 'http://127.0.0.1:9110',
base_cdp_port: 9010,
base_server_port: 9110,
base_extension_port: 9310,
load_extensions: false,
headless: false,
},
graders: [],
}
}
const task: Task = {
query_id: 'task-1',
dataset: 'test',
query: 'Find the title',
graders: [],
metadata: {
original_task_id: 'task-1',
},
}
describe('ClaudeCodeEvaluator', () => {
it('accepts claude-code config defaults without permission mode', () => {
const agent = AgentConfigSchema.parse({ type: 'claude-code' })
expect(agent).toEqual({
type: 'claude-code',
claudePath: 'claude',
extraArgs: [],
})
})
it('accepts claude-code as a runnable eval agent', () => {
const parsed = EvalConfigSchema.parse({
agent: {
type: 'claude-code',
model: 'opus',
},
dataset: 'data/test-set.jsonl',
browseros: {
server_url: 'http://127.0.0.1:9110',
},
})
expect(parsed.agent.type).toBe('claude-code')
expect(parsed.agent.model).toBe('opus')
})
it('rejects unsupported claude-code settings instead of silently ignoring them', () => {
expect(
AgentConfigSchema.safeParse({
type: 'claude-code',
permissionMode: 'bypassPermissions',
}).success,
).toBe(false)
expect(
AgentConfigSchema.safeParse({
type: 'claude-code',
maxTurns: 3,
}).success,
).toBe(false)
})
it('allows claude-code in task metadata', () => {
const metadata = TaskMetadataSchema.parse({
query_id: 'task-1',
dataset: 'test',
query: 'Do the thing',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
total_duration_ms: 100,
total_steps: 1,
termination_reason: 'completed',
final_answer: 'done',
errors: [],
warnings: [],
agent_config: {
type: 'claude-code',
model: 'opus',
},
grader_results: {},
})
expect(metadata.agent_config.type).toBe('claude-code')
})
it('is created by the agent factory', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const agent = createAgent({
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
})
expect(agent).toBeInstanceOf(ClaudeCodeEvaluator)
})
it('runs claude code, logs messages, writes MCP config, and saves metadata', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const calls: Array<{ executable: string; args: string[]; cwd: string }> = []
const evaluator = new ClaudeCodeEvaluator(
{
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
},
{
processRunner: {
async run(options) {
calls.push(options)
await options.onStdoutLine(
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: 'The title is Example' }],
},
}),
)
await options.onStdoutLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'The title is Example',
}),
)
return { exitCode: 0, stderr: '' }
},
},
},
)
const result = await evaluator.execute()
expect(result.finalAnswer).toBe('The title is Example')
expect(result.metadata.agent_config).toMatchObject({
type: 'claude-code',
model: 'opus',
})
expect(result.messages.some((msg) => msg.type === 'user')).toBe(true)
expect(result.messages.some((msg) => msg.type === 'text-delta')).toBe(true)
const mcpConfig = JSON.parse(
await readFile(join(taskOutputDir, 'claude-code-mcp.json'), 'utf-8'),
)
expect(mcpConfig.mcpServers.browseros).toMatchObject({
type: 'http',
url: 'http://127.0.0.1:9110/mcp',
headers: {
'X-BrowserOS-Source': 'sdk-internal',
},
})
expect(calls).toEqual([
expect.objectContaining({
executable: 'claude',
cwd: taskOutputDir,
args: [
'-p',
expect.stringContaining('Task: Find the title'),
'--mcp-config',
join(taskOutputDir, 'claude-code-mcp.json'),
'--strict-mcp-config',
'--output-format',
'stream-json',
'--verbose',
'--model',
'opus',
],
}),
])
expect(calls[0].args).not.toContain('--permission-mode')
})
it('records non-fatal stream processing errors as warnings', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const evaluator = new ClaudeCodeEvaluator(
{
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
},
{
processRunner: {
async run(options) {
await options.onStdoutLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'done',
}),
)
return {
exitCode: 0,
stderr: '',
streamErrors: ['bad stream line'],
}
},
},
},
)
const result = await evaluator.execute()
expect(result.finalAnswer).toBe('done')
expect(result.metadata.warnings).toEqual([
expect.objectContaining({
source: 'message_logging',
message: 'Claude Code stream event processing failed: bad stream line',
}),
])
})
})

View File

@@ -1,78 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createClaudeCodeProcessRunner } from '../../src/agents/claude-code/process-runner'
async function writeStdoutScript(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'claude-code-runner-'))
const script = join(dir, 'stdout-lines')
await writeFile(script, '#!/bin/sh\nprintf "first\\nbad\\nlast\\n"\n')
await chmod(script, 0o755)
return script
}
describe('createClaudeCodeProcessRunner', () => {
it('passes executable and args to the spawn dependency', async () => {
const calls: unknown[] = []
const runner = createClaudeCodeProcessRunner({
spawn: async (cmd, options) => {
calls.push({ cmd, options })
await options.onStdoutLine('{"type":"result","result":"done"}')
return { exitCode: 0, stderr: '' }
},
})
const result = await runner.run({
executable: 'claude',
args: ['-p', 'hello'],
cwd: '/tmp',
signal: new AbortController().signal,
onStdoutLine: async () => {},
})
expect(result.exitCode).toBe(0)
expect(calls).toEqual([
{
cmd: ['claude', '-p', 'hello'],
options: expect.objectContaining({ cwd: '/tmp' }),
},
])
})
it('returns stderr and non-zero exit codes', async () => {
const runner = createClaudeCodeProcessRunner({
spawn: async () => ({ exitCode: 2, stderr: 'bad auth' }),
})
const result = await runner.run({
executable: 'claude',
args: [],
cwd: '/tmp',
signal: new AbortController().signal,
onStdoutLine: async () => {},
})
expect(result).toEqual({ exitCode: 2, stderr: 'bad auth' })
})
it('continues reading stdout after a line handler error', async () => {
const script = await writeStdoutScript()
const lines: string[] = []
const runner = createClaudeCodeProcessRunner()
const result = await runner.run({
executable: script,
args: [],
cwd: '/tmp',
onStdoutLine: async (line) => {
lines.push(line)
if (line === 'bad') throw new Error('bad line')
},
})
expect(result.exitCode).toBe(0)
expect(result.streamErrors).toEqual(['bad line'])
expect(lines).toEqual(['first', 'bad', 'last'])
})
})

View File

@@ -1,102 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
ClaudeCodeStreamParser,
shouldCaptureScreenshotForTool,
} from '../../src/agents/claude-code/stream-parser'
describe('ClaudeCodeStreamParser', () => {
it('maps assistant text and MCP tool use into eval stream events', () => {
const parser = new ClaudeCodeStreamParser()
const events = parser.pushLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'I will navigate.' },
{
type: 'tool_use',
id: 'toolu_1',
name: 'mcp__browseros__navigate_page',
input: { page: 2, url: 'https://example.com' },
},
],
},
}),
)
expect(events).toEqual([
{ type: 'text-start', id: expect.any(String) },
{
type: 'text-delta',
id: expect.any(String),
delta: 'I will navigate.',
},
{ type: 'text-end', id: expect.any(String) },
{
type: 'tool-input-available',
toolCallId: 'toolu_1',
toolName: 'mcp__browseros__navigate_page',
input: { page: 2, url: 'https://example.com' },
},
])
expect(parser.getLastText()).toBe('I will navigate.')
expect(parser.getToolCallCount()).toBe(1)
})
it('maps Claude Code tool results into eval output events', () => {
const parser = new ClaudeCodeStreamParser()
const events = parser.pushLine(
JSON.stringify({
type: 'user',
message: {
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: 'Navigated successfully',
},
],
},
}),
)
expect(events).toEqual([
{
type: 'tool-output-available',
toolCallId: 'toolu_1',
output: 'Navigated successfully',
},
])
})
it('uses result messages as the authoritative final text', () => {
const parser = new ClaudeCodeStreamParser()
parser.pushLine(
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: 'I will complete the task.' }],
},
}),
)
parser.pushLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'Final answer',
}),
)
expect(parser.getLastText()).toBe('Final answer')
})
it('identifies BrowserOS MCP tools that should trigger screenshots', () => {
expect(
shouldCaptureScreenshotForTool('mcp__browseros__navigate_page'),
).toBe(true)
expect(
shouldCaptureScreenshotForTool('mcp__browseros__take_screenshot'),
).toBe(false)
expect(shouldCaptureScreenshotForTool('Read')).toBe(false)
})
})

View File

@@ -7,11 +7,8 @@ import {
runSuiteCommand,
} from '../../src/cli/commands/suite'
import type { RunEvalOptions } from '../../src/runner/types'
import type { EvalSuite } from '../../src/suites/schema'
async function writeTempSuite(
overrides: Partial<EvalSuite> = {},
): Promise<{ dir: string; suitePath: string }> {
async function writeTempSuite(): Promise<{ dir: string; suitePath: string }> {
const dir = await mkdtemp(join(tmpdir(), 'eval-suite-cli-'))
const suitePath = join(dir, 'agisdk-daily-10.json')
await writeFile(
@@ -26,9 +23,8 @@ async function writeTempSuite(
restartBrowserPerTask: true,
browseros: {
server_url: 'http://127.0.0.1:9110',
headless: false,
headless: true,
},
...overrides,
},
null,
2,
@@ -47,7 +43,9 @@ describe('suite command', () => {
expect(resolved.kind).toBe('config')
expect(resolved.suite.id).toBe('browseros-agent-weekly')
expect(resolved.evalConfig.dataset).toBe('../../data/agisdk-real.jsonl')
expect(resolved.evalConfig.dataset).toBe(
'../../data/webbench-2of4-50.jsonl',
)
expect(resolved.variant.publicMetadata.agent.apiKeyConfigured).toBe(true)
})
@@ -77,25 +75,6 @@ describe('suite command', () => {
expect(resolved.evalConfig.num_workers).toBe(2)
})
it('resolves claude-code suites without provider API credentials', async () => {
const { dir, suitePath } = await writeTempSuite({
agent: { type: 'claude-code' },
})
const resolved = await resolveSuiteCommand({
suitePath,
model: 'opus',
env: {},
})
expect(resolved.kind).toBe('suite')
expect(resolved.evalConfig.agent).toMatchObject({
type: 'claude-code',
model: 'opus',
})
expect(resolved.datasetPath).toBe(join(dir, 'tasks.jsonl'))
})
it('runs config and suite commands through the runner dependency', async () => {
const calls: RunEvalOptions[] = []
await runSuiteCommand(

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test'
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runPythonJsonEvaluator } from '../../src/grading/python-evaluator'
@@ -11,17 +11,6 @@ async function writeScript(source: string): Promise<string> {
return script
}
async function writePythonWrapper(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'eval-python-wrapper-'))
const wrapper = join(dir, 'python-wrapper')
await writeFile(
wrapper,
'#!/bin/sh\necho custom-python >&2\nexec python3 "$@"\n',
)
await chmod(wrapper, 0o755)
return wrapper
}
describe('runPythonJsonEvaluator', () => {
it('sends JSON on stdin, captures stderr, and parses stdout JSON', async () => {
const script = await writeScript(`
@@ -60,34 +49,6 @@ sys.exit(3)
).rejects.toThrow('bad verifier')
})
it('uses BROWSEROS_EVAL_PYTHON when provided', async () => {
const script = await writeScript(`
import json, sys
data = json.loads(sys.stdin.read())
print(json.dumps({"ok": data["ok"]}))
`)
const wrapper = await writePythonWrapper()
const previousPythonPath = process.env.BROWSEROS_EVAL_PYTHON
process.env.BROWSEROS_EVAL_PYTHON = wrapper
try {
const result = await runPythonJsonEvaluator<{ ok: boolean }>({
scriptPath: script,
input: { ok: true },
timeoutMs: 5_000,
})
expect(result.output).toEqual({ ok: true })
expect(result.stderr).toContain('custom-python')
} finally {
if (previousPythonPath === undefined) {
delete process.env.BROWSEROS_EVAL_PYTHON
} else {
process.env.BROWSEROS_EVAL_PYTHON = previousPythonPath
}
}
})
it('enforces timeouts', async () => {
const script = await writeScript(`
import time

View File

@@ -1,18 +1,15 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { adaptEvalConfigFile } from '../../src/suites/config-adapter'
describe('adaptEvalConfigFile', () => {
it('preserves browseros-agent-weekly AGI SDK config semantics', async () => {
it('preserves browseros-agent-weekly config semantics', async () => {
const adapted = await adaptEvalConfigFile(
'apps/eval/configs/legacy/browseros-agent-weekly.json',
)
expect(adapted.suite.id).toBe('browseros-agent-weekly')
expect(adapted.suite.dataset).toBe('../../data/agisdk-real.jsonl')
expect(adapted.suite.graders).toEqual(['agisdk_state_diff'])
expect(adapted.suite.dataset).toBe('../../data/webbench-2of4-50.jsonl')
expect(adapted.suite.graders).toEqual(['performance_grader'])
expect(adapted.suite.workers).toBe(10)
expect(adapted.suite.restartBrowserPerTask).toBe(true)
expect(adapted.suite.timeoutMs).toBe(1_800_000)
@@ -37,33 +34,4 @@ describe('adaptEvalConfigFile', () => {
'secret-openrouter-value',
)
})
it('adapts claude-code configs without provider credentials', async () => {
const dir = await mkdtemp(join(tmpdir(), 'claude-code-config-'))
const configPath = join(dir, 'claude-code-agisdk.json')
await writeFile(
configPath,
JSON.stringify({
agent: {
type: 'claude-code',
model: 'opus',
},
dataset: 'tasks.jsonl',
num_workers: 1,
restart_server_per_task: false,
browseros: {
server_url: 'http://127.0.0.1:9110',
headless: false,
},
}),
)
const adapted = await adaptEvalConfigFile(configPath, { env: {} })
expect(adapted.suite.agent).toEqual({ type: 'claude-code' })
expect(adapted.variant.agent).toMatchObject({
provider: 'claude-code',
model: 'opus',
})
})
})

View File

@@ -35,16 +35,6 @@ describe('EvalSuiteSchema', () => {
expect(parsed.success).toBe(false)
})
it('validates claude-code suites', () => {
const suite = EvalSuiteSchema.parse({
id: 'claude-code-agisdk',
dataset: 'data/agisdk-real.jsonl',
agent: { type: 'claude-code' },
})
expect(suite.agent.type).toBe('claude-code')
})
it('validates the daily AGISDK 10-task suite', async () => {
const loaded = await loadSuite(
'apps/eval/configs/suites/agisdk-daily-10.json',
@@ -99,40 +89,4 @@ describe('resolveVariant', () => {
}),
).toThrow('EVAL_AGENT_API_KEY')
})
it('resolves claude-code variants without model or API key requirements', () => {
const variant = resolveVariant({
variantId: 'claude-opus',
provider: 'claude-code',
model: 'opus',
env: {},
})
expect(variant.id).toBe('claude-opus')
expect(variant.agent).toEqual({
provider: 'claude-code',
model: 'opus',
})
expect(variant.publicMetadata.agent).toEqual({
provider: 'claude-code',
model: 'opus',
apiKeyConfigured: false,
})
const defaultVariant = resolveVariant({
provider: 'claude-code',
env: {},
})
expect(defaultVariant.id).toBe('claude-code')
expect(defaultVariant.agent).toEqual({
provider: 'claude-code',
model: '',
})
expect(defaultVariant.publicMetadata.agent).toEqual({
provider: 'claude-code',
model: 'default',
apiKeyConfigured: false,
})
})
})

View File

@@ -311,49 +311,17 @@ export class ChatService {
contextChanges.length > 0
? `${contextChanges.map((c) => `[Context: ${c}]`).join('\n')}\n\n`
: ''
// Persist the *raw* user text in session.agent.messages so it
// round-trips clean to the client's useChat state and to any
// future history reload. The wrapped form (browser context +
// <selected_text> + <USER_QUERY>) is built as a transient prompt
// copy below — the LLM sees it, the user-visible state never
// does.
session.agent.appendUserMessage(request.message)
const promptUserText = contextPrefix + userContent
const wrappedUserMessageId =
session.agent.messages[session.agent.messages.length - 1]?.id
const promptUiMessages = filterValidMessages(session.agent.messages).map(
(msg) =>
msg.id === wrappedUserMessageId && msg.role === 'user'
? {
...msg,
parts: [{ type: 'text' as const, text: promptUserText }],
}
: msg,
)
session.agent.appendUserMessage(contextPrefix + userContent)
return createAgentUIStreamResponse({
agent: session.agent.toolLoopAgent,
uiMessages: promptUiMessages,
uiMessages: filterValidMessages(session.agent.messages),
abortSignal,
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
// The agent loop returns `messages` containing the prompt-
// wrapped user text. Restore the raw form before persisting
// so subsequent turns see the clean text and the client's
// local UIMessage matches what was originally typed.
const restored = messages.map((msg) =>
msg.id === wrappedUserMessageId && msg.role === 'user'
? {
...msg,
parts: [{ type: 'text' as const, text: request.message }],
}
: msg,
)
session.agent.messages = filterValidMessages(restored)
session.agent.messages = filterValidMessages(messages)
logger.info('Agent execution complete', {
conversationId: request.conversationId,
totalMessages: restored.length,
totalMessages: messages.length,
})
if (session?.hiddenPageId) {

View File

@@ -558,53 +558,13 @@ function mapToolUseToHistoryToolCall(
}
function userContentToText(content: AcpxUserContent): string {
if ('Text' in content) return unwrapBrowserosAcpUserMessage(content.Text)
if ('Text' in content) return unwrapBrowserosAcpPrompt(content.Text)
if ('Mention' in content) return content.Mention.content
if ('Image' in content) return content.Image.source ? '[image]' : ''
return ''
}
/**
* Strip the BrowserOS ACP envelopes from a user-message text so HTTP
* consumers (history endpoint, listing's `lastUserMessage`) see only
* the user's actual question. Two layers are added on the wire today:
*
* 1. <role>…</role>\n\n<user_request>…</user_request> from
* `buildBrowserosAcpPrompt` (outer).
* 2. ## Browser Context + <selected_text> + <USER_QUERY> from
* `apps/server/src/agent/format-message.ts` (inner).
*
* Each step is independently defensive — anchors that don't match are
* skipped — so partially-wrapped text (older persisted records,
* messages without a selection, future schema drift) gets best-
* effort cleaning without throwing. The function is idempotent;
* applying it to already-clean text is a no-op.
*
* TODO: drop this once acpx/runtime exposes a real system-prompt
* surface so we can stop persisting the role block on every user
* message. Tracked in the server architecture audit.
*/
export function unwrapBrowserosAcpUserMessage(raw: string): string {
if (!raw) return raw
let text = raw
// Order matters: the outer envelope is added AFTER
// `escapePromptTagText` runs over the inner formatUserMessage
// payload (see buildBrowserosAcpPrompt). So once the outer
// <role>…</role>+<user_request>…</user_request> tags are stripped,
// the inner content is still entity-escaped (`&lt;USER_QUERY&gt;`
// not `<USER_QUERY>`). We decode entities BEFORE the inner-envelope
// strips so their anchors actually match.
text = stripOuterRoleEnvelope(text)
text = decodeBasicEntities(text)
text = stripBrowserContextHeader(text)
text = stripSelectedTextBlock(text)
text = unwrapUserQuery(text)
return text.trim()
}
function stripOuterRoleEnvelope(value: string): string {
function unwrapBrowserosAcpPrompt(value: string): string {
const prefix = `${BROWSEROS_ACP_AGENT_INSTRUCTIONS}
<user_request>
@@ -612,41 +572,12 @@ function stripOuterRoleEnvelope(value: string): string {
const suffix = `
</user_request>`
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value
return value.slice(prefix.length, -suffix.length)
// TODO: nikhil: remove this once acpx/runtime exposes system prompt support.
return unescapePromptTagText(value.slice(prefix.length, -suffix.length))
}
function stripBrowserContextHeader(value: string): string {
// The `## Browser Context` block (when present) ends with the
// `\n\n---\n\n` separator emitted by `formatBrowserContext`.
// Anchored at the start of the string; non-greedy match through
// the body; one removal.
const match = value.match(/^## Browser Context\n[\s\S]*?\n\n---\n\n/)
return match ? value.slice(match[0].length) : value
}
function stripSelectedTextBlock(value: string): string {
// Optional `<selected_text [attrs]>…</selected_text>\n\n` block
// emitted by `formatUserMessage` when the user has a selection.
return value.replace(
/<selected_text(?:[^>]*)>\n[\s\S]*?\n<\/selected_text>\n\n/,
'',
)
}
function unwrapUserQuery(value: string): string {
// `formatUserMessage` always wraps the user's typed text in
// `<USER_QUERY>\n…\n</USER_QUERY>` — even when no browser context
// or selection is present.
const match = value.match(/^<USER_QUERY>\n([\s\S]*?)\n<\/USER_QUERY>$/)
return match ? match[1] : value
}
function decodeBasicEntities(value: string): string {
// Reverse the three escapes the server applied via
// `escapePromptTagText` so user-typed XML-like content (e.g.
// `<USER_QUERY>` typed literally) renders as the user typed it.
// Decode `&amp;` last to avoid double-decoding sequences like
// `&amp;lt;` → `&lt;` → `<`.
function unescapePromptTagText(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')

View File

@@ -298,9 +298,7 @@ describe('ChatService Klavis session rebuilds', () => {
const firstAgent = createFakeAgent()
const secondAgent = createFakeAgent()
agentToReturn = firstAgent
let lastPromptUiMessages: MockMessage[] | undefined
streamResponseHandler = async ({ onFinish, uiMessages }) => {
lastPromptUiMessages = uiMessages
await onFinish({ messages: uiMessages ?? [] })
return new Response('ok')
}
@@ -350,24 +348,13 @@ describe('ChatService Klavis session rebuilds', () => {
expect(createAgentSpy.mock.calls.length - createCallsBefore).toBe(2)
expect(firstAgent.dispose).toHaveBeenCalledTimes(1)
// Persisted form stays the raw user text — TKT-774. The Klavis
// context-change notice and the formatted user envelope go only
// into the transient prompt copy fed to the LLM.
expect(secondAgent.messages).toHaveLength(2)
const persistedRebuiltMessage =
secondAgent.messages[1]?.parts[0]?.text ?? ''
expect(persistedRebuiltMessage).toBe('check integrations again')
// Prompt copy (what the agent loop actually saw) carries the
// context-change prefix so the model knows about the new tools.
const promptRebuiltMessage =
lastPromptUiMessages?.at(-1)?.parts[0]?.text ?? ''
expect(promptRebuiltMessage).toContain(
const rebuiltMessage = secondAgent.messages[1]?.parts[0]?.text ?? ''
expect(rebuiltMessage).toContain(
'Klavis app integration tools are now available for the following connected apps: slack.',
)
expect(promptRebuiltMessage).not.toContain('klavis:pending')
expect(promptRebuiltMessage).not.toContain('klavis:connected')
expect(rebuiltMessage).not.toContain('klavis:pending')
expect(rebuiltMessage).not.toContain('klavis:connected')
})
it('does not rebuild a session with no enabled managed apps when Klavis connects', async () => {

View File

@@ -15,11 +15,7 @@ import type {
AcpRuntime as AcpxCoreRuntime,
} from 'acpx/runtime'
import { createRuntimeStore } from 'acpx/runtime'
import { formatUserMessage } from '../../../src/agent/format-message'
import {
AcpxRuntime,
unwrapBrowserosAcpUserMessage,
} from '../../../src/lib/agents/acpx-runtime'
import { AcpxRuntime } from '../../../src/lib/agents/acpx-runtime'
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
@@ -309,242 +305,6 @@ open &lt;example.com&gt;
])
})
it('strips the inner formatUserMessage envelope from history payloads', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(cwd, stateDir)
const timestamp = '2026-04-29T20:00:00.000Z'
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Browser bot',
adapter: 'codex',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
// Wrapped form persisted to the session record. Note that the
// inner formatUserMessage envelope's tags (`<selected_text>`,
// `<USER_QUERY>`) are escaped to `&lt;…&gt;` because
// `buildBrowserosAcpPrompt` runs `escapePromptTagText` over the
// entire payload before adding the outer envelope.
const wrapped = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
## Browser Context
**Active Tab:** Tab 1 (Page ID: 101) - "Example" (https://example.com)
---
&lt;selected_text (from "Example" — https://example.com)&gt;
quoted selection
&lt;/selected_text&gt;
&lt;USER_QUERY&gt;
summarise this
&lt;/USER_QUERY&gt;
</user_request>`
const record: AcpSessionRecord = {
schema: 'acpx.session.v1',
acpxRecordId: agent.sessionKey,
acpSessionId: 'sid-1',
agentSessionId: 'inner-1',
agentCommand: 'codex --acp',
cwd,
name: agent.sessionKey,
createdAt: timestamp,
lastUsedAt: timestamp,
lastSeq: 0,
eventLog: {
active_path: '',
segment_count: 0,
max_segment_bytes: 0,
max_segments: 0,
},
closed: false,
messages: [
{
User: {
id: 'user-1',
content: [{ Text: wrapped }],
},
},
],
updated_at: timestamp,
cumulative_token_usage: {},
request_token_usage: {},
acpx: {},
}
await createRuntimeStore({ stateDir }).save(record)
const history = await new AcpxRuntime({ cwd, stateDir }).getHistory({
agent,
sessionId: 'main',
})
expect(history.items[0]?.text).toBe('summarise this')
})
describe('unwrapBrowserosAcpUserMessage', () => {
it('returns clean text for input that has no envelope', () => {
expect(unwrapBrowserosAcpUserMessage('hello')).toBe('hello')
})
it('handles empty input', () => {
expect(unwrapBrowserosAcpUserMessage('')).toBe('')
})
it('strips a fully wrapped message and decodes escapes', () => {
// On-wire form: `escapePromptTagText` escapes the inner tags
// before the outer envelope is added.
const wrapped = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
## Browser Context
**Active Tab:** Tab 1 (Page ID: 101) - "Example" (https://example.com)
---
&lt;USER_QUERY&gt;
look at example
&lt;/USER_QUERY&gt;
</user_request>`
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe('look at example')
})
it('strips the inner envelope when only the inner wrapper is present', () => {
// Plain (un-escaped) inner-envelope-only input — covers the
// hypothetical case where some future code path stores the
// unwrapped-outer form directly.
const innerOnly = `## Browser Context
**Active Tab:** Tab 1
---
<USER_QUERY>
just inner
</USER_QUERY>`
expect(unwrapBrowserosAcpUserMessage(innerOnly)).toBe('just inner')
})
it('strips the outer envelope when only the outer wrapper is present', () => {
const outerOnly = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
just outer
</user_request>`
expect(unwrapBrowserosAcpUserMessage(outerOnly)).toBe('just outer')
})
it('removes a selected_text block with attribute string', () => {
const wrapped = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
&lt;selected_text (from "Title" — https://example.com)&gt;
selection body
&lt;/selected_text&gt;
&lt;USER_QUERY&gt;
question with selection
&lt;/USER_QUERY&gt;
</user_request>`
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
'question with selection',
)
})
it('is idempotent — applying twice equals applying once', () => {
const wrapped = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
## Browser Context
ctx
---
&lt;USER_QUERY&gt;
hello
&lt;/USER_QUERY&gt;
</user_request>`
const once = unwrapBrowserosAcpUserMessage(wrapped)
const twice = unwrapBrowserosAcpUserMessage(once)
expect(twice).toBe(once)
expect(twice).toBe('hello')
})
it('round-trips formatUserMessage output back to the user typed text', () => {
const userText = 'fix the OAuth redirect after login'
const formatted = formatUserMessage(userText, {
activeTab: {
id: 1,
url: 'https://example.com',
title: 'Example',
},
})
// Mirror what acpx-runtime.ts's buildBrowserosAcpPrompt does
// on the wire: escape the inner payload (so its tags survive
// round-trip serialisation) and then wrap with <role>…</role>
// + <user_request>…</user_request>. Constants/escape rules
// are duplicated here so the test pins the exact serialised
// shape rather than the helpers that produce it.
const escapeForPrompt = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const ROLE = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>`
const wrapped = `${ROLE}
<user_request>
${escapeForPrompt(formatted)}
</user_request>`
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(userText)
})
it('preserves user-typed angle-brackets via the entity decode', () => {
// `escapePromptTagText` escapes every `<` and `>` in the
// payload — including the inner envelope's own tags AND any
// user-typed tag-like content. The on-wire form below is what
// a user typing `<USER_QUERY>foo</USER_QUERY>` literally
// produces after formatUserMessage + buildBrowserosAcpPrompt.
const wrapped = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>
<user_request>
&lt;USER_QUERY&gt;
&lt;USER_QUERY&gt;foo&lt;/USER_QUERY&gt;
&lt;/USER_QUERY&gt;
</user_request>`
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
'<USER_QUERY>foo</USER_QUERY>',
)
})
})
it('continues the turn when runtime config control is unavailable', async () => {
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({

View File

@@ -13,12 +13,8 @@
"dev:watch:new": "./tools/dev/run.sh watch --new",
"dev:manual": "./tools/dev/run.sh watch --manual",
"dev:setup": "./tools/dev/run.sh setup",
"dev:cleanup": "./tools/dev/run.sh cleanup --target dev",
"dev:reset": "./tools/dev/run.sh reset --target dev",
"dev:cleanup:dogfood": "./tools/dev/run.sh cleanup --target dogfood",
"dev:reset:dogfood": "./tools/dev/run.sh reset --target dogfood",
"dev:cleanup:prod": "./tools/dev/run.sh cleanup --target prod",
"dev:reset:prod": "./tools/dev/run.sh reset --target prod",
"dev:cleanup": "./tools/dev/run.sh cleanup",
"dev:reset": "./tools/dev/run.sh reset",
"install:browseros-dogfood": "make -C tools/dogfood install",
"test:env": "./tools/dev/run.sh test",
"test:cleanup": "./tools/dev/run.sh cleanup --quick --yes",

View File

@@ -14,20 +14,16 @@ import (
var cleanupCmd = &cobra.Command{
Use: "cleanup",
Short: "Kill target processes and remove orphaned temp directories",
Long: "Stops target BrowserOS processes, clears target ports, and removes target temp directories.",
Short: "Kill port processes and remove orphaned temp directories",
Long: "Stops old dev watch processes, clears dev/test ports, and removes orphaned browseros-* temp directories.",
RunE: runCleanup,
}
var (
cleanupOnlyPorts bool
cleanupOnlyTemps bool
cleanupQuick bool
cleanupYes bool
cleanupTarget string
cleanupBrowserOSDir string
cleanupPortsValue string
cleanupBrowserUserDataDir string
cleanupPorts bool
cleanupTemps bool
cleanupQuick bool
cleanupYes bool
)
type safeCleanupOptions struct {
@@ -36,12 +32,8 @@ type safeCleanupOptions struct {
}
func init() {
cleanupCmd.Flags().StringVar(&cleanupTarget, "target", targetDev, "Cleanup target: dev, dogfood, or prod")
cleanupCmd.Flags().StringVar(&cleanupBrowserOSDir, "browseros-dir", "", "Override target BrowserOS state directory")
cleanupCmd.Flags().StringVar(&cleanupPortsValue, "ports", "", "Override ports as cdp,server,extension")
cleanupCmd.Flags().StringVar(&cleanupBrowserUserDataDir, "browser-user-data-dir", "", "Override BrowserOS user-data dir to stop")
cleanupCmd.Flags().BoolVar(&cleanupOnlyPorts, "only-ports", false, "Only kill port processes")
cleanupCmd.Flags().BoolVar(&cleanupOnlyTemps, "only-temps", false, "Only remove temp directories")
cleanupCmd.Flags().BoolVar(&cleanupPorts, "ports", false, "Only kill port processes")
cleanupCmd.Flags().BoolVar(&cleanupTemps, "temps", false, "Only remove temp directories")
cleanupCmd.Flags().BoolVar(&cleanupQuick, "quick", false, "Run safe cleanup only")
cleanupCmd.Flags().BoolVar(&cleanupYes, "yes", false, "Answer yes to the safe cleanup prompt")
rootCmd.AddCommand(cleanupCmd)
@@ -50,24 +42,11 @@ func init() {
// runCleanup performs the non-destructive daily cleanup path for local dev.
func runCleanup(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
root, err := proc.FindMonorepoRoot()
if err != nil {
return err
}
target, err := resolveResetTarget(root, resetTargetOptions{
Target: cleanupTarget,
BrowserOSDir: cleanupBrowserOSDir,
Ports: cleanupPortsValue,
BrowserUserDataDir: cleanupBrowserUserDataDir,
})
if err != nil {
return err
}
if !cleanupYes && !cleanupQuick {
ok, err := confirmYesNo(out, bufio.NewReader(os.Stdin), resetPrompt{
Title: "Run safe cleanup?",
Body: fmt.Sprintf("Stops %s processes, clears target ports, and removes target temp profiles. This does not touch saved BrowserOS data, Lima, containers, or images.", target.Name),
Action: "Run safe cleanup for " + target.Name,
Body: "Stops old dev watch processes, clears dev ports, and removes temporary /tmp browser profiles. This does not touch ~/.browseros-dev, Lima, containers, images, or saved dev data.",
Action: "Run safe cleanup",
})
if err != nil {
return err
@@ -77,51 +56,42 @@ func runCleanup(cmd *cobra.Command, args []string) error {
return nil
}
}
if err := ensureTargetStopped(out, target); err != nil {
return err
}
return runSafeCleanup(out, target, safeCleanupOptions{
ports: !cleanupOnlyTemps || cleanupOnlyPorts,
temps: !cleanupOnlyPorts || cleanupOnlyTemps,
return runSafeCleanup(out, safeCleanupOptions{
ports: !cleanupTemps || cleanupPorts,
temps: !cleanupPorts || cleanupTemps,
})
}
// runSafeCleanup is shared by cleanup and reset before any destructive repair steps.
func runSafeCleanup(out io.Writer, target resetTarget, opts safeCleanupOptions) error {
func runSafeCleanup(out io.Writer, opts safeCleanupOptions) error {
if opts.ports {
if target.WatchRunStateDir != "" {
stopped, err := proc.StopAllWatchProcessesInDir(target.WatchRunStateDir, 3*time.Second)
if err != nil {
return err
}
if stopped > 0 {
fmt.Fprintf(out, "%s stopped %d old %s watch process group(s)\n", successStyle.Sprint("Stopped:"), stopped, target.Name)
}
ports := proc.DefaultLocalPorts()
stopped, err := proc.StopAllWatchProcesses(3 * time.Second)
if err != nil {
return err
}
if len(target.BrowserUserDataDirs) > 0 {
killedBrowsers, err := proc.KillBrowserProcessesForUserDataDirs(target.BrowserUserDataDirs, 3*time.Second)
if err != nil {
return err
}
if killedBrowsers > 0 {
fmt.Fprintf(out, "%s stopped %d BrowserOS %s profile process(es)\n", successStyle.Sprint("Stopped:"), killedBrowsers, target.Name)
}
if stopped > 0 {
fmt.Fprintf(out, "%s stopped %d old dev watch process group(s)\n", successStyle.Sprint("Stopped:"), stopped)
}
if target.Ports != nil {
ports := *target.Ports
fmt.Fprintf(out, "%s ports %d, %d, %d\n", labelStyle.Sprint("Clearing:"), ports.CDP, ports.Server, ports.Extension)
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("Ports cleared."))
killedBrowsers, err := proc.KillBrowserProcessesForDevProfiles(3 * time.Second)
if err != nil {
return err
}
if killedBrowsers > 0 {
fmt.Fprintf(out, "%s stopped %d BrowserOS dev/test profile process(es)\n", successStyle.Sprint("Stopped:"), killedBrowsers)
}
fmt.Fprintf(out, "%s ports %d, %d, %d\n", labelStyle.Sprint("Clearing:"), ports.CDP, ports.Server, ports.Extension)
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("Ports cleared."))
}
if opts.temps {
n := proc.CleanupTempDirs(target.TempPrefixes...)
n := proc.CleanupTempDirs("browseros-test-", "browseros-dev-")
if n > 0 {
fmt.Fprintf(out, "%s removed %d temp directories\n", successStyle.Sprint("Removed:"), n)
} else if len(target.TempPrefixes) > 0 {
} else {
fmt.Fprintln(out, dimStyle.Sprint("No orphaned temp directories found."))
}
}

View File

@@ -64,11 +64,7 @@ func TestConfirmTypedRequiresExactToken(t *testing.T) {
func TestResetOverviewTellsUserToUseSmallestReset(t *testing.T) {
var out bytes.Buffer
printResetOverview(&out, resetTarget{
Title: "BrowserOS dev reset",
BrowserOSDir: "/Users/me/.browseros-dev",
DeleteRootLabel: "Delete dev profile:",
})
printResetOverview(&out, devPaths{Root: "/Users/me/.browseros-dev"})
text := out.String()
for _, want := range []string{

View File

@@ -1,197 +0,0 @@
package cmd
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"syscall"
"time"
)
const dogfoodStopTimeout = 10 * time.Second
type dogfoodRunState struct {
PID int `json:"pid"`
Mode string `json:"mode"`
SocketPath string `json:"socket_path"`
LogPath string `json:"log_path"`
}
type dogfoodIPCRequest struct {
Command string `json:"command"`
}
type dogfoodIPCResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
func ensureTargetStopped(out io.Writer, target resetTarget) error {
if target.Dogfood == nil {
return nil
}
return stopDogfoodRun(out, *target.Dogfood, dogfoodStopTimeout)
}
func stopDogfoodRun(out io.Writer, target dogfoodRuntimeTarget, timeout time.Duration) error {
active, err := dogfoodRunActive(target.LockPath)
if err != nil {
return err
}
if !active {
cleanupDogfoodRunFilesWithWarning(out, target)
return nil
}
fmt.Fprintln(out, labelStyle.Sprint("Stopping dogfood run first."))
if err := stopDogfoodDaemon(target); err == nil {
if stopped, err := waitForDogfoodStopped(out, target, timeout); err != nil {
return err
} else if stopped {
fmt.Fprintln(out, successStyle.Sprint("Dogfood stopped."))
return nil
}
}
state, err := readDogfoodRunState(target.StatePath)
if err != nil {
return fmt.Errorf("dogfood is running but state is unreadable at %s: %w", target.StatePath, err)
}
if state.PID <= 0 {
return fmt.Errorf("dogfood is running but state has no pid at %s", target.StatePath)
}
if err := signalDogfoodPID(state.PID, syscall.SIGTERM); err != nil {
return err
}
if stopped, err := waitForDogfoodStopped(out, target, timeout); err != nil {
return err
} else if stopped {
fmt.Fprintln(out, successStyle.Sprint("Dogfood stopped."))
return nil
}
if err := signalDogfoodPID(state.PID, syscall.SIGKILL); err != nil {
return err
}
if stopped, err := waitForDogfoodStopped(out, target, time.Second); err != nil {
return err
} else if stopped {
fmt.Fprintln(out, successStyle.Sprint("Dogfood force-stopped."))
return nil
}
return fmt.Errorf("dogfood is still running; stop it manually before cleanup/reset")
}
func stopDogfoodDaemon(target dogfoodRuntimeTarget) error {
socketPath := target.SocketPath
if state, err := readDogfoodRunState(target.StatePath); err == nil && state.SocketPath != "" {
socketPath = state.SocketPath
}
conn, err := net.DialTimeout("unix", socketPath, 700*time.Millisecond)
if err != nil {
return err
}
defer conn.Close()
data, err := json.Marshal(dogfoodIPCRequest{Command: "stop"})
if err != nil {
return err
}
data = append(data, '\n')
if _, err := conn.Write(data); err != nil {
return err
}
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
return errors.New("dogfood daemon closed connection without response")
}
var response dogfoodIPCResponse
if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
return err
}
if response.Error != "" {
return errors.New(response.Error)
}
if !response.OK {
return errors.New("dogfood daemon did not accept stop request")
}
return nil
}
func waitForDogfoodStopped(out io.Writer, target dogfoodRuntimeTarget, timeout time.Duration) (bool, error) {
deadline := time.Now().Add(timeout)
for {
active, err := dogfoodRunActive(target.LockPath)
if err != nil {
return false, err
}
if !active {
cleanupDogfoodRunFilesWithWarning(out, target)
return true, nil
}
if time.Now().After(deadline) {
return false, nil
}
time.Sleep(100 * time.Millisecond)
}
}
func dogfoodRunActive(lockPath string) (bool, error) {
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return false, err
}
defer file.Close()
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return true, nil
}
return false, err
}
return false, syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
}
func readDogfoodRunState(path string) (dogfoodRunState, error) {
data, err := os.ReadFile(path)
if err != nil {
return dogfoodRunState{}, err
}
var state dogfoodRunState
if err := json.Unmarshal(data, &state); err != nil {
return dogfoodRunState{}, err
}
return state, nil
}
func signalDogfoodPID(pid int, sig syscall.Signal) error {
if pid <= 0 {
return fmt.Errorf("invalid dogfood pid %d", pid)
}
if err := syscall.Kill(pid, sig); err != nil && err != syscall.ESRCH {
return err
}
return nil
}
func cleanupDogfoodRunFilesWithWarning(out io.Writer, target dogfoodRuntimeTarget) {
if err := cleanupDogfoodRunFiles(target); err != nil {
fmt.Fprintf(out, "%s could not remove dogfood run files: %v\n", warnStyle.Sprint("Warning:"), err)
}
}
func cleanupDogfoodRunFiles(target dogfoodRuntimeTarget) error {
if err := os.Remove(target.SocketPath); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Remove(target.StatePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@@ -1,37 +0,0 @@
package cmd
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestWaitForDogfoodStoppedWarnsWhenRunFileCleanupFails(t *testing.T) {
root := t.TempDir()
socketPath := filepath.Join(root, "dogfood.sock")
if err := os.Mkdir(socketPath, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(socketPath, "child"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
stopped, err := waitForDogfoodStopped(&out, dogfoodRuntimeTarget{
LockPath: filepath.Join(root, "run.lock"),
SocketPath: socketPath,
StatePath: filepath.Join(root, "state.json"),
}, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if !stopped {
t.Fatal("expected inactive dogfood run to be treated as stopped")
}
if !strings.Contains(out.String(), "Warning:") {
t.Fatalf("missing cleanup warning:\n%s", out.String())
}
}

View File

@@ -10,12 +10,11 @@ import (
"path/filepath"
"strings"
"browseros-dev/proc"
"github.com/spf13/cobra"
)
const (
devDirName = ".browseros-dev"
limaVMName = "browseros-vm"
openClawImage = "ghcr.io/openclaw/openclaw:2026.4.12"
openClawContainerName = "browseros-openclaw-openclaw-gateway-1"
@@ -24,11 +23,16 @@ const (
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Guide destructive BrowserOS profile and VM resets",
Long: "Walks through safe cleanup, VM shutdown/deletion, OpenClaw container/image removal, and target BrowserOS state reset.",
Short: "Guide destructive BrowserOS dev profile and VM resets",
Long: "Walks through safe cleanup, VM shutdown/deletion, OpenClaw container/image removal, and full ~/.browseros-dev reset.",
RunE: runReset,
}
type devPaths struct {
Root string
LimaHome string
}
type resetPrompt struct {
Title string
Body string
@@ -45,18 +49,7 @@ type podmanMachineEntry struct {
Running bool `json:"Running"`
}
var (
resetTargetName string
resetBrowserOSDir string
resetPortsValue string
resetBrowserUserDataDir string
)
func init() {
resetCmd.Flags().StringVar(&resetTargetName, "target", targetDev, "Reset target: dev, dogfood, or prod")
resetCmd.Flags().StringVar(&resetBrowserOSDir, "browseros-dir", "", "Override target BrowserOS state directory")
resetCmd.Flags().StringVar(&resetPortsValue, "ports", "", "Override ports as cdp,server,extension")
resetCmd.Flags().StringVar(&resetBrowserUserDataDir, "browser-user-data-dir", "", "Override BrowserOS user-data dir to stop")
rootCmd.AddCommand(resetCmd)
}
@@ -64,34 +57,21 @@ func init() {
func runReset(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
reader := bufio.NewReader(os.Stdin)
root, err := proc.FindMonorepoRoot()
if err != nil {
return err
}
target, err := resolveResetTarget(root, resetTargetOptions{
Target: resetTargetName,
BrowserOSDir: resetBrowserOSDir,
Ports: resetPortsValue,
BrowserUserDataDir: resetBrowserUserDataDir,
})
paths, err := resolveDevPaths()
if err != nil {
return err
}
printResetOverview(out, target)
if err := ensureTargetStopped(out, target); err != nil {
return err
}
printResetOverview(out, paths)
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Run safe cleanup first?",
Body: fmt.Sprintf("This stops %s processes, clears target ports, and removes target temp profiles. It does not touch saved BrowserOS data.", target.Name),
Action: "Run safe cleanup for " + target.Name,
Body: "This stops old dev watch processes, clears dev ports, and removes temporary /tmp browser profiles. It does not touch saved dev data.",
Action: "Run safe cleanup",
}); err != nil {
return err
} else if ok {
if err := runSafeCleanup(out, target, safeCleanupOptions{ports: true, temps: true}); err != nil {
if err := runSafeCleanup(out, safeCleanupOptions{ports: true, temps: true}); err != nil {
return err
}
}
@@ -102,28 +82,28 @@ func runReset(cmd *cobra.Command, args []string) error {
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteTargetRoot(out, reader, target)
return maybeDeleteDevProfile(out, reader, paths)
}
vm, err := findVM(limactlPath, target.LimaHome)
vm, err := findVM(limactlPath, paths.LimaHome)
if err != nil {
fmt.Fprintf(out, "%s could not inspect Lima VMs: %v\n", warnStyle.Sprint("Warning:"), err)
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteTargetRoot(out, reader, target)
return maybeDeleteDevProfile(out, reader, paths)
}
if vm == nil {
fmt.Fprintf(out, "%s %s was not found in %s.\n", dimStyle.Sprint("Not found:"), limaVMName, pathStyle.Sprint(target.LimaHome))
fmt.Fprintf(out, "%s %s was not found in %s.\n", dimStyle.Sprint("Not found:"), limaVMName, pathStyle.Sprint(paths.LimaHome))
if err := maybeResetLegacyPodman(out, reader); err != nil {
return err
}
return maybeDeleteTargetRoot(out, reader, target)
return maybeDeleteDevProfile(out, reader, paths)
}
fmt.Fprintf(out, "%s %s %s\n", labelStyle.Sprint("Found VM:"), commandStyle.Sprint(vm.Name), dimStyle.Sprintf("(%s)", vm.Status))
if strings.EqualFold(vm.Status, "Running") {
if err := maybeResetOpenClaw(out, reader, limactlPath, target.LimaHome); err != nil {
if err := maybeResetOpenClaw(out, reader, limactlPath, paths.LimaHome); err != nil {
return err
}
if ok, err := confirmYesNo(out, reader, resetPrompt{
@@ -133,7 +113,7 @@ func runReset(cmd *cobra.Command, args []string) error {
}); err != nil {
return err
} else if ok {
if err := runLimactl(out, limactlPath, target.LimaHome, "stop", limaVMName); err != nil {
if err := runLimactl(out, limactlPath, paths.LimaHome, "stop", limaVMName); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("VM stopped."))
@@ -145,12 +125,12 @@ func runReset(cmd *cobra.Command, args []string) error {
if ok, err := confirmYesNo(out, reader, resetPrompt{
Title: "Delete VM?",
Body: fmt.Sprintf("This deletes the Lima VM and its container store. %s remains. OpenClaw will be pulled again next time.", target.BrowserOSDir),
Body: "This deletes the Lima VM and its container store. ~/.browseros-dev remains. OpenClaw will be pulled again next time.",
Action: "Delete browseros-vm",
}); err != nil {
return err
} else if ok {
if err := runLimactl(out, limactlPath, target.LimaHome, "delete", "--force", limaVMName); err != nil {
if err := runLimactl(out, limactlPath, paths.LimaHome, "delete", "--force", limaVMName); err != nil {
return err
}
fmt.Fprintln(out, successStyle.Sprint("VM deleted."))
@@ -160,19 +140,35 @@ func runReset(cmd *cobra.Command, args []string) error {
return err
}
return maybeDeleteTargetRoot(out, reader, target)
return maybeDeleteDevProfile(out, reader, paths)
}
func printResetOverview(out io.Writer, target resetTarget) {
fmt.Fprintln(out, headerStyle.Sprint(target.Title))
func resolveDevPaths() (devPaths, error) {
if override := strings.TrimSpace(os.Getenv("BROWSEROS_DIR")); override != "" {
root, err := filepath.Abs(override)
if err != nil {
return devPaths{}, err
}
return devPaths{Root: root, LimaHome: filepath.Join(root, "lima")}, nil
}
home, err := os.UserHomeDir()
if err != nil {
return devPaths{}, err
}
root := filepath.Join(home, devDirName)
return devPaths{Root: root, LimaHome: filepath.Join(root, "lima")}, nil
}
func printResetOverview(out io.Writer, paths devPaths) {
fmt.Fprintln(out, headerStyle.Sprint("BrowserOS dev reset"))
fmt.Fprintln(out)
fmt.Fprintf(out, "This can reset parts of %s. Pick the smallest reset that matches the problem.\n", pathStyle.Sprint(target.BrowserOSDir))
fmt.Fprintf(out, "This can reset parts of %s. Pick the smallest reset that matches the problem.\n", pathStyle.Sprint(paths.Root))
fmt.Fprintln(out)
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Stop VM:"), dimStyle.Sprint("Shuts down browseros-vm. Keeps data."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Delete VM:"), dimStyle.Sprint("Removes Lima/container state. Keeps the target state root."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Delete VM:"), dimStyle.Sprint("Removes Lima/container state. Keeps the dev profile."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw container:"), dimStyle.Sprint("Keeps the downloaded OpenClaw image."))
fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw image:"), dimStyle.Sprint("Next startup pulls it again."))
fmt.Fprintf(out, " %s %s\n", warnStyle.Sprint(target.DeleteRootLabel), dimStyle.Sprint("Deletes the target BrowserOS state root."))
fmt.Fprintf(out, " %s %s\n", warnStyle.Sprint("Delete dev profile:"), dimStyle.Sprint("Deletes the dev profile root and dev-local BrowserOS data."))
fmt.Fprintln(out)
}
@@ -248,24 +244,24 @@ func maybeResetOpenClaw(out io.Writer, reader *bufio.Reader, limactlPath string,
return nil
}
func maybeDeleteTargetRoot(out io.Writer, reader *bufio.Reader, target resetTarget) error {
func maybeDeleteDevProfile(out io.Writer, reader *bufio.Reader, paths devPaths) error {
ok, err := confirmTyped(
out,
reader,
target.DeleteRootLabel,
fmt.Sprintf("This deletes %s. %s", pathStyle.Sprint(target.BrowserOSDir), target.DeleteRootBody),
"Delete dev profile?",
fmt.Sprintf("This deletes %s. It removes BrowserOS dev data plus VM/OpenClaw state.", pathStyle.Sprint(paths.Root)),
"DELETE",
)
if err != nil || !ok {
return err
}
if err := validateDevProfileRootForDeletion(target.BrowserOSDir); err != nil {
if err := validateDevProfileRootForDeletion(paths.Root); err != nil {
return err
}
if err := os.RemoveAll(target.BrowserOSDir); err != nil {
if err := os.RemoveAll(paths.Root); err != nil {
return err
}
fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), pathStyle.Sprint(target.BrowserOSDir))
fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), pathStyle.Sprint(paths.Root))
return nil
}

View File

@@ -1,365 +0,0 @@
package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"browseros-dev/proc"
"gopkg.in/yaml.v3"
)
const (
targetDev = "dev"
targetDogfood = "dogfood"
targetProd = "prod"
devDirName = ".browseros-dev"
prodDirName = ".browseros"
)
type resetTargetOptions struct {
Target string
BrowserOSDir string
Ports string
BrowserUserDataDir string
}
type resetTarget struct {
Name string
Title string
BrowserOSDir string
LimaHome string
Ports *proc.Ports
BrowserUserDataDirs []string
TempPrefixes []string
WatchRunStateDir string
DeleteRootLabel string
DeleteRootBody string
Dogfood *dogfoodRuntimeTarget
}
type dogfoodRuntimeTarget struct {
ConfigDir string
LockPath string
StatePath string
SocketPath string
}
type dogfoodConfigFile struct {
BrowserOSDir string `yaml:"browseros_dir"`
DevUserDataDir string `yaml:"dev_user_data_dir"`
Ports struct {
CDP int `yaml:"cdp"`
Server int `yaml:"server"`
Extension int `yaml:"extension"`
} `yaml:"ports"`
}
func resolveResetTarget(root string, opts resetTargetOptions) (resetTarget, error) {
target := strings.TrimSpace(opts.Target)
if target == "" {
target = targetDev
}
switch target {
case targetDev:
return resolveDevTarget(root, opts)
case targetDogfood:
return resolveDogfoodTarget(opts)
case targetProd:
return resolveProdTarget(opts)
default:
return resetTarget{}, fmt.Errorf("unsupported reset target %q", target)
}
}
func resolveDevTarget(root string, opts resetTargetOptions) (resetTarget, error) {
browserosDir, err := resolveBrowserOSDir(opts.BrowserOSDir, devDirName)
if err != nil {
return resetTarget{}, err
}
ports, err := resolveTargetPorts(root, opts.Ports)
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetDev,
Title: "BrowserOS dev reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
Ports: &ports,
BrowserUserDataDirs: []string{"/tmp/browseros-dev"},
TempPrefixes: []string{"browseros-test-", "browseros-dev-"},
WatchRunStateDir: filepath.Join(browserosDir, "runs"),
DeleteRootLabel: "Delete dev profile?",
DeleteRootBody: "It removes BrowserOS dev data plus VM/OpenClaw state.",
}, nil
}
func resolveDogfoodTarget(opts resetTargetOptions) (resetTarget, error) {
cfgDir, err := dogfoodConfigDir()
if err != nil {
return resetTarget{}, err
}
cfg, err := loadDogfoodConfig(filepath.Join(cfgDir, "config.yaml"))
if err != nil {
return resetTarget{}, err
}
applyDogfoodDefaults(&cfg, cfgDir)
browserosDir := firstNonEmpty(opts.BrowserOSDir, cfg.BrowserOSDir)
if browserosDir == "" {
return resetTarget{}, fmt.Errorf("dogfood browseros_dir is empty")
}
browserosDir, err = filepath.Abs(expandTilde(browserosDir))
if err != nil {
return resetTarget{}, err
}
ports, err := parsePorts(firstNonEmpty(opts.Ports, formatPorts(proc.Ports{
CDP: cfg.Ports.CDP,
Server: cfg.Ports.Server,
Extension: cfg.Ports.Extension,
})))
if err != nil {
return resetTarget{}, err
}
browserUserDataDir := firstNonEmpty(opts.BrowserUserDataDir, cfg.DevUserDataDir)
if browserUserDataDir == "" {
return resetTarget{}, fmt.Errorf("dogfood dev_user_data_dir is empty")
}
browserUserDataDir, err = filepath.Abs(expandTilde(browserUserDataDir))
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetDogfood,
Title: "BrowserOS dogfood reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
Ports: &ports,
BrowserUserDataDirs: []string{browserUserDataDir},
DeleteRootLabel: "Delete dogfood BrowserOS state?",
DeleteRootBody: "It removes dogfood-local BrowserOS server data plus VM/OpenClaw state. It does not touch your source BrowserOS browser profile.",
Dogfood: &dogfoodRuntimeTarget{
ConfigDir: cfgDir,
LockPath: filepath.Join(cfgDir, "run.lock"),
StatePath: filepath.Join(cfgDir, "state.json"),
SocketPath: filepath.Join(cfgDir, "daemon.sock"),
},
}, nil
}
func applyDogfoodDefaults(cfg *dogfoodConfigFile, cfgDir string) {
if cfg.BrowserOSDir == "" {
if home, err := os.UserHomeDir(); err == nil {
cfg.BrowserOSDir = filepath.Join(home, ".browseros-dogfood")
}
}
if cfg.DevUserDataDir == "" {
cfg.DevUserDataDir = filepath.Join(cfgDir, "profile")
}
if cfg.Ports.CDP == 0 {
cfg.Ports.CDP = 9015
}
if cfg.Ports.Server == 0 {
cfg.Ports.Server = 9115
}
if cfg.Ports.Extension == 0 {
cfg.Ports.Extension = 9315
}
}
func resolveProdTarget(opts resetTargetOptions) (resetTarget, error) {
browserosDir, err := resolveBrowserOSDir(opts.BrowserOSDir, prodDirName)
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetProd,
Title: "BrowserOS prod reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
DeleteRootLabel: "Delete prod BrowserOS state?",
DeleteRootBody: "It removes ~/.browseros server data plus VM/OpenClaw state. It does not delete your BrowserOS browser profile.",
}, nil
}
func resolveBrowserOSDir(override string, dirName string) (string, error) {
if strings.TrimSpace(override) != "" {
return filepath.Abs(expandTilde(strings.TrimSpace(override)))
}
if dirName == devDirName {
if env := strings.TrimSpace(os.Getenv("BROWSEROS_DIR")); env != "" {
return filepath.Abs(expandTilde(env))
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, dirName), nil
}
func resolveTargetPorts(root string, explicit string) (proc.Ports, error) {
if strings.TrimSpace(explicit) != "" {
return parsePorts(explicit)
}
for _, path := range []string{
filepath.Join(root, "apps/server/.env.development"),
filepath.Join(root, "apps/server/.env.example"),
} {
ports, ok, err := readPortsFromEnvFile(path)
if err != nil {
return proc.Ports{}, err
}
if ok {
return ports, nil
}
}
return proc.DefaultLocalPorts(), nil
}
func readPortsFromEnvFile(path string) (proc.Ports, bool, error) {
file, err := os.Open(path)
if os.IsNotExist(err) {
return proc.Ports{}, false, nil
}
if err != nil {
return proc.Ports{}, false, err
}
defer file.Close()
values := map[string]int{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, value, ok := parseEnvLine(scanner.Text())
if !ok {
continue
}
switch key {
case "BROWSEROS_CDP_PORT", "BROWSEROS_SERVER_PORT", "BROWSEROS_EXTENSION_PORT":
port, err := strconv.Atoi(value)
if err != nil {
return proc.Ports{}, false, fmt.Errorf("parse %s in %s: %w", key, path, err)
}
values[key] = port
}
}
if err := scanner.Err(); err != nil {
return proc.Ports{}, false, err
}
if len(values) != 3 {
return proc.Ports{}, false, nil
}
return proc.Ports{
CDP: values["BROWSEROS_CDP_PORT"],
Server: values["BROWSEROS_SERVER_PORT"],
Extension: values["BROWSEROS_EXTENSION_PORT"],
}, true, nil
}
func parseEnvLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(stripInlineComment(value))
value = strings.Trim(value, `"'`)
return key, value, key != "" && value != ""
}
func stripInlineComment(value string) string {
quote := byte(0)
for index := 0; index < len(value); index++ {
switch value[index] {
case '\'', '"':
if quote == 0 {
quote = value[index]
} else if quote == value[index] {
quote = 0
}
case '#':
if quote == 0 {
return value[:index]
}
}
}
return value
}
func parsePorts(value string) (proc.Ports, error) {
parts := strings.Split(value, ",")
if len(parts) != 3 {
return proc.Ports{}, fmt.Errorf("ports must be cdp,server,extension")
}
parsed := [3]int{}
for i, part := range parts {
port, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return proc.Ports{}, fmt.Errorf("parse port %q: %w", part, err)
}
if port <= 0 || port > 65535 {
return proc.Ports{}, fmt.Errorf("port %d out of range", port)
}
parsed[i] = port
}
return proc.Ports{CDP: parsed[0], Server: parsed[1], Extension: parsed[2]}, nil
}
func formatPorts(ports proc.Ports) string {
return fmt.Sprintf("%d,%d,%d", ports.CDP, ports.Server, ports.Extension)
}
func dogfoodConfigDir() (string, error) {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return filepath.Join(expandTilde(xdg), "browseros-dogfood"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "browseros-dogfood"), nil
}
func loadDogfoodConfig(path string) (dogfoodConfigFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return dogfoodConfigFile{}, fmt.Errorf("read dogfood config at %s: %w", path, err)
}
var cfg dogfoodConfigFile
if err := yaml.Unmarshal(data, &cfg); err != nil {
return dogfoodConfigFile{}, fmt.Errorf("parse dogfood config: %w", err)
}
return cfg, nil
}
func expandTilde(path string) string {
if path == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
}
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -1,166 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestResolveDevTargetReadsDevelopmentEnvPorts(t *testing.T) {
root := t.TempDir()
serverDir := filepath.Join(root, "apps/server")
if err := os.MkdirAll(serverDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(serverDir, ".env.development"), []byte(
"BROWSEROS_CDP_PORT=9101\nBROWSEROS_SERVER_PORT=9201\nBROWSEROS_EXTENSION_PORT=9301\n",
), 0o644); err != nil {
t.Fatal(err)
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("BROWSEROS_DIR", "")
target, err := resolveResetTarget(root, resetTargetOptions{Target: "dev"})
if err != nil {
t.Fatal(err)
}
if target.Ports == nil || target.Ports.CDP != 9101 || target.Ports.Server != 9201 || target.Ports.Extension != 9301 {
t.Fatalf("unexpected ports: %#v", target.Ports)
}
if target.BrowserOSDir != filepath.Join(home, ".browseros-dev") {
t.Fatalf("unexpected browseros dir: %s", target.BrowserOSDir)
}
}
func TestResolveDevTargetFallsBackToExampleEnvPorts(t *testing.T) {
root := t.TempDir()
serverDir := filepath.Join(root, "apps/server")
if err := os.MkdirAll(serverDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(serverDir, ".env.example"), []byte(
"BROWSEROS_CDP_PORT=9000\nBROWSEROS_SERVER_PORT=9100\nBROWSEROS_EXTENSION_PORT=9300\n",
), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", t.TempDir())
t.Setenv("BROWSEROS_DIR", "")
target, err := resolveResetTarget(root, resetTargetOptions{Target: "dev"})
if err != nil {
t.Fatal(err)
}
if target.Ports == nil || target.Ports.CDP != 9000 || target.Ports.Server != 9100 || target.Ports.Extension != 9300 {
t.Fatalf("unexpected ports: %#v", target.Ports)
}
}
func TestReadPortsFromEnvFileStripsHashComments(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(
"BROWSEROS_CDP_PORT=9005#comment\nBROWSEROS_SERVER_PORT=9105 # comment\nBROWSEROS_EXTENSION_PORT=9305\n",
), 0o644); err != nil {
t.Fatal(err)
}
ports, ok, err := readPortsFromEnvFile(path)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected ports to be found")
}
if ports.CDP != 9005 || ports.Server != 9105 || ports.Extension != 9305 {
t.Fatalf("unexpected ports: %#v", ports)
}
}
func TestResolveDogfoodTargetReadsDogfoodConfig(t *testing.T) {
root := t.TempDir()
xdgConfig := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdgConfig)
cfgDir := filepath.Join(xdgConfig, "browseros-dogfood")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(`
browseros_dir: /tmp/browseros-dogfood-state
dev_user_data_dir: /tmp/browseros-dogfood-profile
ports:
cdp: 9015
server: 9115
extension: 9315
`), 0o644); err != nil {
t.Fatal(err)
}
target, err := resolveResetTarget(root, resetTargetOptions{Target: "dogfood"})
if err != nil {
t.Fatal(err)
}
if target.BrowserOSDir != "/tmp/browseros-dogfood-state" {
t.Fatalf("unexpected browseros dir: %s", target.BrowserOSDir)
}
if target.Ports == nil || target.Ports.CDP != 9015 || target.Ports.Server != 9115 || target.Ports.Extension != 9315 {
t.Fatalf("unexpected ports: %#v", target.Ports)
}
if len(target.BrowserUserDataDirs) != 1 || target.BrowserUserDataDirs[0] != "/tmp/browseros-dogfood-profile" {
t.Fatalf("unexpected browser user data dirs: %#v", target.BrowserUserDataDirs)
}
if target.Dogfood == nil || target.Dogfood.StatePath != filepath.Join(cfgDir, "state.json") {
t.Fatalf("unexpected dogfood runtime paths: %#v", target.Dogfood)
}
}
func TestResolveDogfoodTargetAppliesDogfoodDefaults(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
xdgConfig := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdgConfig)
cfgDir := filepath.Join(xdgConfig, "browseros-dogfood")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
target, err := resolveResetTarget(root, resetTargetOptions{Target: "dogfood"})
if err != nil {
t.Fatal(err)
}
if target.BrowserOSDir != filepath.Join(home, ".browseros-dogfood") {
t.Fatalf("unexpected browseros dir: %s", target.BrowserOSDir)
}
if target.Ports == nil || target.Ports.CDP != 9015 || target.Ports.Server != 9115 || target.Ports.Extension != 9315 {
t.Fatalf("unexpected ports: %#v", target.Ports)
}
if len(target.BrowserUserDataDirs) != 1 || target.BrowserUserDataDirs[0] != filepath.Join(cfgDir, "profile") {
t.Fatalf("unexpected browser user data dirs: %#v", target.BrowserUserDataDirs)
}
}
func TestResolveProdTargetUsesBrowserosStateRoot(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("BROWSEROS_DIR", "")
target, err := resolveResetTarget(root, resetTargetOptions{Target: "prod"})
if err != nil {
t.Fatal(err)
}
if target.BrowserOSDir != filepath.Join(home, ".browseros") {
t.Fatalf("unexpected browseros dir: %s", target.BrowserOSDir)
}
if target.Ports != nil {
t.Fatalf("prod target should not clear ports by default: %#v", target.Ports)
}
}

View File

@@ -41,10 +41,7 @@ func runTest(cmd *cobra.Command, args []string) error {
return err
}
p, err := resolveTargetPorts(root, "")
if err != nil {
return err
}
p := proc.DefaultLocalPorts()
proc.LogMsg(proc.TagInfo, "Killing processes on test ports...")
proc.KillPorts(p)

View File

@@ -44,10 +44,7 @@ func runWatch(cmd *cobra.Command, args []string) error {
return err
}
defaultPorts, err := resolveTargetPorts(root, "")
if err != nil {
return err
}
defaultPorts := proc.DefaultLocalPorts()
p := defaultPorts
var reservations *proc.PortReservations
userDataDir := "/tmp/browseros-dev"

View File

@@ -5,7 +5,6 @@ go 1.25.7
require (
github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (

View File

@@ -18,7 +18,4 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -27,7 +27,7 @@ const (
randomPortMax = 9999
)
var defaultLocalPorts = Ports{CDP: 9000, Server: 9100, Extension: 9300}
var defaultLocalPorts = Ports{CDP: 9005, Server: 9105, Extension: 9305}
func DefaultLocalPorts() Ports {
return defaultLocalPorts

View File

@@ -169,16 +169,7 @@ func StopAllWatchProcessesInDir(baseDir string, timeout time.Duration) (int, err
// KillBrowserProcessesForDevProfiles kills BrowserOS instances using temporary dev/test profiles.
func KillBrowserProcessesForDevProfiles(timeout time.Duration) (int, error) {
return killBrowserProcesses([]string{"/tmp/browseros-dev"}, true, timeout)
}
// KillBrowserProcessesForUserDataDirs kills BrowserOS instances using the given user-data dirs.
func KillBrowserProcessesForUserDataDirs(userDataDirs []string, timeout time.Duration) (int, error) {
return killBrowserProcesses(userDataDirs, false, timeout)
}
func killBrowserProcesses(userDataDirs []string, includeDevTempProfiles bool, timeout time.Duration) (int, error) {
pids, err := currentBrowserProfilePIDs(userDataDirs, includeDevTempProfiles)
pids, err := currentBrowserProfilePIDs()
if err != nil {
return 0, err
}
@@ -193,7 +184,7 @@ func killBrowserProcesses(userDataDirs []string, includeDevTempProfiles bool, ti
deadline := time.Now().Add(timeout)
for {
remaining, err := currentBrowserProfilePIDs(userDataDirs, includeDevTempProfiles)
remaining, err := currentBrowserProfilePIDs()
if err != nil {
return 0, err
}
@@ -301,19 +292,15 @@ func processGroupLive(pgid int) bool {
return err == nil || err == syscall.EPERM
}
func currentBrowserProfilePIDs(userDataDirs []string, includeDevTempProfiles bool) ([]int, error) {
func currentBrowserProfilePIDs() ([]int, error) {
output, err := exec.Command("ps", "-axo", "pid=,pgid=,command=").Output()
if err != nil {
return nil, fmt.Errorf("listing processes: %w", err)
}
return browserProfilePIDsFromPSForUserDataDirs(string(output), userDataDirs, includeDevTempProfiles), nil
return browserProfilePIDsFromPS(string(output)), nil
}
func browserProfilePIDsFromPS(output string) []int {
return browserProfilePIDsFromPSForUserDataDirs(output, []string{"/tmp/browseros-dev"}, true)
}
func browserProfilePIDsFromPSForUserDataDirs(output string, userDataDirs []string, includeDevTempProfiles bool) []int {
var pids []int
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
@@ -325,7 +312,7 @@ func browserProfilePIDsFromPSForUserDataDirs(output string, userDataDirs []strin
continue
}
command := strings.Join(fields[2:], " ")
if isBrowserProcessForUserDataDir(command, userDataDirs, includeDevTempProfiles) {
if isDevBrowserProcess(command) {
pids = append(pids, pid)
}
}
@@ -334,24 +321,12 @@ func browserProfilePIDsFromPSForUserDataDirs(output string, userDataDirs []strin
}
func isDevBrowserProcess(command string) bool {
return isBrowserProcessForUserDataDir(command, []string{"/tmp/browseros-dev"}, true)
}
func isBrowserProcessForUserDataDir(command string, userDataDirs []string, includeDevTempProfiles bool) bool {
if !strings.Contains(command, "BrowserOS.app/Contents/MacOS/BrowserOS") {
return false
}
for _, dir := range userDataDirs {
if dir == "" {
continue
}
if strings.Contains(command, "--user-data-dir="+dir) {
return true
}
}
return includeDevTempProfiles &&
(strings.Contains(command, "browseros-dev-") ||
strings.Contains(command, "browseros-test-"))
return strings.Contains(command, "--user-data-dir=/tmp/browseros-dev") ||
strings.Contains(command, "browseros-dev-") ||
strings.Contains(command, "browseros-test-")
}
func watchRunPaths(baseDir string, identity WatchRunIdentity) watchRunPathsResult {

View File

@@ -38,7 +38,6 @@ func init() {
ChangedRef: changed,
RangeEnd: rangeEnd,
Filters: filters,
Progress: commandProgress(cmd),
})
if err != nil {
return err

View File

@@ -3,9 +3,7 @@ package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/repo"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/ui"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/workspace"
"github.com/spf13/cobra"
)
@@ -49,13 +47,3 @@ func ensureRepoConfigured(override string) error {
appState.Config.PatchesRepo = info.Root
return nil
}
// commandProgress routes long-running engine updates to stderr in human mode only.
func commandProgress(cmd *cobra.Command) engine.Progress {
if jsonOut {
return nil
}
return engine.ProgressFunc(func(message string) {
fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\n", ui.Muted("..."), message)
})
}

View File

@@ -1,43 +0,0 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCommandProgressWritesHumanUpdatesToStderr(t *testing.T) {
oldJSONOut := jsonOut
t.Cleanup(func() {
jsonOut = oldJSONOut
})
jsonOut = false
var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetErr(&stderr)
progress := commandProgress(cmd)
if progress == nil {
t.Fatalf("expected human progress reporter")
}
progress.Step("Applying 1 patch operation")
if !strings.Contains(stderr.String(), "Applying 1 patch operation") {
t.Fatalf("expected progress on stderr, got %q", stderr.String())
}
}
func TestCommandProgressDisabledForJSON(t *testing.T) {
oldJSONOut := jsonOut
t.Cleanup(func() {
jsonOut = oldJSONOut
})
jsonOut = true
if progress := commandProgress(&cobra.Command{}); progress != nil {
t.Fatalf("expected nil progress reporter in JSON mode")
}
}

View File

@@ -20,10 +20,7 @@ func init() {
if err != nil {
return err
}
result, err := engine.Continue(cmd.Context(), engine.ContinueOptions{
Workspace: ws,
Progress: commandProgress(cmd),
})
result, err := engine.Continue(cmd.Context(), ws)
if err != nil {
return err
}

View File

@@ -25,11 +25,7 @@ func init() {
if err != nil {
return err
}
status, err := engine.InspectWorkspace(cmd.Context(), engine.InspectWorkspaceOptions{
Workspace: ws,
Repo: info,
Progress: commandProgress(cmd),
})
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
if err != nil {
return err
}

View File

@@ -52,7 +52,6 @@ func init() {
Squash: squash,
Base: base,
Filters: filters,
Progress: commandProgress(cmd),
})
if err != nil {
return err

View File

@@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/patch/internal/ui"
"github.com/spf13/cobra"
)
@@ -12,7 +13,7 @@ func init() {
Use: "list",
Aliases: []string{"ls"},
Annotations: map[string]string{"group": "Workspace:"},
Short: "List registered workspaces",
Short: "List registered workspaces and their sync state",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(appState.Registry.Workspaces) == 0 {
@@ -20,15 +21,27 @@ func init() {
fmt.Println("No workspaces registered. Run `browseros-patch add <name> <path>`.")
})
}
info, err := repoInfo()
if err != nil {
return err
}
rows := make([][]string, 0, len(appState.Registry.Workspaces))
statuses := make([]*engine.WorkspaceStatus, 0, len(appState.Registry.Workspaces))
for _, ws := range appState.Registry.Workspaces {
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
if err != nil {
return err
}
statuses = append(statuses, status)
rows = append(rows, []string{
ws.Name,
status.SyncState,
fmt.Sprintf("%d/%d/%d", len(status.UpToDate), len(status.NeedsUpdate), len(status.Orphaned)),
ws.Path,
})
}
return renderResult(map[string]any{"workspaces": appState.Registry.Workspaces}, func() {
fmt.Println(ui.RenderTable([]string{"NAME", "PATH"}, rows))
return renderResult(map[string]any{"workspaces": statuses}, func() {
fmt.Println(ui.RenderTable([]string{"NAME", "STATE", "PATCHES", "PATH"}, rows))
})
},
}

View File

@@ -24,12 +24,7 @@ func init() {
if len(args) == 1 {
remote = args[0]
}
result, err := engine.Publish(cmd.Context(), engine.PublishOptions{
Repo: info,
Remote: remote,
Message: message,
Progress: commandProgress(cmd),
})
result, err := engine.Publish(cmd.Context(), info, remote, message)
if err != nil {
return err
}

View File

@@ -20,10 +20,7 @@ func init() {
if err != nil {
return err
}
result, err := engine.Skip(cmd.Context(), engine.SkipOptions{
Workspace: ws,
Progress: commandProgress(cmd),
})
result, err := engine.Skip(cmd.Context(), ws)
if err != nil {
return err
}

View File

@@ -24,11 +24,7 @@ func init() {
if err != nil {
return err
}
status, err := engine.InspectWorkspace(cmd.Context(), engine.InspectWorkspaceOptions{
Workspace: ws,
Repo: info,
Progress: commandProgress(cmd),
})
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
if err != nil {
return err
}

View File

@@ -31,7 +31,6 @@ func init() {
Repo: info,
Remote: remote,
Rebase: rebase,
Progress: commandProgress(cmd),
})
if err != nil {
return err

View File

@@ -23,7 +23,6 @@ type ApplyOptions struct {
RangeEnd string
Filters []string
Mode string
Progress Progress
}
type ApplyResult struct {
@@ -42,12 +41,10 @@ func Apply(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) {
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Inspecting workspace changes")
ops, orphaned, err := buildApplyOperations(ctx, opts)
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Applying %d patch %s", len(ops), plural(len(ops), "operation", "operations"))
result := &ApplyResult{
Workspace: opts.Workspace.Name,
Mode: applyMode(opts),
@@ -64,7 +61,7 @@ func Apply(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) {
}
return result, nil
}
next, err := applyOperationRange(ctx, opts.Workspace, opts.Repo, ops, 0, nil, nil, result, opts.Progress)
next, err := applyOperationRange(ctx, opts.Workspace, opts.Repo, ops, 0, nil, nil, result)
if err != nil {
return nil, err
}
@@ -80,14 +77,7 @@ func Apply(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) {
return result, nil
}
type ContinueOptions struct {
Workspace workspace.Entry
Progress Progress
}
// Continue resumes a saved patch application after the current conflict is resolved.
func Continue(ctx context.Context, opts ContinueOptions) (*ApplyResult, error) {
ws := opts.Workspace
func Continue(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) {
state, err := resolve.Load(ws.Path)
if err != nil {
return nil, err
@@ -112,8 +102,7 @@ func Continue(ctx context.Context, opts ContinueOptions) (*ApplyResult, error) {
Applied: append([]string{}, state.Resolved...),
Conflicts: nil,
}
reportProgress(opts.Progress, "Continuing patch resolution")
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result, opts.Progress)
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result)
if err != nil {
return nil, err
}
@@ -128,14 +117,7 @@ func Continue(ctx context.Context, opts ContinueOptions) (*ApplyResult, error) {
return result, nil
}
type SkipOptions struct {
Workspace workspace.Entry
Progress Progress
}
// Skip records the current conflict as skipped and resumes the remaining patch operations.
func Skip(ctx context.Context, opts SkipOptions) (*ApplyResult, error) {
ws := opts.Workspace
func Skip(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) {
state, err := resolve.Load(ws.Path)
if err != nil {
return nil, err
@@ -156,8 +138,7 @@ func Skip(ctx context.Context, opts SkipOptions) (*ApplyResult, error) {
RepoRev: state.RepoRev,
Applied: append([]string{}, state.Resolved...),
}
reportProgress(opts.Progress, "Skipping current conflict")
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result, opts.Progress)
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result)
if err != nil {
return nil, err
}
@@ -263,7 +244,6 @@ func applyOperationRange(
resolved []string,
skipped []string,
result *ApplyResult,
progress Progress,
) (int, error) {
repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil)
if err != nil {
@@ -271,7 +251,6 @@ func applyOperationRange(
}
for idx := start; idx < len(ops); idx++ {
op := ops[idx]
reportProgress(progress, "Applying %d/%d %s", idx+1, len(ops), op.ChromiumPath)
result.ResetPaths = append(result.ResetPaths, op.ChromiumPath)
if op.OldPath != "" {
if err := git.ResetPathToCommit(ctx, ws.Path, repoInfo.BaseCommit, op.OldPath); err != nil {

View File

@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
@@ -88,7 +87,7 @@ func TestPublishReturnsHelpfulErrorWhenNothingChanged(t *testing.T) {
if err != nil {
t.Fatalf("repo.Load: %v", err)
}
if _, err := Publish(ctx, PublishOptions{Repo: repoInfo}); err == nil || !strings.Contains(err.Error(), "nothing to publish") {
if _, err := Publish(ctx, repoInfo, "", ""); err == nil || !strings.Contains(err.Error(), "nothing to publish") {
t.Fatalf("expected helpful no-op error, got %v", err)
}
}
@@ -111,47 +110,6 @@ func TestOperationsFromChangesNormalizesOldPath(t *testing.T) {
}
}
func TestApplyReportsPatchProgress(t *testing.T) {
ctx := context.Background()
workspacePath := initGitRepo(t)
writeFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "base\n")
runGit(t, workspacePath, "add", "chrome/browser.cc")
runGit(t, workspacePath, "commit", "-m", "workspace base")
baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD")
writeFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "patched\n")
diff, err := git.DiffText(ctx, workspacePath, baseCommit, "--", "chrome/browser.cc")
if err != nil {
t.Fatalf("DiffText: %v", err)
}
runGit(t, workspacePath, "checkout", "--", "chrome/browser.cc")
repoRoot := initGitRepo(t)
writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), baseCommit+"\n")
writeFile(t, filepath.Join(repoRoot, "chromium_patches", "chrome", "browser.cc"), diff)
runGit(t, repoRoot, "add", "BASE_COMMIT", "chromium_patches/chrome/browser.cc")
runGit(t, repoRoot, "commit", "-m", "patch repo init")
repoInfo, err := repo.Load(repoRoot)
if err != nil {
t.Fatalf("repo.Load: %v", err)
}
progress := &progressRecorder{}
_, err = Apply(ctx, ApplyOptions{
Workspace: workspace.Entry{Name: "ws", Path: workspacePath},
Repo: repoInfo,
Progress: progress,
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
progress.requireContains(t, "Inspecting workspace changes")
progress.requireContains(t, "Applying 1 patch operation")
progress.requireContains(t, "Applying 1/1 chrome/browser.cc")
assertFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "patched\n")
}
func TestSyncClearsPendingStashAfterSuccessfulNonRebaseRun(t *testing.T) {
ctx := context.Background()
workspacePath := initGitRepo(t)
@@ -210,47 +168,6 @@ func TestSyncClearsPendingStashAfterSuccessfulNonRebaseRun(t *testing.T) {
}
}
func TestSyncReportsPatchRepoProgress(t *testing.T) {
ctx := context.Background()
workspacePath := initGitRepo(t)
writeFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "base\n")
runGit(t, workspacePath, "add", "chrome/browser.cc")
runGit(t, workspacePath, "commit", "-m", "workspace base")
baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD")
remoteRepo := t.TempDir()
runGit(t, remoteRepo, "init", "--bare")
repoRoot := initGitRepo(t)
if err := os.MkdirAll(filepath.Join(repoRoot, "chromium_patches"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), baseCommit+"\n")
runGit(t, repoRoot, "add", "BASE_COMMIT")
runGit(t, repoRoot, "commit", "-m", "patch repo init")
runGit(t, repoRoot, "remote", "add", "origin", remoteRepo)
runGit(t, repoRoot, "push", "-u", "origin", "HEAD")
repoInfo, err := repo.Load(repoRoot)
if err != nil {
t.Fatalf("repo.Load: %v", err)
}
progress := &progressRecorder{}
_, err = Sync(ctx, SyncOptions{
Workspace: workspace.Entry{Name: "ws", Path: workspacePath},
Repo: repoInfo,
Remote: "origin",
Progress: progress,
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
progress.requireContains(t, "Checking patch repo status")
progress.requireContains(t, "Pulling patch repo from origin/")
progress.requireContains(t, "Inspecting workspace drift")
}
func initGitRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -260,24 +177,6 @@ func initGitRepo(t *testing.T) string {
return dir
}
type progressRecorder struct {
messages []string
}
func (p *progressRecorder) Step(message string) {
p.messages = append(p.messages, message)
}
func (p *progressRecorder) requireContains(t *testing.T, want string) {
t.Helper()
if slices.ContainsFunc(p.messages, func(message string) bool {
return strings.Contains(message, want)
}) {
return
}
t.Fatalf("progress missing %q in %#v", want, p.messages)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)

View File

@@ -19,7 +19,6 @@ type ExtractOptions struct {
Squash bool
Base string
Filters []string
Progress Progress
}
type ExtractResult struct {
@@ -44,7 +43,6 @@ func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) {
switch {
case opts.Commit != "":
mode = "commit"
reportProgress(opts.Progress, "Extracting patches from commit %s", opts.Commit)
set, err = patch.BuildCommitPatchSet(ctx, opts.Workspace.Path, opts.Commit, opts.Base, opts.Filters)
if err == nil {
if opts.Base != "" {
@@ -59,7 +57,6 @@ func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) {
}
case opts.RangeStart != "" && opts.RangeEnd != "":
mode = "range"
reportProgress(opts.Progress, "Extracting patches from range %s..%s", opts.RangeStart, opts.RangeEnd)
set, err = patch.BuildRangePatchSet(ctx, opts.Workspace.Path, opts.RangeStart, opts.RangeEnd, opts.Base, opts.Squash, opts.Filters)
if err == nil {
if opts.Base != "" || opts.Squash {
@@ -74,7 +71,6 @@ func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) {
}
default:
mode = "working-tree"
reportProgress(opts.Progress, "Extracting workspace changes")
set, err = patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, base, opts.Filters)
if err == nil && len(opts.Filters) > 0 {
scope = opts.Filters
@@ -83,7 +79,6 @@ func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) {
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Writing %d patch %s", len(set), plural(len(set), "file", "files"))
written, deleted, err := patch.WriteRepoPatchSet(opts.Repo.PatchesDir, set, scope)
if err != nil {
return nil, err

View File

@@ -1,22 +0,0 @@
package engine
import "fmt"
// Progress receives concise updates for operations that can take noticeable time.
type Progress interface {
Step(message string)
}
type ProgressFunc func(message string)
// Step sends one progress message through f.
func (f ProgressFunc) Step(message string) {
f(message)
}
func reportProgress(progress Progress, format string, args ...any) {
if progress == nil {
return
}
progress.Step(fmt.Sprintf(format, args...))
}

View File

@@ -14,44 +14,32 @@ type PublishResult struct {
Message string `json:"message"`
}
type PublishOptions struct {
Repo *repo.Info
Remote string
Message string
Progress Progress
}
// Publish commits chromium_patches changes and pushes them to the selected remote.
func Publish(ctx context.Context, opts PublishOptions) (*PublishResult, error) {
if opts.Remote == "" {
opts.Remote = "origin"
func Publish(ctx context.Context, repoInfo *repo.Info, remote string, message string) (*PublishResult, error) {
if remote == "" {
remote = "origin"
}
if opts.Message == "" {
opts.Message = "chore: update chromium patches"
if message == "" {
message = "chore: update chromium patches"
}
reportProgress(opts.Progress, "Checking chromium_patches changes")
dirty, err := git.IsDirtyPaths(ctx, opts.Repo.Root, []string{"chromium_patches"})
dirty, err := git.IsDirtyPaths(ctx, repoInfo.Root, []string{"chromium_patches"})
if err != nil {
return nil, err
}
if !dirty {
return nil, fmt.Errorf("nothing to publish: chromium_patches has no uncommitted changes")
}
reportProgress(opts.Progress, "Staging chromium_patches")
if err := git.AddPaths(ctx, opts.Repo.Root, []string{"chromium_patches"}); err != nil {
if err := git.AddPaths(ctx, repoInfo.Root, []string{"chromium_patches"}); err != nil {
return nil, err
}
reportProgress(opts.Progress, "Committing chromium_patches")
if err := git.Commit(ctx, opts.Repo.Root, opts.Message); err != nil {
if err := git.Commit(ctx, repoInfo.Root, message); err != nil {
return nil, err
}
branch, err := git.CurrentBranch(ctx, opts.Repo.Root)
branch, err := git.CurrentBranch(ctx, repoInfo.Root)
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Pushing patch repo to %s/%s", opts.Remote, branch)
if err := git.Push(ctx, opts.Repo.Root, opts.Remote, branch); err != nil {
if err := git.Push(ctx, repoInfo.Root, remote, branch); err != nil {
return nil, err
}
return &PublishResult{Remote: opts.Remote, Branch: branch, Message: opts.Message}, nil
return &PublishResult{Remote: remote, Branch: branch, Message: message}, nil
}

View File

@@ -25,41 +25,31 @@ type WorkspaceStatus struct {
SyncState string `json:"sync_state"`
}
type InspectWorkspaceOptions struct {
Workspace workspace.Entry
Repo *repo.Info
Progress Progress
}
// InspectWorkspace compares a workspace against the patch repo and classifies drift.
func InspectWorkspace(ctx context.Context, opts InspectWorkspaceOptions) (*WorkspaceStatus, error) {
reportProgress(opts.Progress, "Inspecting workspace drift")
head, err := git.HeadRev(ctx, opts.Repo.Root)
func InspectWorkspace(ctx context.Context, ws workspace.Entry, repoInfo *repo.Info) (*WorkspaceStatus, error) {
head, err := git.HeadRev(ctx, repoInfo.Root)
if err != nil {
return nil, err
}
state, err := workspace.LoadState(opts.Workspace.Path)
state, err := workspace.LoadState(ws.Path)
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Loading repo patch set")
repoSet, err := patch.LoadRepoPatchSet(opts.Repo.PatchesDir, nil)
repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil)
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Building workspace patch set")
localSet, err := patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, opts.Repo.BaseCommit, nil)
localSet, err := patch.BuildWorkingTreePatchSet(ctx, ws.Path, repoInfo.BaseCommit, nil)
if err != nil {
return nil, err
}
status := &WorkspaceStatus{
Workspace: opts.Workspace,
Workspace: ws,
RepoHead: head,
BaseCommit: opts.Repo.BaseCommit,
BaseCommit: repoInfo.BaseCommit,
LastApplyRev: state.LastApplyRev,
LastSyncRev: state.LastSyncRev,
LastExtractRev: state.LastExtractRev,
ActiveResolve: resolve.Exists(opts.Workspace.Path),
ActiveResolve: resolve.Exists(ws.Path),
}
for _, delta := range patch.Compare(repoSet, localSet) {
switch delta.Kind {

View File

@@ -1,8 +0,0 @@
package engine
func plural(count int, singular string, pluralForm string) string {
if count == 1 {
return singular
}
return pluralForm
}

View File

@@ -15,7 +15,6 @@ type SyncOptions struct {
Repo *repo.Info
Remote string
Rebase bool
Progress Progress
}
type SyncResult struct {
@@ -33,7 +32,6 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
if opts.Remote == "" {
opts.Remote = "origin"
}
reportProgress(opts.Progress, "Checking patch repo status")
dirty, err := git.IsDirty(ctx, opts.Repo.Root)
if err != nil {
return nil, err
@@ -45,7 +43,6 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
if err != nil {
return nil, err
}
reportProgress(opts.Progress, "Pulling patch repo from %s/%s", opts.Remote, branch)
if err := git.PullRebase(ctx, opts.Repo.Root, opts.Remote, branch); err != nil {
return nil, err
}
@@ -63,18 +60,13 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
RepoHead: head,
Rebased: opts.Rebase,
}
status, err := InspectWorkspace(ctx, InspectWorkspaceOptions{
Workspace: opts.Workspace,
Repo: opts.Repo,
Progress: opts.Progress,
})
status, err := InspectWorkspace(ctx, opts.Workspace, opts.Repo)
if err != nil {
return nil, err
}
divergent := append([]string{}, status.NeedsUpdate...)
divergent = append(divergent, status.Orphaned...)
if len(divergent) > 0 {
reportProgress(opts.Progress, "Stashing %d divergent %s", len(divergent), plural(len(divergent), "file", "files"))
stashRef, err := git.StashPush(ctx, opts.Workspace.Path, "browseros-patch sync stash", true, divergent)
if err != nil {
return nil, err
@@ -92,7 +84,6 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
Repo: opts.Repo,
Reset: true,
Mode: "sync-reset",
Progress: opts.Progress,
})
if err != nil {
return nil, err
@@ -111,7 +102,6 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
ChangedRef: state.LastSyncRev,
RangeEnd: head,
Mode: "sync",
Progress: opts.Progress,
})
if err != nil {
return nil, err
@@ -125,7 +115,6 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
}
}
if opts.Rebase && result.StashRef != "" {
reportProgress(opts.Progress, "Restoring stashed local changes")
if err := git.StashPop(ctx, opts.Workspace.Path, result.StashRef); err != nil {
return nil, err
}