forked from pingdotgg/t3code
-
Notifications
You must be signed in to change notification settings - Fork 0
Add in-app browser host and surface management #146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sak0a
wants to merge
2
commits into
main
Choose a base branch
from
feature/add-in-app-browser-usability
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| # Agent Handoff: Ryco In-App Browser Computer-Use | ||
|
|
||
| ## Repo / branch | ||
|
|
||
| - **Path:** `/Users/laurinfrank/.ryco/worktrees/0ed0fd07-3a8e-4874-8a7d-f15a17a80f5a/feature-add-in-app-browser-usability__budop` | ||
| - **Branch:** `feature/add-in-app-browser-usability` | ||
| - **Status:** ~56 modified/new files, **nothing committed yet** | ||
|
|
||
| ## Goal | ||
|
|
||
| Finish the in-app browser so agents can do real **computer use** (open browser → observe page → click/type → verify) through Ryco's isolated Electron browser, across providers (Codex, Claude, Copilot, OpenCode, Cursor). | ||
|
|
||
| Design spec: `docs/superpowers/specs/2026-06-22-in-app-browser-design.md` | ||
|
|
||
| **AGENTS.md requirements before done:** `bun fmt`, `bun lint`, `bun typecheck`, `bun run test` (not `bun test`). | ||
|
|
||
| --- | ||
|
|
||
| ## What's already implemented | ||
|
|
||
| ### Desktop host (`apps/desktop/src/browser/`) | ||
|
|
||
| - `BrowserKernel.ts`: navigation, input (x/y + **DOM ref click**), screenshot (`capturePage`), structured DOM snapshot (refs + bounds), console/network buffers | ||
| - `BrowserObservationHelpers.ts`: DOM snapshot script, console parsing | ||
| - `BrowserHostConnection.ts`: `screenshots: true` capability | ||
| - `BrowserSurfaceManager.ts`: HiDPI bounds via `resolveElectronSurfaceBounds` from `@ryco/shared/browser` | ||
|
|
||
| ### Server control plane (`apps/server/src/browser/`) | ||
|
|
||
| - `BrowserService.ts`: observation methods (`snapshotDom`, `screenshot`, `readConsole`, `readNetwork`, `waitFor`), agent session cleanup (`closeAgentSessionsForThread`), origin policy enforcement (`source: "agent"` blocks `"ask"` origins) | ||
| - `BrowserArtifactStore.ts`: PNG/JSON blob storage under `{stateDir}/browser-artifacts/` with `readData` | ||
| - `BrowserMcpBridge.ts` + `browser-mcp-stdio.ts` + `BrowserRuntimeMcpConfig.ts`: MCP bridge for OpenCode/Cursor subprocesses | ||
| - `BrowserThreadCleanup.ts`: stops MCP bridge + closes agent sessions after provider turn | ||
| - `server.ts`: wires `BrowserLayerLive` (mergeAll: HostRegistry, Policy, ArtifactStore, McpBridge, BrowserService, ProviderRuntimeToolRegistry) + `ProviderRuntimeEventHubLive` in ProviderLayer | ||
|
|
||
| ### Provider tool bridge (`apps/server/src/provider/tools/`) | ||
|
|
||
| - `BrowserRuntimeTool.ts`: **12 tools** — `browser_open`, `browser_navigate`, back/forward/reload/stop, `browser_input`, `browser_snapshot`, `browser_screenshot`, `browser_console`, `browser_network`, `browser_wait_for` | ||
| - `ProviderRuntimeToolRegistry.ts`: executes tools, emits `browser_tool_call` lifecycle events via `ProviderRuntimeEventHub` | ||
| - `BrowserRuntimeToolHelpers.ts`: result mapping + **PNG image content blocks** for screenshots (`enrichBrowserRuntimeToolCallToolResult`) | ||
| - `BrowserToolLifecycleEvents.ts`: canonical `browser_tool_call` started/completed events | ||
|
|
||
| ### Provider integrations | ||
|
|
||
| | Provider | Status | How | | ||
| |----------|--------|-----| | ||
| | **Claude** | ✅ wired | `ClaudeBrowserRuntimeTools.ts` — in-process MCP server `ryco`, auto-allow in `canUseTool` | | ||
| | **Copilot** | ✅ wired | `CopilotBrowserRuntimeTools.ts` — `defineTool`, negative system prompt removed | | ||
| | **OpenCode** | ✅ wired | MCP bridge at session start | | ||
| | **Cursor/ACP** | ✅ wired | Ryco browser MCP injected into `mcpServers` via `AcpSessionRuntime` | | ||
| | **Codex** | ❌ blocked | `item/tool/call` handler in `CodexSessionRuntime.ts` exists, but `resolveProviderBrowserToolSupport("codex")` returns **`supported: false`** — Codex app-server has no `dynamicTools` field on thread start yet | | ||
|
|
||
| ### Web UI (`apps/web/src/components/`) | ||
|
|
||
| - `BrowserPanel.tsx`: thread profile mode, agent session badge, permission/download toasts, HiDPI surface bounds | ||
| - `BrowserPanel.logic.ts`: session adoption helpers, tests extended | ||
|
|
||
| ### Contracts (`packages/contracts/src/browser.ts`, `rpc.ts`) | ||
|
|
||
| - `BrowserInputAction` supports `{ type: "click", ref }` in addition to x/y | ||
| - Observation result schemas, WS RPC methods for snapshot/screenshot/console/network/waitFor | ||
|
|
||
| ### Tests added/updated (passing in targeted runs) | ||
|
|
||
| - `BrowserService.test.ts`, `BrowserArtifactStore.test.ts`, `BrowserRuntimeTool.test.ts`, `BrowserRuntimeToolHelpers.test.ts`, `ClaudeBrowserRuntimeTools.test.ts`, `BrowserPanel.logic.test.ts` | ||
| - Adapter test layer mocks via `BrowserRuntimeToolTestLayers.ts` | ||
|
|
||
| --- | ||
|
|
||
| ## What's NOT done / known gaps | ||
|
|
||
| 1. **`bun typecheck` fails** — production code is clean (0 non-test errors), but ~168 errors in `apps/server/src/server.test.ts` and `apps/server/src/cli.test.ts` (Effect `unknown` in requirements channel from new browser layers). **AGENTS.md requires full typecheck pass.** | ||
|
|
||
| 2. **Full test suite not re-verified** after last fixes — targeted browser tests pass; full `bun run test` in `apps/server` was interrupted. A bad `Layer.mergeAll` in `server.test.ts` briefly broke 100 tests; fixed back to `BrowserServiceLive.pipe(Layer.provideMerge(...), Layer.provideMerge(browserRuntimeToolTestLayers))`. | ||
|
|
||
| 3. **Codex non-functional** — inbound `item/tool/call` handler is dead code until Codex schema supports advertising tools at thread start. Either regenerate `effect-codex-app-server` when upstream adds `dynamicTools`, or keep `supported: false` with a clear reason. | ||
|
|
||
| 4. **Origin approval UX missing** — agent navigation to `"ask"` origins hard-fails with `origin_denied`; no `browser_origin_approval` request.opened/resolved flow in UI. | ||
|
|
||
| 5. **Single-tab / no popups** — `BrowserKernel` only drives `tabs[0]`; `window.open` denied. OAuth/popup flows break. | ||
|
|
||
| 6. **Session lifecycle** — cleanup on turn end exists, but panel unmount doesn't call `closeSession`; no TTL for abandoned WebContentsViews. | ||
|
|
||
| 7. **`runServer` uses `as` cast** in `server.ts` (line ~495) instead of `satisfies` — layer graph leaks `unknown` at type level; runtime works. | ||
|
|
||
| 8. **Timeline UI** — `browser_tool_call` events emitted server-side; orchestration/timeline rendering may still be incomplete. | ||
|
|
||
| 9. **Nothing committed** — user has not asked for a commit yet. | ||
|
|
||
| --- | ||
|
|
||
| ## Your task: finish to merge-ready | ||
|
|
||
| Follow `AGENTS.md`: `bun fmt`, `bun lint`, `bun typecheck`, `bun run test` must all pass before done. | ||
|
|
||
| ### Priority 0 — Must do before merge | ||
|
|
||
| - [ ] Fix ~168 test typecheck errors in `server.test.ts` / `cli.test.ts`: | ||
| - Likely fix: align `buildAppUnderTest` browser layer with production pattern OR use `browserRuntimeToolTestLayers` mocks consistently | ||
| - Do **not** use `Layer.mergeAll` for browser services in tests without proper `Layer.provide` — caused `Service not found: BrowserHostRegistry` | ||
| - [ ] Run `bun fmt && bun lint && bun typecheck && bun run test` — all must pass | ||
| - [ ] Verify no accidental file loss from git stash/checkout during prior session (confirm `BrowserService.ts` ~994 lines, `BrowserArtifactStore.ts` has `readData` + fs blobs) | ||
|
|
||
| ### Priority 1 — Strongly recommended | ||
|
|
||
| - [ ] Re-run full server test suite (1440+ tests) and fix failures | ||
| - [ ] Manual smoke: desktop app → open Browser panel → agent on Claude drives `browser_open` → `browser_snapshot` → ref click → `browser_screenshot` | ||
| - [ ] Clean up `runServer` typing if possible without breaking launch layer contract in `server.ts:493` | ||
|
|
||
| ### Priority 2 — Nice to have / follow-up | ||
|
|
||
| - [ ] Codex: investigate `effect-codex-app-server` for dynamic tool registration; flip `resolveProviderBrowserToolSupport("codex")` when possible | ||
| - [ ] Origin approval UI for agent `"ask"` navigations (`browser_origin_approval` request type exists in contracts) | ||
| - [ ] Multi-tab / controlled popup support for OAuth | ||
| - [ ] Timeline UI for `browser_tool_call` items in chat | ||
|
|
||
| --- | ||
|
|
||
| ## Key files to read first | ||
|
|
||
| ``` | ||
| apps/server/src/server.ts # layer wiring | ||
| apps/server/src/browser/BrowserService.ts # observation + policy | ||
| apps/server/src/provider/tools/BrowserRuntimeTool.ts | ||
| apps/server/src/provider/tools/ProviderRuntimeToolRegistry.ts | ||
| apps/server/src/provider/tools/ClaudeBrowserRuntimeTools.ts | ||
| apps/server/src/provider/tools/CopilotBrowserRuntimeTools.ts | ||
| apps/server/src/provider/Layers/CodexSessionRuntime.ts | ||
| apps/server/src/browser/BrowserMcpBridge.ts | ||
| apps/desktop/src/browser/BrowserKernel.ts | ||
| apps/web/src/components/BrowserPanel.tsx | ||
| apps/server/src/server.test.ts # fix test layer + typecheck (~line 610 browserLayer, ~line 419 buildAppUnderTest) | ||
| docs/superpowers/specs/2026-06-22-in-app-browser-design.md | ||
| docs/superpowers/plans/2026-06-22-in-app-browser.md | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Architecture (quick ref) | ||
|
|
||
| ``` | ||
| Agent tool call | ||
| → Provider adapter (Claude MCP / Copilot defineTool / OpenCode+Cursor MCP bridge / Codex item/tool/call) | ||
| → ProviderRuntimeToolRegistry.executeBrowserTool | ||
| → BrowserRuntimeTool → BrowserService | ||
| → BrowserHostRegistry → Desktop BrowserKernel (WebContentsView) | ||
| → observation back (snapshot/screenshot/console) via BrowserArtifactStore | ||
| → browser_tool_call lifecycle events → ProviderRuntimeEventHub | ||
| ``` | ||
|
|
||
| Two channels: | ||
|
|
||
| - **UI**: `browser.*` WS RPC + `desktopBridge.browser` surface IPC | ||
| - **Agents**: provider-specific MCP/tools → shared registry → same `BrowserService` | ||
|
|
||
| --- | ||
|
|
||
| ## Pitfalls from prior session | ||
|
|
||
| - **Don't `git stash push -u` carelessly** — lost untracked files twice | ||
| - **Don't use `Layer.mergeAll` for browser layer in tests** without dependency ordering — use `BrowserServiceLive.pipe(Layer.provideMerge(...))` pattern | ||
| - **`BrowserServiceLive` hard `yield* BrowserArtifactStore` at layer init** caused `unknown` leak — use `Effect.serviceOption` at call time in methods, not layer init (partially done) | ||
| - **`server.test.ts` browser layer** should use `browserRuntimeToolTestLayers` mock, not live `BrowserMcpBridgeLive` | ||
| - **`runServer satisfies`** fails due to layer `unknown` — production uses `as` cast as workaround | ||
|
|
||
| --- | ||
|
|
||
| ## Success criteria | ||
|
|
||
| - [ ] `bun fmt` / `bun lint` / `bun typecheck` / `bun run test` all green | ||
| - [ ] Claude (and ideally Copilot/Cursor/OpenCode) can drive browser tools on desktop | ||
| - [ ] Codex either works or is explicitly documented/typed as unsupported with clear reason | ||
| - [ ] No regressions in `server.test.ts` integration tests | ||
| - [ ] User decides when to commit (don't commit unless asked) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { BrowserHostConnection } from "./BrowserHostConnection.ts"; | ||
| import { BrowserKernel } from "./BrowserKernel.ts"; | ||
|
|
||
| export class BrowserHost { | ||
| readonly kernel = new BrowserKernel(); | ||
| readonly connection: BrowserHostConnection; | ||
|
|
||
| constructor(appRunId: string) { | ||
| this.connection = new BrowserHostConnection(appRunId, (command) => | ||
| this.kernel.execute(command), | ||
| ); | ||
| this.kernel.setEventSink((event) => this.connection.publishEvent(event)); | ||
| } | ||
|
|
||
| start(input: { readonly wsBaseUrl: string; readonly token: string }): Promise<void> { | ||
| return this.connection.start(input); | ||
| } | ||
|
|
||
| stop(): Promise<void> { | ||
| return this.connection.stop(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import * as NodeSocket from "@effect/platform-node/NodeSocket"; | ||
| import { | ||
| BrowserHostId, | ||
| BrowserHostRpcGroup, | ||
| BrowserHostRunId, | ||
| BROWSER_HOST_METHODS, | ||
| } from "@ryco/contracts"; | ||
| import type { | ||
| BrowserEvent, | ||
| BrowserHostCommandEnvelope, | ||
| BrowserCommandResult, | ||
| } from "@ryco/contracts"; | ||
| import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; | ||
| import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ||
| import * as Socket from "effect/unstable/socket/Socket"; | ||
|
|
||
| const makeBrowserHostRpcClient = RpcClient.make(BrowserHostRpcGroup); | ||
| type BrowserHostRpcClient = | ||
| typeof makeBrowserHostRpcClient extends Effect.Effect<infer Client, any, any> ? Client : never; | ||
|
|
||
| type CommandHandler = (command: BrowserHostCommandEnvelope) => Promise<BrowserCommandResult>; | ||
|
|
||
| function browserHostWsUrl(wsBaseUrl: string): string { | ||
| const url = new URL(wsBaseUrl); | ||
| url.pathname = "/browser-host/ws"; | ||
| url.search = ""; | ||
| url.hash = ""; | ||
| return url.toString(); | ||
| } | ||
|
|
||
| function browserHostProtocolLayer( | ||
| wsUrl: string, | ||
| token: string, | ||
| ): Layer.Layer<RpcClient.Protocol, never, never> { | ||
| const webSocketConstructorLayer = Layer.succeed( | ||
| Socket.WebSocketConstructor, | ||
| (socketUrl, protocols) => | ||
| new NodeSocket.NodeWS.WebSocket(socketUrl, protocols, { | ||
| headers: { | ||
| authorization: `Bearer ${token}`, | ||
| }, | ||
| }) as unknown as globalThis.WebSocket, | ||
| ); | ||
|
|
||
| return RpcClient.layerProtocolSocket().pipe( | ||
| Layer.provide(Socket.layerWebSocket(wsUrl).pipe(Layer.provide(webSocketConstructorLayer))), | ||
| Layer.provide(RpcSerialization.layerJson), | ||
| ); | ||
| } | ||
|
|
||
| export class BrowserHostConnection { | ||
| private runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never> | null = null; | ||
| private client: BrowserHostRpcClient | null = null; | ||
| private clientScope: Scope.Scope | null = null; | ||
| private heartbeatTimer: ReturnType<typeof setInterval> | null = null; | ||
| private commandStreamAbort = false; | ||
| private readonly handleCommand: CommandHandler; | ||
|
|
||
| readonly hostId: BrowserHostId; | ||
| readonly runId: BrowserHostRunId; | ||
|
|
||
| constructor(appRunId: string, handleCommand: CommandHandler) { | ||
| this.handleCommand = handleCommand; | ||
| this.hostId = BrowserHostId.make(`desktop-browser-host:${appRunId}`); | ||
| this.runId = BrowserHostRunId.make(`desktop-browser-host-run:${crypto.randomUUID()}`); | ||
| } | ||
|
|
||
| async start(input: { readonly wsBaseUrl: string; readonly token: string }): Promise<void> { | ||
| await this.stop(); | ||
| this.commandStreamAbort = false; | ||
| const runtime = ManagedRuntime.make( | ||
| browserHostProtocolLayer(browserHostWsUrl(input.wsBaseUrl), input.token), | ||
| ); | ||
| this.runtime = runtime; | ||
| const clientScope = runtime.runSync(Scope.make()); | ||
| this.clientScope = clientScope; | ||
| const client = await runtime.runPromise(Scope.provide(clientScope)(makeBrowserHostRpcClient)); | ||
| this.client = client; | ||
| await runtime.runPromise( | ||
| client[BROWSER_HOST_METHODS.register]({ | ||
| hostId: this.hostId, | ||
| runId: this.runId, | ||
| capabilities: { | ||
| surface: true, | ||
| persistentProfiles: true, | ||
| temporaryProfiles: true, | ||
| screenshots: true, | ||
| domSnapshot: true, | ||
| input: true, | ||
| downloads: false, | ||
| devtools: false, | ||
| }, | ||
| }), | ||
| ); | ||
| this.startCommandStream(client); | ||
| this.heartbeatTimer = setInterval(() => { | ||
| void this.heartbeat().catch((error) => { | ||
| console.warn("[desktop-browser-host] heartbeat failed", error); | ||
| }); | ||
| }, 5_000); | ||
| this.heartbeatTimer.unref(); | ||
| } | ||
|
|
||
| async stop(): Promise<void> { | ||
| this.commandStreamAbort = true; | ||
| if (this.heartbeatTimer) { | ||
| clearInterval(this.heartbeatTimer); | ||
| this.heartbeatTimer = null; | ||
| } | ||
| const runtime = this.runtime; | ||
| const clientScope = this.clientScope; | ||
| this.runtime = null; | ||
| this.client = null; | ||
| this.clientScope = null; | ||
| if (runtime) { | ||
| if (clientScope) { | ||
| await runtime.runPromise(Scope.close(clientScope, Exit.void)).catch(() => undefined); | ||
| } | ||
| await runtime.dispose(); | ||
| } | ||
| } | ||
|
|
||
| async publishEvent(event: BrowserEvent): Promise<void> { | ||
| const runtime = this.runtime; | ||
| const client = this.client; | ||
| if (!runtime || !client) return; | ||
| await runtime.runPromise( | ||
| client[BROWSER_HOST_METHODS.event]({ | ||
| hostId: this.hostId, | ||
| runId: this.runId, | ||
| event, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| private async heartbeat(): Promise<void> { | ||
| const runtime = this.runtime; | ||
| const client = this.client; | ||
| if (!runtime || !client) return; | ||
| await runtime.runPromise( | ||
| client[BROWSER_HOST_METHODS.heartbeat]({ | ||
| hostId: this.hostId, | ||
| runId: this.runId, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| private startCommandStream(client: BrowserHostRpcClient): void { | ||
| const runtime = this.runtime; | ||
| if (!runtime) return; | ||
| void runtime | ||
| .runPromise( | ||
| Stream.runForEach( | ||
| client[BROWSER_HOST_METHODS.subscribeCommands]({ | ||
| hostId: this.hostId, | ||
| runId: this.runId, | ||
| }), | ||
| (envelope) => | ||
| Effect.promise(async () => { | ||
| if (this.commandStreamAbort) return; | ||
| const result = await this.handleCommand(envelope); | ||
| await runtime.runPromise( | ||
| client[BROWSER_HOST_METHODS.commandResult]({ | ||
| hostId: this.hostId, | ||
| runId: this.runId, | ||
| result, | ||
| }), | ||
| ); | ||
| }), | ||
| ), | ||
| ) | ||
| .catch((error) => { | ||
| if (!this.commandStreamAbort) { | ||
| console.warn("[desktop-browser-host] command stream closed", error); | ||
| } | ||
| }); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add language tags to the fenced blocks.
Both fences are missing a language identifier, which triggers markdownlint
MD040. Usetextfor these handoff/architecture blocks.Suggested edit
Also applies to: 141-149
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 121-121: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools