Skip to content

feat: side-panel launcher to start a coding agent in a terminal - #17

Closed
whysosaket wants to merge 38 commits into
mainfrom
feat/launch-claude-from-extension
Closed

whysosaket wants to merge 38 commits into
mainfrom
feat/launch-claude-from-extension

Conversation

@whysosaket

@whysosaket whysosaket commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What

Launch a coding agent in a terminal from the extension — now via a side-panel launcher (chrome.sidePanel), not a single button.

The side panel shows an agent picker (Claude selectable; Codex/Cursor visible but disabled, "coming soon"), the current tab as context, and an instruction composer. Hitting Launch (or ⌘/Ctrl+Enter) opens the chosen agent in a new terminal — seeded with the page metadata as --append-system-prompt context and your typed instruction as the first task. The composer is gated on connection (only usable when the bridge is connected), and the popup only offers "Open launcher" when connected.

Why it's shaped this way

The daemon runs detached with stdio: "ignore" and has no TTY, so it can't stdio: "inherit" like browserops --claude. It writes a launcher script + a context file + an instruction file under the 0700 state dir and asks the OS to open a terminal that runs the script.

Transport: POST /launch (next to /pair / /status), token-authed + origin-pinned. The side panel already holds the loopback token and the repo already does token'd fetch to the bridge, so this is idiomatic — and HTTP gives the panel a real success/error to render. No changes to the WS protocol or background worker.

Agent-agnostic by construction

  • packages/shared/src/agents.ts — one AGENTS registry both sides consume (UI picker + bridge validation). Claude enabled; Codex/Cursor enabled: false.
  • launch-agent.ts (was launch-claude.ts) — launchAgent({agent, instruction, page}) validates via isLaunchableAgent (disabled/unknown → agent_disabled) and dispatches through a per-agent AGENT_LAUNCHERS spec. Adding Codex/Cursor later is a registry flag + one spec entry.

Platforms

launch-agent.ts switches on process.platform: macOS bash .command via open -a iTerm/Terminal; Windows PowerShell .ps1 via wt.exe or cmd /c start powershell. Linux returns unsupported_platform (the remaining seam).

Security

Two independent gates on /launch: the Origin must match the pinned extension (a web page's real Origin won't) and the body must carry the loopback token (only the pinned extension receives it via /pair). The launcher gesture is the user-consent event — deliberately not routed through the risky-action approval layer.

No shell injection: page metadata and the typed instruction only ever reach the shell as the content of a file read into a variable, never interpolated into the script. A <title> or instruction of $(rm -rf ~) is inert.

Testing

  • Shared: agent-registry tests. Bridge: 344 tests — launcher unit tests (agent dispatch, agent_disabled, instruction file, injection-safety), 14 /launch endpoint tests (incl. agent + instruction forwarding, auth/origin gates), and a real-shell E2E that runs the generated .command against a fake claude and asserts it execs with [--dangerously-skip-permissions, --append-system-prompt, <ctx>, <instruction>] (no word-split), that $(...) values stay inert, and that the script self-deletes. Extension: 133 tests (launch-client helpers). Full pnpm build + typecheck + test green.
  • Live E2E against the running daemon: codex/ghost502 agent_disabled (no terminal opens), wrong token → 401, non-pinned origin → 403.

Manual E2E left for a human (spawns a real interactive session)

  1. pnpm build && node packages/cli/dist/index.js restart; load-unpack packages/extension/dist.
  2. Popup → Open launcher (only shown when connected) → side panel opens.
  3. Pick Claude, type an instruction, Launch → a terminal opens running Claude in danger mode, seeded with the page context + your instruction.

Follow-ups

  • Wire Codex/Cursor (registry already structured for it). Linux terminal launching. Side-panel fonts (currently system stack). Verify open -a iTerm <file>.command runs vs opens on an iTerm box.

Foundation for the extension-triggered Claude launch: a private launch/
subdir under the 0700 state dir for transient launcher artifacts, and a
safe_mode reader that mirrors the CLI launcher's getSafeMode so the daemon
and `browserops --claude` agree on the danger flag. Path is injectable for
hermetic tests.
The detached daemon has no TTY, so it writes a launcher script + a context
file under the 0700 launch/ dir and asks the OS to open a terminal that runs
it. Per-platform: macOS bash .command opened via 'open -a iTerm/Terminal';
Windows PowerShell .ps1 opened via wt.exe or 'cmd /c start powershell'.

Page metadata is seeded into Claude via --append-system-prompt but only ever
reaches the shell as the content of a file read into a variable, never
interpolated into the script source — so arbitrary page titles can't inject
shell commands. claude is invoked by bare name inside the new terminal so it
resolves against the user's interactive PATH. Pure builders + an injectable
orchestrator are covered by 20 unit tests, incl. an injection-safety case.
Adds a token-authed, origin-pinned POST /launch HTTP endpoint next to /pair
and /status. Two independent gates: the Origin must match the pinned
extension (a web page's real Origin won't) AND the body must carry the
loopback token (only the pinned extension gets it via /pair). On success it
calls the injected onLaunch (daemon-runtime wires it to launchClaude) and
returns the outcome as JSON so the popup can render real success/error.

13 endpoint tests cover the happy path + field mapping, 401 (bad/missing
token), 403 (non-pinned and web origins), empty-origin host_permissions
case, 502 handler-failure, 503 unavailable, 400 malformed body, 405, and
the OPTIONS preflight.
The button captures the active tab's metadata (url + title always; meta
description / og:site_name via a self-contained executeScript that gracefully
degrades on restricted pages), reads the loopback token, and POSTs to the
bridge's /launch endpoint. The POST is independent of the WS connection, so
it works from a fresh popup as long as the daemon is up; a connection refusal
renders as "Bridge not running". The button disables while pending to avoid a
double-spawn. Pure helpers (body builder, status mapper, text clamp) live in
launch.ts with 7 unit tests; index.ts is the DOM/chrome glue.
Executes the actual generated .command against a fake claude on PATH and
asserts it execs with exactly [--dangerously-skip-permissions,
--append-system-prompt, <context>] — proving the quoted "$CTX" is not
word-split, the malicious $(touch PWNED) title is passed as inert data
(no command injection at runtime), and the launcher self-deletes its
temp files. A second case proves the claude-not-found path exits 1.
Gated to darwin; skipped elsewhere.
A single AGENTS catalogue both sides consume: the extension renders it as
the launcher's agent picker, the bridge validates /launch requests against
it. Claude is enabled; Codex/Cursor ship disabled ("coming soon") so the
launcher is agent-agnostic and adding them later is a registry entry plus a
per-agent launch spec.
Renames launch-claude -> launch-agent and generalizes it around the shared
agent registry: launchAgent({agent, instruction, page}) validates the agent
via isLaunchableAgent (codex/cursor and unknown ids -> agent_disabled) and
dispatches through a per-agent AGENT_LAUNCHERS spec (only claude today).

Adds an optional instruction (typed in the composer): written to its own
.instr file, read into $INSTR/$instr, and passed as a separate quoted
positional after the context — same file-not-argv injection invariant as the
page metadata. The /launch endpoint + daemon-runtime now thread agent +
instruction. Tests updated; shell E2E proves the instruction is a distinct
quoted positional and $(...) values stay inert.
Replaces the popup's single launch button with a roomier side-panel
launcher (chrome.sidePanel). The panel renders the shared agent registry as
a picker (Claude selectable; Codex/Cursor shown disabled 'coming soon'), a
current-tab context line, and an instruction composer; Cmd/Ctrl+Enter or the
button POSTs {agent, instruction, page metadata, token} to /launch and shows
the result. The composer is gated on connection (reads storage.local.connected,
live-updates) and the panel refreshes its context as you switch tabs.

The popup now shows an 'Open launcher' entry only when connected, which calls
chrome.sidePanel.open() for the window (window id resolved at load so the call
stays inside the click gesture). Launch helpers moved popup/launch.ts ->
lib/launch-client.ts and gained agent + instruction fields. manifest: adds the
sidePanel permission + side_panel.default_path.
@whysosaket whysosaket changed the title feat: launch Claude in a terminal from the extension popup feat: side-panel launcher to start a coding agent in a terminal Jun 16, 2026
The panel now opens as a chat window — agent picker on top, a conversation
area, and a message input at the bottom. Sending the first message launches
the chosen agent in a terminal (the message becomes its first task; page
context still rides along as the system prompt) and drops a confirmation
bubble; the conversation then continues in the terminal. For now there's no
in-panel streaming, so the composer parks after launch with a 'New' button to
start over. Enter sends, Shift+Enter newlines; still connection-gated and
context refreshes per active tab. Backend unchanged.
The page context was passed only via --append-system-prompt, which the agent
keeps in its (invisible) system prompt — and the TUI clears any pre-launch
banner — so the user couldn't tell the context took effect. Split it: the
operational guidance (browser_* tools, /browserops skill) stays in the system
prompt, while the page facts now lead the VISIBLE first prompt, followed by
the user's instruction. The agent shows that as its opening turn, so the page
context is plainly visible in the terminal. Launcher files renamed ctx/instr
-> sys/prompt; same file-not-argv injection invariant. Tests + shell E2E
updated.
Add chat.ts: the agent-agnostic message vocabulary (ChatRole/ChatBlock/
ChatMessage/SessionRef/ChatEvent/ChatPageContext) plus TerminalMode, shared
by the bridge's chat engine and the side-panel client so neither depends on a
specific agent's wire format.

Extend the agent registry with caps {launch, chat} so terminal-launch and
in-panel chat can ship per agent independently; add canChatAgent().
The agent-specific half of the chat engine: map Claude's stream-json events
(live) and on-disk JSONL records (history) onto the shared ChatMessage shapes.

- normalize.ts: one content/block normalizer shared by both feeds; live event
  mapper (assistant/user→message, result→idle/error status, init handled by
  the runtime); drops empty thinking blocks and the thinking signature.
- transcript.ts: resolve a session by scanning ~/.claude/projects/*/<id>.jsonl
  (slug-agnostic, since dotted cwds break the slug rule), whitelist
  user/assistant turns, drop sidechains + skill/reminder injections, and
  re-join the per-block assistant records Claude splits a turn into.

Fixtures captured from a real claude v2.1.153 stream-json run.
The custom read/send transport behind the AgentDriver seam. Drives Claude as
a per-turn stream-json subprocess (validated: first turn --session-id, later
--resume continue the same session id + transcript), parses stdout into
ChatEvents, fans them to live subscribers, mirrors a human-readable log for the
"show in terminal" view, and resolves history by re-reading the transcript.

- driver.ts (AgentDriver interface): the pluggable per-agent seam.
- claude/bin.ts: login-shell PATH resolution (the daemon PATH is minimal).
- claude/runtime.ts: runClaudeTurn — spawn (no shell → argv is injection-inert,
  stdin ignored to skip claude's 3s stall), NDJSON line-buffer → events.
- claude/mirror.ts: pretty mirror-log lines for the read-only terminal tail.
- claude/driver.ts: per-session turn queue, replay buffer for the start race,
  --session-id/--resume selection, stop()=eject (abort + refuse further sends).
- paths.ts: chatDir/sessionsDir/workspaceDir under the 0700 state dir.

Tests incl. a real-process bash-fake E2E proving a $(...) message stays inert.
ChatEngine is the agent-agnostic orchestrator the HTTP/SSE surface will call.
It owns the driver registry (one per agent), the session index, and an
in-memory ref cache, and routes every call by session id — resolving live refs
from cache or cold from the index after a daemon restart (so a session survives
a restart and resumes via --resume). Adding an agent is just registering its
driver here.

- sessions.ts: SessionIndex — our small manifest of started sessions (history
  itself stays in the agents' transcripts); atomic, capped, corruption-tolerant.
- manager.ts: start/send/subscribe/stop/history/list, EngineError codes.
- errors.ts: typed EngineError so the HTTP layer maps codes to status.
…he daemon

Expose the in-panel chat surface over the bridge port, gated by the same two
gates as /launch (pinned-extension origin + loopback token):
- POST /chat/new   → engine.start, returns the SessionRef
- POST /chat/send  → engine.send
- POST /chat/terminal → mirror | takeover (stubbed; wired next step)
- GET  /chat/sessions, /chat/history → list / replay (token in query)
- GET  /chat/stream → SSE; queues the engine's replayed buffer until headers
  are written so an unknown session still maps to 404; registered so stop()
  ends open streams.

EngineError codes map to HTTP status (unknown_session->404, ejected->409, ...).
daemon-runtime constructs the ChatEngine and adapts it to the ChatService the
bridge consumes. ChatService stays an interface so ws-server is engine-agnostic
and unit-testable with a fake.
Implement POST /chat/terminal's two modes, reusing the launchAgent opener
machinery (Terminal/iTerm/wt/cmd):
- mirror:   open a read-only terminal that tails the session's mirror log
            (tail -n +1 -F / Get-Content -Wait); the engine keeps driving.
- takeover: engine.stop() lets go, then open an interactive
            claude --resume <id> in the session's cwd — one writer at a time.

Only repo/engine-controlled values (temp script path, session id, workspace
cwd, mirror path) are interpolated, all single-quoted, so the injection-safe
invariant holds. safe_mode is honored on takeover.
Rewire the side panel from fire-and-forget launch into a real bidirectional
chat over /chat/*:
- first message → POST /chat/new, then open an EventSource on /chat/stream and
  render assistant/tool/thinking blocks as they arrive; the composer stays live
  for follow-up turns (POST /chat/send).
- header buttons: 'Terminal' (read-only live mirror) and 'Take over' (eject into
  an interactive terminal; the panel then goes read-only).
- a recent-sessions strip lists past sessions (/chat/sessions); clicking one
  re-reads its transcript (/chat/history) and reconnects the live stream.

lib/chat-client.ts holds the pure pieces (reducer, body builder, status copy)
so they unit-test in Node; reuses grabPageMeta/clampText from launch-client.
The agent picker now keys off caps.chat.
AGENTS.md (loaded as CLAUDE.md): architecture note on the bidirectional chat
engine + a Where-to-look row for packages/bridge/src/engine.
README: a user-facing 'Chat from the side panel' section (in-panel chat,
Terminal mirror, Take over, recent-sessions strip).
Add a chat_transport preference ("terminal" | "headless") to the bridge
config, resolved from BROWSEROPS_CHAT_TRANSPORT then the config file, defaulting
to "terminal". It selects how the in-panel chat engine drives Claude; driver
selection (a later step) degrades terminal->headless when node-pty is missing.

Add the pty_unavailable EngineError code and map it to HTTP 503 in the chat
surface.
Add engine/claude/tail.ts: a polling, byte-offset transcript tailer that emits
normalized ChatEvents for the interactive transport's clean chat feed. It
advances only on newline boundaries (no partial line / split UTF-8) and tolerates
the transcript file being created after the session starts.

Turn-end (the transcript feed has no result event) is decided by detectTurnState:
a terminal stop_reason (end_turn/stop_sequence) is primary, a quiescence timeout
is the universal fallback that also covers interrupts and turns typed directly in
the terminal. Add normalize.stopReasonOf and refactor parseTranscript's record
loop into a reusable createTranscriptReducer the tailer drives incrementally.
Add engine/claude/pty.ts: a lazily-loaded node-pty wrapper that (1) imports the
native module only on demand, so a missing/incompatible binary surfaces as
PtyUnavailableError (the engine falls back to headless) rather than crashing the
daemon at boot; (2) re-chmods node-pty's spawn-helper +x before the first spawn —
pnpm's onlyBuiltDependencies skips the postinstall that would set it, so spawn
would otherwise fail with posix_spawnp; and (3) submits turns with a two-step
discrete Enter (a single text+CR write is swallowed as a paste by the Ink TUI),
bracketed-pasting multi-line input.

PtySession adds raw-output fan-out with bounded scrollback replay (so the embedded
xterm pane renders the screen when it attaches late) and a quiescence readiness
gate. Add node-pty to the bridge deps + the workspace onlyBuiltDependencies list.
Add engine/claude/terminal-driver.ts: a second AgentDriver that spawns ONE
long-lived INTERACTIVE claude on a pty (no --print, no skip-permissions;
--permission-mode acceptEdits + --allowedTools mcp__browserops in non-safe mode,
default-prompting in safe mode). Turns are injected at the tty; the clean chat
feed comes from the transcript tailer; the raw TUI is exposed via a new
TerminalAttachable capability (attachTerminal) for the embedded xterm pane.

Pre-seeds workspace folder-trust in ~/.claude.json (atomic, one-time) so the
session doesn't open on a trust prompt that would swallow the seed turn. A node-pty
load failure surfaces as pty_unavailable so the engine can fall back to headless.

Add TranscriptTailer.markThinking() so turn injection drives the tailer's
thinking->idle lifecycle (the tailer can't observe the injection), and make
quiescence a long interrupt-only fallback.
Wire driver selection by chat_transport in daemon-runtime: terminal (default)
constructs ClaudePtyDriver with the headless ClaudeDriver as an automatic
fallback; headless constructs ClaudeDriver alone. ChatEngine now catches
pty_unavailable from a primary driver's start() and retries with the registered
fallback, remembering the owning driver per session so send/subscribe/history/stop
route to whichever driver actually owns it.
Add a token+origin-gated WebSocket route (/chat/pty?token&session_id) that binds
the panel's xterm pane to the live pty: raw pty output is sent to the socket
(scrollback replayed on attach), and JSON {type:input|resize} frames from the pane
are forwarded to the pty. Reuses the existing upgrade path with a custom callback;
sockets are tracked and closed on stop().

ChatEngine.attachTerminal resolves the session's owning driver and returns its
TerminalHandle — unknown_session for cold sessions, pty_unavailable for the
non-terminal headless fallback. ChatService gains attachTerminal, wired in
daemon-runtime.
The Terminal button now toggles a live xterm.js pane inside the side panel, wired
to the bridge's /chat/pty WebSocket: it shows the raw interactive Claude TUI and
you can type into it — the same pty the chat drives, so a turn typed in the
terminal also lands in the chat bubbles. xterm (~330KB) is lazy-imported only when
the terminal is opened; Take over still ejects to a real external terminal.

Add pure ptyUrl + input/resize frame builders to chat-client (+tests) and a thin
pty-terminal module for the xterm<->WS wiring (fit on open/resize).
A live E2E against real claude surfaced two issues in the interactive transport:
- Readiness resolved before the TUI mounted, so the seed turn was injected into an
  unmounted prompt and lost. waitUntilReady now starts its quiescence timer on the
  FIRST byte of output (with a hard-timeout floor), waiting for the TUI to actually
  render and settle.
- A multi-line turn went through bracketed paste, which Claude's Ink TUI collapses
  into a 'pasted text' attachment instead of submitting. submitText now flattens to
  a single line (the proven submit path) and drops bracketed paste.

Verified end-to-end: interactive session -> grounded seed reply -> follow-up ->
history with both turns.
AGENTS.md: the in-panel chat engine now has two Claude transports (terminal
default via ClaudePtyDriver, headless --print fallback), the /chat/pty
embedded-xterm WebSocket, and the transcript-tail clean feed. README: the Terminal
button is now an embedded in-panel terminal; note the interactive-session safety
framing and the chat_transport flag.
…y mirror

The takeover eject called chatEngine.stop(), which for the pty transport
hard-killed the pty AND the transcript tailer, freezing the panel chat.

Add an optional AgentDriver.relinquish() distinct from stop(): the pty
driver kills its own pty (one writer at a time) but keeps the tailer
running, emitting an 'ejected' status instead of 'end'. The killed pty's
onExit is silenced (the death is expected) and send/attachTerminal are
refused with session_ejected. ChatEngine.eject() prefers relinquish and
falls back to stop() for the headless driver; the takeover path now calls
eject(). Adds ChatEngine.terminalCapable()/driver.terminalLive() so the
panel can tell whether to offer the embedded-terminal toggle.
…ly-skip-permissions

The takeover resume scripts injected --dangerously-skip-permissions in
non-safe mode — the exact ban-risk flag the interactive-terminal transport
exists to avoid. An ejected session is driven by a human in a terminal, so
prompting is fine.

Parameterize buildMac/WindowsResumeScript with explicit permissionArgs
(via resumePermissionArgs) instead of deriving a skip-permissions flag:
acceptEdits + --allowedTools mcp__browserops when not in safe mode,
--permission-mode default in safe mode — matching the pty driver's args.

NOTE: this also changes the headless transport's takeover (it no longer
skip-permissions on eject); unified deliberately so the codebase never
ejects with the ban-risk flag. Flagged for review.
…sessions

The panel needs to know whether a session has a live pty before offering the
embedded-terminal toggle (a headless-fallback, cold post-restart, or already-
ejected session has none). Add ChatService.terminalCapable() wired to the
engine, include the flag on the /chat/new response and each /chat/sessions
item, and thread it into the client ChatState (cleared on the ejected status).
The embedded terminal was a fixed 280px pane shown alongside the chat. Make
it a real view toggle: the header button swaps the whole panel between the
chat and a full-window live terminal — mutually exclusive, not a cramped pane.

The pty session keeps running across the toggle (the chat tailer stays live
underneath), so flipping back shows the conversation intact and turns typed
in the terminal appear in the chat. The toggle is offered only when the
session is terminalCapable (live pty); render() falls back to chat if the
session loses its pty (eject, disconnect, cold/new session). CSS drives the
swap via a .view-terminal class; xterm stays a lazy chunk.
After '⤢ Take over', keep the SSE stream open instead of closing it: the
daemon now keeps tailing the transcript (relinquish), so the chat becomes a
LIVE read-only mirror of the turns typed in the external terminal. Only input
is locked — the composer is disabled (ejected) and the embedded-terminal view
is closed/hidden (terminalCapable cleared).

Also reconcile the optimistic user echo with the pty tailer's transcript user
record so a composer turn shows once (was a latent double-bubble): a real user
record adopts the oldest pending optimistic id but keeps the user's original
text; a user record with no pending echo (terminal-typed, or streamed in after
eject) just appends.
Document the embedded terminal as a full-window view toggle (chat xor
terminal) and the external eject as a live read-only mirror that resumes
interactively (never --dangerously-skip-permissions), in AGENTS.md and the
README side-panel section.
feat: interactive-terminal chat transport (Claude on a pty) + embedded xterm, headless fallback
feat: bidirectional in-panel chat engine (Claude), pluggable + terminal-mirrored
Step: shared chat contract

Substeps:

- add per-session chat capabilities and migration metadata

- mark Codex and Cursor as chat-capable but not launch-capable

- export the new shared capability type
Step: bridge chat engine and backend

Substeps:

- route driver construction through a factory with Claude fallback support

- add generic headless process driver helpers for Codex and Cursor

- persist BrowserOps normalized transcripts for migration and fallback history

- expose per-session capabilities and a migration endpoint
Step: side-panel integration

Substeps:

- read per-session capabilities from chat responses

- gate terminal and takeover controls independently

- add a client helper for future chat migration UI
@prathameshagrawal

Copy link
Copy Markdown
Contributor

Closing for now; branch retained.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants