Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2f80143
feat(bridge): add launchDir() path + loadSafeMode() config reader
whysosaket Jun 16, 2026
18ba4d1
feat(bridge): add launch-claude module to spawn Claude in a new terminal
whysosaket Jun 16, 2026
86426a3
feat(bridge): expose POST /launch endpoint and wire daemon onLaunch
whysosaket Jun 16, 2026
12fb78d
feat(extension): add "Launch Claude here" button to the popup
whysosaket Jun 16, 2026
6c83b09
test(bridge): real-shell E2E for the macOS Claude launcher
whysosaket Jun 16, 2026
5f8c9ec
feat(shared): add launchable-agent registry
whysosaket Jun 16, 2026
aa47c50
feat(bridge): make the launcher agent-agnostic + carry an instruction
whysosaket Jun 16, 2026
1bb8327
feat(extension): side-panel launcher with agent picker + composer
whysosaket Jun 16, 2026
ca01903
feat(extension): reframe the side-panel launcher as a chat window
whysosaket Jun 16, 2026
9a50090
fix(bridge): make the page context visible in the launched session
whysosaket Jun 16, 2026
3632b40
feat(shared): normalized chat vocabulary + per-agent capability flags
whysosaket Jun 16, 2026
6fc9bfa
feat(bridge): Claude transcript normalizer + history reader
whysosaket Jun 16, 2026
78b63b6
feat(bridge): ClaudeDriver — headless stream-json chat engine (R2)
whysosaket Jun 16, 2026
2285117
feat(bridge): session index + ChatEngine manager
whysosaket Jun 16, 2026
ece3d41
feat(bridge): /chat/* endpoints (SSE stream) + wire ChatEngine into t…
whysosaket Jun 16, 2026
b4436df
feat(bridge): terminal mirror + takeover-eject for chat sessions
whysosaket Jun 16, 2026
0af76a6
feat(extension): side panel becomes a streaming two-way chat client
whysosaket Jun 16, 2026
7e1bdef
docs: document the in-panel chat engine
whysosaket Jun 17, 2026
194872b
feat(bridge): chat_transport config flag + pty_unavailable error
whysosaket Jun 17, 2026
12fc8dd
feat(bridge): incremental transcript tailer + turn-end detection
whysosaket Jun 17, 2026
7c7e0e2
feat(bridge): node-pty wrapper for the interactive transport
whysosaket Jun 17, 2026
07ed20f
feat(bridge): ClaudePtyDriver — interactive claude on a pty
whysosaket Jun 17, 2026
e2c80c0
feat(bridge): select chat transport + fall back to headless
whysosaket Jun 17, 2026
7d0d476
feat(bridge): /chat/pty WebSocket bridges the embedded terminal
whysosaket Jun 17, 2026
8f65121
feat(extension): embedded xterm terminal pane bound to /chat/pty
whysosaket Jun 17, 2026
35a5b45
fix(bridge): pty submit + readiness, found via live claude E2E
whysosaket Jun 17, 2026
732178b
docs: document the interactive-terminal chat transport
whysosaket Jun 17, 2026
05b0278
feat(bridge): relinquish seam so external eject keeps a live read-onl…
whysosaket Jun 17, 2026
72df190
feat(bridge): external eject resumes interactively, never --dangerous…
whysosaket Jun 17, 2026
a5859c2
feat: surface transient 'terminal' capability on /chat/new and /chat/…
whysosaket Jun 17, 2026
ab2c93a
feat(extension): full-window terminal view toggle (chat XOR terminal)
whysosaket Jun 17, 2026
4fee14a
feat(extension): live read-only chat mirror after external eject
whysosaket Jun 17, 2026
c6742af
docs: full-window terminal toggle + live read-only mirror on eject
whysosaket Jun 17, 2026
8b2e383
Merge pull request #19 from quaylabshq/experiment/pty-terminal-chat
whysosaket Jun 17, 2026
4ab577b
Merge pull request #18 from quaylabshq/feat/agent-chat-engine
whysosaket Jun 17, 2026
80296b2
feat(shared): add chat capability metadata
whysosaket Jun 24, 2026
4409ba3
feat(bridge): add multi-agent chat engine support
whysosaket Jun 24, 2026
61a401f
feat(extension): use chat session capabilities
whysosaket Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,27 @@ user's real Chrome tabs (with existing logins intact)

**Procedure runtime.** Named, parameterised action graphs stored under `~/browserops/procedures/` as YAML. Five `procedures_*` tools (search, get, save, delete, execute) manage them. The store ships empty — new procedures are authored on-demand by the `browserops-teach` skill (autonomous) or by hand.

**In-panel chat engine.** Besides the MCP path, the bridge can drive an agent as a **bidirectional chat** for the extension's side panel — turns normalized into `ChatMessage`s and streamed to the panel over **SSE** (`GET /chat/stream`); the panel sends turns via `POST /chat/new` / `/chat/send`. Each agent is a pluggable `AgentDriver` (Codex/Cursor slot in behind the same interface); **history is re-read from the agent's own transcript** (`~/.claude/projects/<slug>/<id>.jsonl`), not a bespoke store. For Claude there are two transports, chosen by the `chat_transport` config flag (env `BROWSEROPS_CHAT_TRANSPORT` overrides):

- **`terminal` (default) — `ClaudePtyDriver`.** Spawns ONE long-lived _interactive_ `claude` on a pty (node-pty). Turns are **injected at the TTY** (a single-line write + a discrete Enter — a combined write or a multi-line paste gets swallowed by the Ink TUI), and the clean chat feed is **tailed from the transcript** (no TUI scraping) by `TranscriptTailer`, which derives thinking/idle from a terminal `stop_reason` with a quiescence fallback. To Anthropic this is an ordinary interactive session — no `--print`, no `--dangerously-skip-permissions` (it uses `--permission-mode acceptEdits` + `--allowedTools mcp__browserops`; safe-mode prompts in the pane). The live raw TUI is exposed over a `/chat/pty` **WebSocket** the panel binds an embedded **xterm.js** terminal to, shown as a **full-window view toggle** (chat _xor_ terminal — the session keeps running across the flip, so the chat stays live underneath); type there and it drives the same session (and shows up in the chat). **External eject** (`/chat/terminal` takeover) hands the session to the user's own terminal via `claude --resume` — also interactive (`--permission-mode acceptEdits`, **never** `--dangerously-skip-permissions`): the driver's `relinquish` kills the pty but keeps the `TranscriptTailer` running, so the panel chat becomes a **live read-only mirror** of the externally-typed turns. A transient `terminalCapable` flag (surfaced on `/chat/new` + `/chat/sessions`) gates the toggle and is cleared on eject.
- **`headless` — `ClaudeDriver`.** `claude --print --output-format stream-json --verbose`, one process per turn (`--session-id` then `--resume`), parsed from stdout; also the **automatic fallback** when node-pty can't load (the engine catches `pty_unavailable` at start). Its `POST /chat/terminal` mirror/takeover (read-only tail of a mirror log / `claude --resume` eject) remains; the takeover resume is interactive (no `--dangerously-skip-permissions`) like the terminal transport's.

The `/chat/*` endpoints reuse the `/launch` two-gate auth (pinned-extension origin + loopback token). However it's spawned, the agent loads the user's own browserops MCP → shim → same daemon socket → same Chrome, so `browser_*` works inside chat too.

## Where to look

| Want to understand… | File |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| The tool catalogue (names, schemas, descriptions) | `packages/shared/src/tools.ts` |
| MCP dispatch pipeline (validate → rate-limit → risky-gate → forward) | `packages/bridge/src/mcp-server.ts` |
| Bridge↔extension WebSocket protocol | `packages/shared/src/protocol.ts` + `packages/bridge/src/ws-server.ts` + `packages/extension/src/background/ws-client.ts` |
| Extension handlers (what actually runs in Chrome) | `packages/extension/src/background/handlers/` |
| Procedure YAML schema + search ranker | `packages/shared/src/procedures.ts` + `packages/bridge/src/procedures/` |
| Error codes + WebSocket close codes | `packages/shared/src/errors.ts` |
| Rate limiter + risky-action gate | `packages/bridge/src/rate-limiter.ts` + `packages/bridge/src/safety.ts` |
| browserops-teach skill (autonomous procedure authoring, replaces teach + autoteach) | `~/.claude/skills/browserops-teach/SKILL.md` (mirror at `skills/browserops-teach.md`) |
| User-facing setup (registration for every major MCP client) | `README.md` |
| Want to understand… | File |
| ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| The tool catalogue (names, schemas, descriptions) | `packages/shared/src/tools.ts` |
| MCP dispatch pipeline (validate → rate-limit → risky-gate → forward) | `packages/bridge/src/mcp-server.ts` |
| Bridge↔extension WebSocket protocol | `packages/shared/src/protocol.ts` + `packages/bridge/src/ws-server.ts` + `packages/extension/src/background/ws-client.ts` |
| In-panel chat engine (drivers incl. `claude/terminal-driver` + `tail` + `pty`, manager, `/chat/*` + `/chat/pty`, side-panel client) | `packages/bridge/src/engine/` + `packages/shared/src/chat.ts` + `packages/extension/src/lib/{chat-client,pty-terminal}.ts` |
| Extension handlers (what actually runs in Chrome) | `packages/extension/src/background/handlers/` |
| Procedure YAML schema + search ranker | `packages/shared/src/procedures.ts` + `packages/bridge/src/procedures/` |
| Error codes + WebSocket close codes | `packages/shared/src/errors.ts` |
| Rate limiter + risky-action gate | `packages/bridge/src/rate-limiter.ts` + `packages/bridge/src/safety.ts` |
| browserops-teach skill (autonomous procedure authoring, replaces teach + autoteach) | `~/.claude/skills/browserops-teach/SKILL.md` (mirror at `skills/browserops-teach.md`) |
| User-facing setup (registration for every major MCP client) | `README.md` |

## browserops orchestration rules

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ A bare `browserops "task"` routes through your configured default launcher (set

Or open the editor you registered in Step 2 and just talk to it — `browserops` is wired in as an MCP server, so any prompt that could use the browser will pick it up.

### Chat from the side panel

You can also chat with an agent **inside Chrome**, no terminal required. Open the BrowserOps popup (toolbar icon) → **Open launcher** to reveal the side panel, then just type. Your first message starts a session, seeded with the page you're on (URL, title, description — so "summarize this" knows what "this" is); replies stream back into the panel, and the agent can drive the very tab you're looking at.

- **Conversation stays in the panel** — keep sending follow-ups, no terminal needed.
- **▤ Terminal** switches the whole panel to a **full-window** live terminal — the real interactive Claude TUI — and toggles back to chat anytime (chat _or_ terminal, never a cramped split). The session keeps running across the toggle, so anything you type in the terminal also shows up in the chat.
- **⤢ Take over** ejects the session into a separate external terminal (`claude --resume <id>`) for full TUI control. The panel then becomes a **live read-only mirror**: it keeps streaming the turns you type in your terminal (input here is locked). The resume runs interactively (`--permission-mode acceptEdits`), **never** with `--dangerously-skip-permissions`.
- The **recent-sessions strip** reopens a past conversation — its transcript is re-read from the agent's own local session files.

Under the hood the panel drives **Claude Code as a normal interactive session** (a real terminal on a pty — no `--print`, no skip-permissions), so it's indistinguishable from running Claude yourself; your turns are injected at the prompt and replies are read from Claude's own transcript. Codex and Cursor are coming, and it honors the same safe-mode toggle as the launcher. (Set `chat_transport: "headless"` in `~/.browserops/config.json` to use the `claude --print` path instead; it's also the automatic fallback when the terminal backend can't load.)

### Daily lifecycle

```bash
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"pnpm": ">=9"
},
"packageManager": "pnpm@9.12.0",
"pnpm": {
"onlyBuiltDependencies": [
"node-pty"
]
},
"scripts": {
"setup": "pnpm install && pnpm build && node packages/cli/dist/index.js init",
"start": "pnpm build && node packages/cli/dist/index.js start",
Expand Down
1 change: 1 addition & 0 deletions packages/bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@modelcontextprotocol/sdk": "^1.13.0",
"ajv": "^8.17.0",
"ajv-formats": "^3.0.1",
"node-pty": "^1.1.0",
"ws": "^8.18.0",
"yaml": "^2.5.0"
},
Expand Down
102 changes: 102 additions & 0 deletions packages/bridge/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadChatTransport, loadSafeMode } from "./config.js";

describe("loadSafeMode", () => {
let dir: string;
let path: string;

beforeEach(async () => {
dir = await fs.mkdtemp(join(tmpdir(), "browserops-config-"));
path = join(dir, "config.json");
});

afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

it("returns false when the config file does not exist", async () => {
await expect(loadSafeMode(join(dir, "missing.json"))).resolves.toBe(false);
});

it("returns false when safe_mode is absent", async () => {
await fs.writeFile(path, JSON.stringify({ features: { procedures: true } }));
await expect(loadSafeMode(path)).resolves.toBe(false);
});

it("returns true only when safe_mode === true", async () => {
await fs.writeFile(path, JSON.stringify({ safe_mode: true }));
await expect(loadSafeMode(path)).resolves.toBe(true);
});

it("treats non-boolean / falsy safe_mode values as false", async () => {
await fs.writeFile(path, JSON.stringify({ safe_mode: "true" }));
await expect(loadSafeMode(path)).resolves.toBe(false);

await fs.writeFile(path, JSON.stringify({ safe_mode: false }));
await expect(loadSafeMode(path)).resolves.toBe(false);
});

it("returns false on malformed JSON rather than throwing", async () => {
await fs.writeFile(path, "{ not json");
await expect(loadSafeMode(path)).resolves.toBe(false);
});
});

describe("loadChatTransport", () => {
let dir: string;
let path: string;
const ENV = "BROWSEROPS_CHAT_TRANSPORT";
let savedEnv: string | undefined;

beforeEach(async () => {
dir = await fs.mkdtemp(join(tmpdir(), "browserops-config-"));
path = join(dir, "config.json");
savedEnv = process.env[ENV];
delete process.env[ENV];
});

afterEach(async () => {
if (savedEnv === undefined) delete process.env[ENV];
else process.env[ENV] = savedEnv;
await fs.rm(dir, { recursive: true, force: true });
});

it("defaults to terminal when the file is missing", async () => {
await expect(loadChatTransport(join(dir, "missing.json"))).resolves.toBe("terminal");
});

it("defaults to terminal when chat_transport is absent", async () => {
await fs.writeFile(path, JSON.stringify({ safe_mode: true }));
await expect(loadChatTransport(path)).resolves.toBe("terminal");
});

it("reads headless from the config file", async () => {
await fs.writeFile(path, JSON.stringify({ chat_transport: "headless" }));
await expect(loadChatTransport(path)).resolves.toBe("headless");
});

it("falls back to terminal on an unrecognised value", async () => {
await fs.writeFile(path, JSON.stringify({ chat_transport: "tmux" }));
await expect(loadChatTransport(path)).resolves.toBe("terminal");
});

it("falls back to terminal on malformed JSON rather than throwing", async () => {
await fs.writeFile(path, "{ not json");
await expect(loadChatTransport(path)).resolves.toBe("terminal");
});

it("lets the env override win over the file", async () => {
await fs.writeFile(path, JSON.stringify({ chat_transport: "terminal" }));
process.env[ENV] = "headless";
await expect(loadChatTransport(path)).resolves.toBe("headless");
});

it("ignores an invalid env override and uses the file", async () => {
await fs.writeFile(path, JSON.stringify({ chat_transport: "headless" }));
process.env[ENV] = "bogus";
await expect(loadChatTransport(path)).resolves.toBe("headless");
});
});
70 changes: 70 additions & 0 deletions packages/bridge/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,35 @@ import { dirname } from "node:path";
import { ENV, type FeatureName, parseFeaturesEnv, resolveFeatures } from "@browserops/shared";
import { configPath } from "./paths.js";

/**
* Which transport the in-panel chat engine uses to drive Claude:
* - `terminal` (default): a real INTERACTIVE `claude` session on a pty — input
* injected at the TTY, output read from Claude's own transcript — which is
* indistinguishable from hand-usage (no `--print`, no skip-permissions).
* - `headless`: the `claude --print` stream-json driver (one process per turn).
* Driver selection degrades `terminal` → `headless` automatically when node-pty
* can't load, so this is a preference, not a hard requirement.
*/
export type ChatTransport = "terminal" | "headless";

const CHAT_TRANSPORTS: readonly ChatTransport[] = ["terminal", "headless"];

function isChatTransport(value: unknown): value is ChatTransport {
return typeof value === "string" && (CHAT_TRANSPORTS as readonly string[]).includes(value);
}

interface ConfigFileShape {
features?: Partial<Record<FeatureName, boolean>>;
/**
* When true, launchers (the CLI `--claude` flag and the extension's
* "Launch Claude here" button) start `claude` WITHOUT
* `--dangerously-skip-permissions`, so Claude prompts for each action.
* Absent/false (the default) means danger mode. Written by the CLI's
* `browserops config set safe_mode <bool>`; mirrors the CLI's getSafeMode.
*/
safe_mode?: boolean;
/** In-panel chat transport. See {@link ChatTransport}. */
chat_transport?: ChatTransport;
}

export interface FeatureResolution {
Expand Down Expand Up @@ -55,6 +82,49 @@ export async function loadConfigFeatures(): Promise<FeatureResolution> {
};
}

/**
* Read the `safe_mode` flag from the config file. Defaults to `false`
* (danger mode) when the file is missing, unreadable, or malformed —
* matching the CLI launcher's getSafeMode so the daemon-side launch path
* and `browserops --claude` agree on the danger flag.
*
* The path is injectable for hermetic tests; production callers use the
* default `configPath()` (`~/.browserops/config.json`).
*/
export async function loadSafeMode(path: string = configPath()): Promise<boolean> {
try {
const raw = await fs.readFile(path, "utf8");
const parsed = JSON.parse(raw) as ConfigFileShape;
return parsed?.safe_mode === true;
} catch {
// ENOENT, parse error, or anything else → default to danger mode.
return false;
}
}

/**
* Resolve the in-panel chat transport. Resolution order:
* 1. the `BROWSEROPS_CHAT_TRANSPORT` env override (one-shot runs / E2E),
* 2. the config file's `chat_transport`,
* 3. the `"terminal"` default.
* Unknown / malformed values fall through to the default rather than throwing —
* driver selection then degrades to headless at runtime if node-pty is missing.
* The path is injectable for hermetic tests.
*/
export async function loadChatTransport(path: string = configPath()): Promise<ChatTransport> {
const envOverride = process.env["BROWSEROPS_CHAT_TRANSPORT"];
if (isChatTransport(envOverride)) return envOverride;
try {
const raw = await fs.readFile(path, "utf8");
const parsed = JSON.parse(raw) as ConfigFileShape | null;
const fromFile = parsed?.chat_transport;
if (isChatTransport(fromFile)) return fromFile;
} catch {
// ENOENT, parse error, or anything else → default.
}
return "terminal";
}

/**
* Merge `updates` into the config file, creating it if absent. Used by the
* CLI's `feature enable / disable` commands. Atomic write via temp-file
Expand Down
Loading