diff --git a/PLANS.md b/PLANS.md index 4c6522b42..f84e02f7e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -29,6 +29,11 @@ the durable truth after it changes. Git history is the archive. ## Active +- [[plans/web-conversation-surface.md]] — Generalizes WebPi into one Web + conversation surface: a neutral `WebSessionHost` with `pi-rpc`, `acp`, + `claude-stream-json`, and `codex-app-server` transports, first-class + permission requests, and capability-gated UI. Live per-runtime acceptance + remains open. - [[plans/unified-page-topbar.md]] — Unifies navigator and content toolbars across the UI, with fixed page actions and content-owned sidebar restoration. Held on `codex/ui-usability-followup` for visual acceptance. diff --git a/docs/README.md b/docs/README.md index 0bb12c1a2..facef0e99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,8 @@ GitHub navigation. | [[docs/ui-interaction-and-motion.md]] | [UI interaction and motion](ui-interaction-and-motion.md) | Clickable affordances, shared motion tokens, entrances/disclosures, reduced-motion policy | | [[docs/workspace-agent-guidance.md]] | [Workspace agent guidance](workspace-agent-guidance.md) | Always-loaded prompt contract, skill ownership, live CLI authority, guidance versioning | | [[docs/workspace-lifecycle.md]] | [Workspace and Session lifecycle](workspace-lifecycle.md) | Offboarding, departed directories, handoff, restore/purge, Session retirement | -| [[docs/workspace-manager.md]] | [Workspace Manager](workspace-manager.md) | Launcher-owned control plane, WebPi quick start, active-desk inventory, and management boundaries | +| [[docs/workspace-manager.md]] | [Workspace Manager](workspace-manager.md) | Launcher-owned control plane, Web quick start, active-desk inventory, and management boundaries | +| [[docs/web-conversation-surface.md]] | [Web conversation surface](web-conversation-surface.md) | Structured-protocol browser conversation for any runtime: wires, transports, neutral snapshot, permission requests, routes | | [[docs/workspace-template-upgrade.md]] | [Workspace Template Upgrade](workspace-template-upgrade.md) | Managed-asset baselines, three-way review, apply transactions, recovery, and the future Merge/Absorb boundary | | [[docs/workspace-absorb.md]] | [Workspace Absorb](workspace-absorb.md) | Directional Workspace consolidation, collision review, archived source identity, and recovery | | [[docs/workspace-issues-and-scheduling.md]] | [Workspace issues and scheduling](workspace-issues-and-scheduling.md) | Markdown issue contract, global board, schedule scanner, headless execution, Inbox delivery | diff --git a/docs/conversation-provenance.md b/docs/conversation-provenance.md index e44187512..a58daabc4 100644 --- a/docs/conversation-provenance.md +++ b/docs/conversation-provenance.md @@ -75,7 +75,7 @@ Ask Alice and AutoQuant list those colleagues from persistent Directory (`GET /api/workspaces/:id/resumes`) decorates those rows with identity, presence, birth, and latest-execution facts; it never invents roster membership. Settings → Harness controls whether a headless-born Session that -has never opened a TUI or WebPi appears on that shared roster (default off); +has never opened a TUI or Web conversation appears on that shared roster (default off); the Issue page still owns those rows. The roster shows only `presence=active` coworkers; Archive files them without destroying either their `resumeId` or Session record. Soft-delete (`presence=deleted`) is still diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index d57543908..a01c1fb51 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -345,10 +345,10 @@ authorize complete argv, prompts, credentials, or environment values in logs. Pi project trust follows the runtime boundary: -- before TUI or WebPi startup, the Pi adapter records a genuinely undecided +- before TUI or Web startup, the Pi adapter records a genuinely undecided OpenAlice-managed Workspace in the trust store used by that Pi process. This prevents a fresh Quick Chat from stalling behind a terminal-only trust - selector that WebPi cannot render; + selector that the Web surface cannot render; - an explicit saved allow or deny decision on the Workspace or its nearest parent remains authoritative. OpenAlice never flips that decision; - interactive argv does not receive the version-sensitive `--approve` flag. diff --git a/docs/model-semantics-and-runtime-injection.md b/docs/model-semantics-and-runtime-injection.md index 9b299e44f..76432bd9f 100644 --- a/docs/model-semantics-and-runtime-injection.md +++ b/docs/model-semantics-and-runtime-injection.md @@ -474,7 +474,7 @@ Native Agent configuration files may contain user- or runtime-owned settings. The compatibility exporter must update only OpenAlice-owned keys/nodes, preserve unknown data, and restore the prior value on reset where a shared scalar is overridden. It is reached through the advanced deprecated surface; -normal Workspace creation, Quick Chat, Issues, probes, WebPi, and resume do not +normal Workspace creation, Quick Chat, Issues, probes, Web Sessions, and resume do not call it. Pi uses one generic OpenAlice-managed project extension plus local provider and diff --git a/docs/project-structure.md b/docs/project-structure.md index 5cf1e5607..87ac5db31 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -209,6 +209,12 @@ Load-bearing paths: - `src/workspaces/service.ts` — Workspace lifecycle and composition. - `src/workspaces/session-pool.ts` — PTY process ownership. +- `src/workspaces/web-session-host.ts` and `src/workspaces/web-session/` — + the browser conversation surface: one long-lived structured-protocol + process per Session record, projected into a neutral snapshot. Transports + own the wire (`pi-rpc`, `acp`, `claude-stream-json`, `codex-app-server`); + adapters declare `capabilities.web` and compose the process command. The + persisted `SessionRecord.surface` value stays `webpi` for every runtime. - `src/workspaces/harness-surface-manager.ts` — managed Harness web processes, readiness, routes, logs, and cleanup. - `src/workspaces/session-registry.ts` — durable session metadata. @@ -244,7 +250,7 @@ provenance link: Do not use a headless task id directly as a roster or process-attachment id, and do not create another `SessionRecord` when the same `resumeId` changes -between headless, terminal, and WebPi execution. The run is execution +between headless, terminal, and Web execution. The run is execution provenance; `resumeId` is the product identity; `SessionRecord` is its one durable launcher-owned roster record. @@ -323,7 +329,7 @@ Inbox is the durable agent-to-user delivery surface. Agents publish reports or status by calling the injected `inbox_push` capability. Alice stamps the product Session and exact execution identity out-of-band. The user can return to the exact originating Session regardless of whether its first turn was -headless or interactive; opening TUI/WebPi attaches a process to the existing +headless or interactive; opening TUI/Web attaches a process to the existing durable Session record. ## Persistent State diff --git a/docs/remote-access.md b/docs/remote-access.md index 3c85d23e3..040699bbe 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -602,8 +602,9 @@ That is a later protocol, not a shortcut in the SSH phase. The existing Workspace PTY WebSocket crosses the SSH tunnel unchanged. The remote PTY and Agent TUI remain authoritative; the local xterm-compatible surface renders received terminal bytes. Shell, Claude Code, Codex, opencode, -and Pi retain the same terminal semantics. WebPi remains an optional structured -Pi surface, not a prerequisite or replacement for shell/TUI workflows. +and Pi retain the same terminal semantics. The Web conversation surface remains +an optional structured presentation of a runtime's own protocol, not a +prerequisite or replacement for shell/TUI workflows. The browser's core health probe publishes a monotonic recovery generation only when Alice transitions from unavailable back to available. PTY views use that @@ -942,7 +943,7 @@ behavior. - persistent terminal screen history by default; - simultaneous writable control from multiple clients; - replacing Electron with a browser wrapper; -- replacing Shell or native Agent TUIs with Pi/WebPi; +- replacing Shell or native Agent TUIs with the Web conversation surface; - scanning arbitrary remote directories or silently cloning OpenAlice; managed clone/update is restricted to the displayed destination and explicit plan; - installing, pinning, downgrading, or repairing Agent Runtime executables on a diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md index 3c78e5d64..7c2386a36 100644 --- a/docs/ui-interaction-and-motion.md +++ b/docs/ui-interaction-and-motion.md @@ -179,8 +179,28 @@ before rendering and supplies only supported send/stop actions. Missing actions do not produce fake controls. Reasoning, tool input/output, failed operations, and unknown payloads remain inspectable; failures expand their activity details. Presentation must not import runtime APIs, parse provider event discriminators, -or fetch Workspace data. Pi's conversion lives in `webpi-presentation.ts` and -its polling/commands in `useWebPiConversation`; `WebPiView` composes the adapter. +or fetch Workspace data. The backend already projects every runtime wire (Pi +RPC, ACP, Claude stream-json, Codex app-server) into one neutral message list; +`web-presentation.ts` converts that list, `useWebConversation` owns +polling/commands, and `WebSessionView` composes the adapter for any runtime +whose `capabilities.web` is declared. Runtime identity is a presentation fact +(placeholder, stop label, wire tooltip), never a branch on the protocol. + +Runtime requests (tool permissions, file-change approvals, questions) render in +`ConversationRequestCard`, pinned above the composer in the `status` slot +rather than inline in the transcript, so the pending decision cannot scroll +away while it is the only way forward. Options come verbatim from the runtime +and answer with one option id; `allow`/`deny`/`neutral` tones map to the shared +button variants. While a request is pending the phase is `awaiting-input`: the +composer stays in stop mode, the card is the primary action, and further +requests are counted rather than stacked. Answer failures keep the card and +surface the error inline. `notice` items are neutral system remarks between +turns (stopped turn, mode change), not assistant prose. + +Launch affordances (Resume CTA "Open in Web", the Workspace header surface +toggle, Manager Quick Start) gate on `agentSupportsWeb(agents, agent)`; a +runtime without a structured protocol keeps its terminal without a dead button, +and an unloaded runtime list hides the affordance rather than guessing. Pending sends keep and lock their draft until acknowledgement, reject repeated submission, and preserve the draft on failure. Enter respects IME composition; diff --git a/docs/web-conversation-surface.md b/docs/web-conversation-surface.md new file mode 100644 index 000000000..0e7ab3651 --- /dev/null +++ b/docs/web-conversation-surface.md @@ -0,0 +1,117 @@ +# Web conversation surface + +The Web surface presents an existing Workspace Session in the browser through +its runtime's own structured protocol instead of a PTY. It is a presentation of +the same durable Session, not another runtime: the runtime still owns the +transcript, credentials, tools, and approvals; Alice keeps one long-lived child +process per Session record and projects that process's protocol into one +neutral live snapshot. + +Read this guide before changing `src/workspaces/web-session-host.ts`, +`src/workspaces/web-session/`, `composeWebCommand`, the `/api/workspaces/:id/sessions/:sid/web/*` +routes, or the browser modules `useWebConversation`, `web-presentation.ts`, +`WebSessionView`, and `ConversationRequestCard`. Rendering rules live in +[[docs/ui-interaction-and-motion.md]]; Manager-specific launch rules live in +[[docs/workspace-manager.md]]. + +## Ownership + +| Layer | Owner | Must not | +|---|---|---| +| Adapter (`src/workspaces/adapters/.ts`) | Declare `capabilities.web = { wire, permissionPrompts, freshSession }` and compose the structured-mode argv in `composeWebCommand` | Parse protocol output or hold session state | +| Transport (`src/workspaces/web-session/-transport.ts`) | Speak exactly one wire over the child's stdio, drive `WebSessionState`, surface requests, learn the native session id | Spawn processes, touch the registry, or know which adapter launched it | +| Host (`src/workspaces/web-session-host.ts`) | Spawn/supervise the process, own the snapshot revision, dispatch prompt/abort/respond to the transport, tail stderr | Branch on agent ids or wire names | +| Service/routes | Bind the Session record and `resumeId`, enforce one live process per Session, expose the snapshot | Reach into transport internals | +| Browser | Poll the snapshot, group the neutral messages into turns, answer requests with an option id | Import runtime APIs or branch on the wire beyond copy | + +`agy` and `shell` have no structured protocol and stay TUI-only. Do not add a +Web capability to a runtime whose structured mode cannot round-trip tool +approval or cannot reopen an exact recorded conversation. + +## Wires + +| Wire | Runtimes | Process | Permission prompts | Fresh session | +|---|---|---|---|---| +| `pi-rpc` | `pi`, `omp` | `--mode rpc` JSONL; Pi additionally `--approve`, omp `--auto-approve` | none in RPC mode; launch-time approval | yes (RPC allocates the id) | +| `acp` | `cursor`, `grok`, `opencode` | Agent Client Protocol JSON-RPC over stdio (`cursor-agent acp`, `grok agent stdio`, `opencode acp`) | `session/request_permission` with the agent's own options | `session/new`; resume via `session/load` when advertised | +| `claude-stream-json` | `claude` | `-p --input-format stream-json --output-format stream-json --include-partial-messages --permission-prompt-tool stdio` | `control_request` `can_use_tool`; answered with allow/deny | `--session-id ` chosen by the adapter | +| `codex-app-server` | `codex` | `codex app-server --listen stdio://` with MCP registration, `approvalPolicy: on-request`, `sandbox: workspace-write` | `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` (answered with a `decision` enum), `item/permissions/requestApproval` (answered with the granted `permissions` profile + `scope`), `item/tool/requestUserInput` | `thread/start`; resume via `thread/resume` | + +The Web surface never resumes "last": it reopens the exact recorded native id +or starts a fresh conversation the transport reports back, so the Session's +`resumeId` binds to one native transcript exactly as PTY discovery does. + +Codex wire enums (`AskForApproval`, `SandboxMode`) are kebab-case and its +server-request response shapes differ per method; verify against +`codex app-server generate-json-schema --out ` from the installed binary +before changing the transport rather than inferring from TypeScript-style +names. + +Every transport must leave the snapshot in `idle` (or `failed` when the process +is gone) with `error` set when a prompt is rejected before the turn starts — +for example missing credentials. A snapshot stuck in `working` with no turn in +flight is a transport bug, not a runtime condition. + +## Neutral model + +`src/workspaces/web-session/model.ts` is the contract the browser mirrors in +`ui/src/components/workspace/api.ts`: + +- `WebConversationMessage` borrows Pi's minimal roles — `user`, `assistant` + (text / thinking / toolCall / data parts), `toolResult`, plus `notice` for + system remarks and `unknown` for records a transport could not classify. + Keep unknown records; they are the audit trail for protocol drift. +- `WebPermissionRequest` carries the runtime's own option list. Transports + translate protocol enums into `{ id, label, tone }`; the browser answers + with the same `id`. Never synthesize options a runtime did not offer. +- `WebSessionPhase` adds `awaiting-input` to the WebPi phases: the turn is + still in flight and blocked on the user. Prompting during it is rejected; + aborting drops the pending requests. +- `streamingMessage` is the cumulative in-flight assistant message and is + replaced, never appended; transports move it into `messages` when the turn + ends. + +The snapshot is ephemeral. Do not persist it, do not migrate it, and do not +read it back as a transcript. + +## Persisted surface value + +`SessionRecord.surface` keeps the shipped value `webpi` (migration 0040) for +every runtime that opens in the Web surface. Renaming it would require a +migration for no behavioral gain; free-floating identifiers (routes, host, +components, labels) use "Web". User-facing copy says "Web", never "WebPi". + +## Routes + +- `POST /web/open` — checks `capabilities.web`, refuses a Session with a + running headless turn, disposes a PTY on the same record, starts the host. +- `GET /web?revision=` — snapshot or `{ unchanged: true }`. +- `POST /web/prompt`, `POST /web/abort` — turn control. +- `POST /web/respond { requestId, optionId }` — answers one request; the + transport validates the option id and fails with `web_respond_failed`. + +Switching a running Web Session to the TUI stops the Web process first; the +reverse disposes the PTY. Exactly one process may own a Session record. + +## Browser + +`agentSupportsWeb(agents, agent)` is the only gate for launch affordances. +Runtime identity affects copy (placeholder, stop label, wire tooltip) and +nothing else. The request card is pinned above the composer; see +[[docs/ui-interaction-and-motion.md]] for the interaction rules. Demo mode +mirrors the capability table in `ui/src/demo/fixtures/web-session.ts` and +scripts a permission turn for prompting runtimes; update it with any contract +change. + +## Verification + +- `npx tsc --noEmit`, `pnpm test`, `cd ui && npx tsc -b`. +- `src/workspaces/web-session-host.spec.ts` drives a fake child over stdio for + every wire; extend it when a transport learns a new message. +- `src/workspaces/adapters/web-command.spec.ts` pins each runtime's argv. +- `pnpm -F open-alice-ui dev:demo`: open a Claude/Codex/ACP quick chat, answer + the request card, stop mid-turn, and confirm the notice. +- Live acceptance needs installed runtimes and is not part of routine CI: for + each runtime, open one Session in Web, send a prompt that needs a tool, + answer the card, stop mid-turn, then reopen the same Session in the TUI and + confirm the native transcript is shared. State the gap when this was not run. diff --git a/docs/workspace-absorb.md b/docs/workspace-absorb.md index 46badb042..5c0abe84a 100644 --- a/docs/workspace-absorb.md +++ b/docs/workspace-absorb.md @@ -60,7 +60,7 @@ target Session the author of the source's old work. Preview and apply inspect process-backed Workspace activity, not persisted `state: running` flags or a bare counter. The review lists the exact open TUI, -WebPi, and headless turns. An exited child process is pruned from the guard even +Web, and headless turns. An exited child process is pruned from the guard even if an outer cleanup promise was lost, preventing a zombie “someone is working” blocker. diff --git a/docs/workspace-manager.md b/docs/workspace-manager.md index 6eab762bb..e11594e4b 100644 --- a/docs/workspace-manager.md +++ b/docs/workspace-manager.md @@ -31,8 +31,13 @@ runtime. Its runtime picker consumes the same registered Agent list, saved default, install state, readiness, credential, model, and context contract as Quick Chat. `useAgentLaunchConfig` owns that resolution and the shared -`AgentLaunchControls` components render it on both surfaces. Pi uses WebPi; -Claude, Codex, and OpenCode retain their native TUI surfaces. +`AgentLaunchControls` components render it on both surfaces. Quick Start opens +Pi in the Web conversation surface; Claude, Codex, and OpenCode start in their +native TUI surfaces. A paused Manager Session of any runtime that declares +`capabilities.web` may later be reopened in the Web surface from its resume +choice; that reopen carries the same manager options (`appendSystemPrompt`, +skills, project approval) and each adapter projects what its structured mode +supports. For OpenCode and Pi, the summary describes the exact credential, model, and context that the next launch will inject. An existing Manager config wins over @@ -70,7 +75,7 @@ files remain owned by their Workspace at `.alice/sessions/.json`. ## Runtime Contract Every Manager runtime receives the same launcher-owned role contract. Pi appends -it as a system prompt and loads `default/skills/workspace-manager` on every WebPi +it as a system prompt and loads `default/skills/workspace-manager` on every Web start, including resume after restart. Native TUIs receive the contract in the fresh interactive seed because those CLIs do not share one portable system- prompt flag; their durable native transcript carries it across later resumes. @@ -85,9 +90,9 @@ The contract says: - choose a target Workspace for durable work, and commit any approved direct edit inside that target. -WebPi explicitly approves this launcher-owned cwd. There is no TUI trust prompt -to render, and entering the dedicated manager surface is the user's visible -approval for the bundled skill and control-plane directory. Native runtimes keep +The Web surface explicitly approves this launcher-owned cwd. There is no TUI +trust prompt to render, and entering the dedicated manager surface is the +user's visible approval for the bundled skill and control-plane directory. Native runtimes keep their existing login, provider-injection, install, and trust behavior. OpenCode's OpenTUI startup asks the terminal emulator for cursor, mode, color, @@ -144,7 +149,7 @@ apply remains preview-first. - `src/workspaces/manager-workspace.ts` — reserved identity and system contract. - `src/workspaces/service.ts` — special runtime resolution and durable Sessions. -- `src/workspaces/adapters/pi.ts` — explicit WebPi prompt/skill/trust flags. +- `src/workspaces/adapters/pi.ts` — explicit Web (RPC) prompt/skill/trust flags. - `src/tool/workspace-list.ts` — active floor inventory. - `src/server/cli.ts` and `src/server/cli-commands.ts` — embedded CLI exposure. - `src/webui/routes/workspaces.ts` — manager status, quick start, resume, and @@ -154,7 +159,7 @@ apply remains preview-first. model, context, and launch-parameter resolution. - `ui/src/components/workspace/AgentLaunchControls.tsx` — shared selectors and truthful launch summary. -- `ui/src/pages/WorkspaceManagerPage.tsx` — manager composer and WebPi/TUI shell. +- `ui/src/pages/WorkspaceManagerPage.tsx` — manager composer and Web/TUI shell. - `ui/src/components/workspace/ChatWorkspaceSection.tsx` — Chat sidebar entry. ## Verification @@ -175,7 +180,7 @@ Then use the real `/chat/manager` route with at least two available runtimes: saved default; 2. on Pi or OpenCode, verify the visible model/context matches the Manager Workspace config, switch provider, and confirm the launch uses the new one; -3. start one Pi/WebPi and one native-TUI Manager Session, then reopen both from +3. start one Pi/Web and one native-TUI Manager Session, then reopen both from the collapsible Manager list in the Chat sidebar; 4. inventory the active floor and confirm real `peer list` tool use; 5. compare a harmless `--ws-id` reconstruction with an exact `--resume-id` diff --git a/docs/workspace-template-upgrade.md b/docs/workspace-template-upgrade.md index 02d7d91b1..39a3e2a66 100644 --- a/docs/workspace-template-upgrade.md +++ b/docs/workspace-template-upgrade.md @@ -84,7 +84,7 @@ Apply takes the shared checkout-operation lease and is serialized per Workspace. Offboarding uses the same lease; a future Merge/Absorb operation must do so as well, so directory reconciliation and directory moves cannot race. Apply also refuses to start while an interactive -Session, WebPi Session, or headless run is active. It also refuses an already +Session, Web Session, or headless run is active. It also refuses an already staged Git index so the template change cannot absorb an unrelated staged change. diff --git a/plans/web-conversation-surface.md b/plans/web-conversation-surface.md new file mode 100644 index 000000000..d929f3b99 --- /dev/null +++ b/plans/web-conversation-surface.md @@ -0,0 +1,124 @@ +# Plan: Web conversation surface for every structured Agent runtime + +**Status:** active +**Owner guides:** [[docs/web-conversation-surface.md]], [[docs/ui-interaction-and-motion.md]], [[docs/workspace-manager.md]] +**Delivery:** PR `cursor/web-conversation-transports-1c9d` → `dev` (`area:workspace`, `area:ui`). + +## Goal + +WebPi proved that a browser conversation over a long-lived structured CLI +process is a better product surface than a PTY for many tasks. It shipped as a +Pi-only special case: the host spoke Pi's RPC protocol, the routes checked +`agent === 'pi'`, and the UI gated every affordance on the same literal. + +This plan turns "WebPi" into one Web surface that any Agent runtime can join by +declaring a wire protocol. The browser keeps the adapter-neutral conversation +presentation from PR #1385; Alice gains a transport layer that normalizes each +runtime's live protocol into one message model, one phase model, and one +permission-request model. + +## Alternatives considered + +1. **One native host per runtime** (the WebPi approach repeated N times). + Closest fit to each CLI, but N protocol parsers and N snapshot shapes, and + the browser would need N presenters. Rejected as the primary structure. +2. **Everything through ACP** (Agent Client Protocol). One client covers + cursor, grok, opencode, omp natively; claude, codex, and pi need an extra + npm adapter package, agy has no trustworthy implementation, and ACP is the + lowest common denominator (no compaction, model, or thinking controls + without vendor `_meta`). Rejected as the only path, adopted as one transport. +3. **Hybrid (chosen):** a neutral `WebSessionHost` with pluggable transports. + `pi-rpc` reuses the existing code for pi and omp; `acp` covers the three + runtimes whose own binary speaks ACP; `claude-stream-json` and + `codex-app-server` use the vendor protocols that are richer than ACP and + need no extra install. Pi's minimal message shape becomes the neutral + model because every other protocol maps onto it losslessly enough for + presentation, and the browser presenter already understands it. + +## Decisions + +- The neutral model is presentation-grade, not a persisted store. Each + runtime's own transcript remains the durable conversation; Alice keeps one + live process per Session record, exactly as WebPi did. +- `SessionRecord.surface: 'webpi'` is a shipped persisted value (migration + 0040) and stays. It now means "structured web conversation" for any agent. + HTTP paths move from `/webpi/*` to `/web/*` because UI and server ship + together; no compatibility alias. +- Adapters opt in with `capabilities.web = { wire }` plus `composeWebCommand`. + The UI reads the capability from `/api/workspaces/agents`; no runtime id + literal decides whether a Web button exists. +- Permission prompts become first-class: transports surface + `session/request_permission` (ACP), `can_use_tool` control requests + (Claude), and `item/*/requestApproval` (Codex) as neutral requests with + options; the browser answers through `POST .../web/respond`. Pi and omp + keep launch-time approval (`--approve` / `--auto-approve`) because their RPC + modes have no per-tool prompt. +- Fresh Web sessions are allowed for runtimes that create sessions in-band + (ACP `session/new`, Codex `thread/start`, Claude `--session-id`, omp fresh + RPC). The transport reports the native id and Alice binds it to the + `resumeId` the same way PTY discovery does. +- agy stays TUI-only: its new `--input-format stream-json` has no permission + round-trip and no native ACP; revisit when either lands. +- Workspace Manager Quick Start keeps opening Pi in Web and other runtimes in + their TUI. A paused Manager Session of any web-capable runtime may be + reopened in Web from its resume choice; the manager options travel with it + and each adapter projects what its structured mode supports + (`--append-system-prompt` for claude/omp/pi, `--rules` for grok). + +## UI design decision + +Alternatives for permission prompts: (a) inline as a transcript item, (b) a +modal dialog, (c) a card pinned above the composer. (c) is chosen: it keeps +the transcript an audit trail, does not steal focus from a user who is typing +a follow-up, and matches the compaction status treatment already pinned in +the same slot. Options render as buttons in the order the runtime supplies; +allow-style options are primary, reject-style are outline. Reduced motion is +inherited from the shared primitives. + +## Work + +- [x] Neutral message/request/snapshot model and transport contract +- [x] `WebSessionHost` with process supervision shared by all transports +- [x] `pi-rpc` transport (pi, omp) extracted from `WebPiSessionHost` +- [x] `acp` transport (cursor, grok, opencode) with permission requests +- [x] `claude-stream-json` transport with `can_use_tool` and interrupt +- [x] `codex-app-server` transport with approvals and `turn/interrupt` +- [x] Adapter capability declarations and `composeWebCommand` per runtime +- [x] Service/routes: `/web/*`, `respond`, native-id binding, capability checks +- [x] UI: generic hook/presenter/view, permission cards, capability gating, demo +- [x] Owner guide ([[docs/web-conversation-surface.md]]) + doc updates +- [x] Real-binary handshake and prompt-path smoke for every wire (see + verification); fixed the Codex enum casing, permission-response shape, and + Pi rejected-prompt phase it uncovered +- [ ] Credentialed live acceptance per runtime: tool-using prompt, answer the + permission card, stop mid-turn, reopen in the TUI (see verification) + +## Verification + +- `npx tsc --noEmit`, `pnpm test`, `cd ui && npx tsc -b`. +- Transport specs drive fake child processes over stdio for every wire. +- Demo route (`pnpm -F open-alice-ui dev:demo`) walks open → prompt → + permission request → respond → stop for a non-Pi runtime. +- Real-binary smoke (done once with `@earendil-works/pi-coding-agent` 0.85.1, + `opencode-ai` 1.18.29, `@openai/codex` 0.153.4, `@anthropic-ai/claude-code` + 2.1.263 installed under a throwaway `HOME`, driving `WebSessionHost` with the + argv each adapter composes): + - every wire completes its handshake (`pi-rpc` `get_state`, ACP + `initialize`→`session/new`, Codex `initialize`→`thread/start`, Claude + stays live and binds the session id on `system/init`); + - without credentials, Pi/Codex/Claude each surface the runtime's own + auth error as `snapshot.error` and return to `idle` instead of sticking in + `working`; + - OpenCode's bundled free model completed a real turn over ACP (user → + thinking → text), so that wire is accepted end to end. + - Codex request/response shapes were checked against + `codex app-server generate-json-schema` rather than memory. +- Credentialed acceptance still needs a maintainer machine: per runtime, open + one Session in Web, send a prompt that needs a tool, answer the card, stop + mid-turn, then reopen the same Session in the TUI and confirm the native + transcript is shared. + +## Completion + +Delete this file and its [[PLANS.md]] bullet when the live acceptance is +recorded and the PR is accepted. diff --git a/src/webui/routes/workspaces.spec.ts b/src/webui/routes/workspaces.spec.ts index 37a2700c7..0c8700c38 100644 --- a/src/webui/routes/workspaces.spec.ts +++ b/src/webui/routes/workspaces.spec.ts @@ -1630,16 +1630,16 @@ describe('POST /:id/sessions/:sid/resume — concurrent coalescing (ANG-120)', ( }); }); -describe('WebPi surface routes', () => { +describe('Web surface routes', () => { const TOKEN = 'pi-calm-amber-river'; - function buildWebPi() { + function buildWeb(agent = 'pi', capabilities: Record = { resumeById: true, web: { wire: 'pi-rpc', permissionPrompts: false, freshSession: true } }) { const order: string[] = []; const record = { id: TOKEN, - resumeId: 'resume-webpi', + resumeId: 'resume-web', wsId: 'ws-1', - agent: 'pi', + agent, name: 'p1', createdAt: '2026-07-12T00:00:00.000Z', lastActiveAt: '2026-07-12T00:00:00.000Z', @@ -1649,30 +1649,36 @@ describe('WebPi surface routes', () => { const snapshot = { recordId: TOKEN, wsId: 'ws-1', - resumeId: 'resume-webpi', + resumeId: 'resume-web', + agent, + wire: 'pi-rpc', + nativeSessionId: 'native-pi', pid: 9001, startedAt: 1, phase: 'idle', - state: {}, messages: [], streamingMessage: null, + requests: [], error: null, stderrTail: '', revision: 1, }; const adapter = { - id: 'pi', - capabilities: { resumeById: true }, + id: agent, + displayName: agent, + capabilities, + composeWebCommand: capabilities['web'] ? vi.fn(() => [agent]) : undefined, readAiConfig: vi.fn(async () => ({ baseUrl: 'https://example.test', apiKey: 'test', model: 'model' })), writeAiConfig: vi.fn(async () => undefined), lifecycle: { prepareWorkspace: vi.fn(async () => { order.push('prepare-workspace'); }) }, }; - const webPi = { + const web = { get: vi.fn(() => snapshot), has: vi.fn(() => false), stop: vi.fn(async () => false), prompt: vi.fn(async () => ({ ...snapshot, phase: 'working' })), abort: vi.fn(async () => snapshot), + respond: vi.fn(async () => ({ ...snapshot, requests: [] })), }; const svc = { registry: { get: () => ({ id: 'ws-1', dir: '/w' }) }, @@ -1686,34 +1692,58 @@ describe('WebPi surface routes', () => { get: vi.fn(() => ({ pid: 123, startedAt: 1 })), disposeToken: vi.fn(() => { order.push('terminal-stopped'); return true; }), }, - webPi, - startWebPiSession: vi.fn(async () => { order.push('webpi-started'); return snapshot; }), + web, + startWebSession: vi.fn(async () => { order.push('web-started'); return snapshot; }), isResumeActive: vi.fn(() => false), config: { launcherRepoRoot: '/repo' }, } as unknown as WorkspaceService; - return { app: createWorkspaceRoutes(svc), order, svc, webPi }; + return { app: createWorkspaceRoutes(svc), order, svc, web }; } - it('hands an existing Pi Session from its PTY to WebPi', async () => { - const { app, order, svc } = buildWebPi(); - const result = await post(app, `/ws-1/sessions/${TOKEN}/webpi/open`); + it('hands an existing Session from its PTY to the Web surface', async () => { + const { app, order, svc } = buildWeb(); + const result = await post(app, `/ws-1/sessions/${TOKEN}/web/open`); expect(result.status).toBe(200); - expect(result.body.snapshot).toMatchObject({ resumeId: 'resume-webpi', phase: 'idle' }); - expect(order).toEqual(['prepare-workspace', 'terminal-stopped', 'webpi-started']); - expect(svc.startWebPiSession).toHaveBeenCalledOnce(); + expect(result.body.snapshot).toMatchObject({ resumeId: 'resume-web', phase: 'idle' }); + expect(order).toEqual(['prepare-workspace', 'terminal-stopped', 'web-started']); + expect(svc.startWebSession).toHaveBeenCalledOnce(); }); - it('passes browser prompts straight to the live Pi RPC host', async () => { - const { app, webPi } = buildWebPi(); - const result = await post(app, `/ws-1/sessions/${TOKEN}/webpi/prompt`, { message: 'hello Pi' }); + it('opens any runtime that declares a Web capability, not only Pi', async () => { + const { app, svc } = buildWeb('codex', { resumeById: true, web: { wire: 'codex-app-server', permissionPrompts: true, freshSession: true } }); + const result = await post(app, `/ws-1/sessions/${TOKEN}/web/open`); expect(result.status).toBe(200); - expect(webPi.prompt).toHaveBeenCalledWith(TOKEN, 'hello Pi'); + expect(svc.startWebSession).toHaveBeenCalledOnce(); + }); + + it('refuses runtimes without a Web capability instead of checking the agent id', async () => { + const { app, svc } = buildWeb('agy', { resumeById: true }); + const result = await post(app, `/ws-1/sessions/${TOKEN}/web/open`); + expect(result.status).toBe(409); + expect(result.body.error).toBe('unsupported_surface'); + expect(svc.startWebSession).not.toHaveBeenCalled(); + }); + + it('passes browser prompts straight to the live host', async () => { + const { app, web } = buildWeb(); + const result = await post(app, `/ws-1/sessions/${TOKEN}/web/prompt`, { message: 'hello Pi' }); + expect(result.status).toBe(200); + expect(web.prompt).toHaveBeenCalledWith(TOKEN, 'hello Pi'); expect(result.body.snapshot.phase).toBe('working'); }); + it('answers runtime permission requests with the chosen option', async () => { + const { app, web } = buildWeb(); + const result = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'acp-7', optionId: 'allow_once' }); + expect(result.status).toBe(200); + expect(web.respond).toHaveBeenCalledWith(TOKEN, 'acp-7', 'allow_once'); + const bad = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'acp-7' }); + expect(bad.status).toBe(400); + }); + it('returns a tiny unchanged response when the browser already has the revision', async () => { - const { app } = buildWebPi(); - const result = await get(app, `/ws-1/sessions/${TOKEN}/webpi?revision=1`); + const { app } = buildWeb(); + const result = await get(app, `/ws-1/sessions/${TOKEN}/web?revision=1`); expect(result.status).toBe(200); expect(result.body).toEqual({ unchanged: true, revision: 1 }); }); @@ -1795,7 +1825,7 @@ describe('Workspace manager surface routes', () => { ); }); - it('starts a launcher-owned Pi conversation directly in WebPi with the manager contract', async () => { + it('starts a launcher-owned Pi conversation directly in the Web surface with the manager contract', async () => { const meta = { id: 'workspace-manager', tag: 'Workspace Manager', @@ -1824,7 +1854,7 @@ describe('Workspace manager surface routes', () => { stderrTail: '', revision: 1, }; - const startWebPiSession = vi.fn(async () => snapshot); + const startWebSession = vi.fn(async () => snapshot); const prompt = vi.fn(async () => snapshot); const disposeToken = vi.fn(() => true); const ensureManagerSession = vi.fn(async (input: any) => { @@ -1900,8 +1930,8 @@ describe('Workspace manager surface routes', () => { disposeToken, }, isResumeActive: vi.fn(() => false), - startWebPiSession, - webPi: { get: vi.fn(() => snapshot), prompt }, + startWebSession, + web: { get: vi.fn(() => snapshot), prompt }, config: { launcherRepoRoot: '/repo' }, } as unknown as WorkspaceService; const app = createWorkspaceRoutes(svc); @@ -1918,8 +1948,8 @@ describe('Workspace manager surface routes', () => { session: { wsId: 'workspace-manager', agent: 'pi', surface: 'webpi' }, snapshot: { phase: 'working' }, }); - expect(disposeToken).toHaveBeenCalledWith(createdRecord.id, 'switch fresh manager Session to WebPi'); - expect(startWebPiSession).toHaveBeenCalledWith( + expect(disposeToken).toHaveBeenCalledWith(createdRecord.id, 'switch fresh manager Session to Web'); + expect(startWebSession).toHaveBeenCalledWith( meta, createdRecord, expect.objectContaining({ @@ -1948,7 +1978,7 @@ describe('Workspace manager surface routes', () => { }; let spawnedContext: any = null; let liveSession: any = null; - const startWebPiSession = vi.fn(); + const startWebSession = vi.fn(); const ensureManagerSession = vi.fn(async (input: any) => { const identity = { resumeId: 'resume-manager-codex', @@ -2027,8 +2057,8 @@ describe('Workspace manager surface routes', () => { }), }, isResumeActive: vi.fn(() => false), - startWebPiSession, - webPi: { get: vi.fn(() => null) }, + startWebSession, + web: { get: vi.fn(() => null) }, config: { launcherRepoRoot: '/repo' }, } as unknown as WorkspaceService; const app = createWorkspaceRoutes(svc); @@ -2061,7 +2091,7 @@ describe('Workspace manager surface routes', () => { expect(result.body).toMatchObject({ session: { title: 'Map ownership.' } }); expect(spawnedContext.initialPrompt).toContain('OpenAlice Workspace Manager'); expect(spawnedContext.initialPrompt).toContain('User request:\nMap ownership.'); - expect(startWebPiSession).not.toHaveBeenCalled(); + expect(startWebSession).not.toHaveBeenCalled(); const unsupported = await post(app, '/manager/quick-start', { prompt: 'Open a shell.', diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index a8ee5592b..bed601f60 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -7,7 +7,7 @@ import { prepareProjectWorkspaces, readProjectWorkspaceSetup } from '../../works * the original `server/src/index.ts` `handleHttp` switch did. */ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join, resolve as resolvePath } from 'node:path'; @@ -597,12 +597,12 @@ export function createWorkspaceRoutes( const publicSession = (record: SessionRecord): PublicSession => { const terminal = svc.pool.get(record.id); - const browser = svc.webPi?.get(record.id) ?? null; + const browser = svc.web?.get(record.id) ?? null; const identity = svc.resumeRegistry.get(record.resumeId); const binding = identity?.runtimeBinding; return projectPublicSession(record, { terminal, - webPi: browser, + web: browser, headless: svc.isResumeActive(record.resumeId), runtimeBinding: binding, ...(identity?.displayName ? { displayName: identity.displayName } : {}), @@ -629,7 +629,7 @@ export function createWorkspaceRoutes( ): Promise => { await svc.sessionRegistry.ensureLoaded(meta.id); const existing = svc.sessionRegistry.findByResumeId(meta.id, resumeId); - if (existing && (svc.pool.get(existing.id) || svc.webPi.get(existing.id))) { + if (existing && (svc.pool.get(existing.id) || svc.web.get(existing.id))) { return { ok: true, created: false, session: publicSession(existing) }; } const identity = svc.resumeRegistry.get(resumeId); @@ -691,10 +691,10 @@ export function createWorkspaceRoutes( } }; - const managerWebPiOptions = { + const managerWebOptions = { appendSystemPrompt: MANAGER_SYSTEM_PROMPT, skills: [managerSkillPath(svc.config.launcherRepoRoot)], - // WebPi has no TUI in which it could render Pi's trust prompt. Entering the + // The Web surface has no TUI in which it could render Pi's trust prompt. Entering the // explicit manager surface is the user's approval for its launcher-owned // skill and active-office-floor cwd. approveProject: true, @@ -722,8 +722,9 @@ export function createWorkspaceRoutes( // ── launcher-owned Workspace manager ─────────────────────────────────── // The manager's cwd is the active office floor, but it is intentionally not // inserted into the business Workspace registry. Its sessions live in the - // same durable Session/Resume registries. Pi opens through WebPi; the other - // supported agent runtimes keep their native TUI surface. + // same durable Session/Resume registries. Pi opens through the Web surface + // with the manager contract; the other runtimes keep their native TUI until + // they gain a manager-prompt injection path. app.get('/manager', async (c) => c.json({ manager: await publicManager() })); app.post('/manager/quick-start', async (c) => { @@ -797,9 +798,9 @@ export function createWorkspaceRoutes( // A fresh native Pi id is allocated by the ordinary interactive spawn // seam. Stop its unused TUI immediately, then reopen that exact native // conversation in RPC mode and submit the visible user prompt. - svc.pool.disposeToken(record.id, 'switch fresh manager Session to WebPi'); - await svc.startWebPiSession(meta, record, managerWebPiOptions); - const snapshot = await svc.webPi.prompt(record.id, prompt); + svc.pool.disposeToken(record.id, 'switch fresh manager Session to Web'); + await svc.startWebSession(meta, record, managerWebOptions); + const snapshot = await svc.web.prompt(record.id, prompt); return c.json({ manager: await publicManager(), session: publicSession(record), @@ -1783,7 +1784,7 @@ export function createWorkspaceRoutes( && ( interactive.state === 'running' || svc.pool.get(interactive.id) - || svc.webPi?.has(interactive.id) + || svc.web?.has(interactive.id) ) ) { return c.json({ @@ -2149,8 +2150,8 @@ export function createWorkspaceRoutes( } } const wasTerminalRunning = svc.pool.disposeToken(token, action === 'pause' ? 'paused' : 'tab stop'); - const wasWebPiRunning = await svc.webPi?.stop(token, action === 'pause' ? 'paused' : 'tab stop') ?? false; - const wasRunning = wasTerminalRunning || wasWebPiRunning; + const wasWebRunning = await svc.web?.stop(token, action === 'pause' ? 'paused' : 'tab stop') ?? false; + const wasRunning = wasTerminalRunning || wasWebRunning; if (record) { const patch: Partial = { state: 'paused', @@ -2176,7 +2177,7 @@ export function createWorkspaceRoutes( resumeId: record.resumeId, agent: record.agent, sessionRecordId: record.id, - surface: wasWebPiRunning && !wasTerminalRunning ? 'webpi' : 'terminal', + surface: wasWebRunning && !wasTerminalRunning ? 'webpi' : 'terminal', status: 'paused', }) } @@ -2195,7 +2196,7 @@ export function createWorkspaceRoutes( if ( record.state !== 'paused' || svc.pool.get(token) - || svc.webPi?.has(token) + || svc.web?.has(token) ) { return c.json({ error: 'session_not_paused', @@ -2308,7 +2309,7 @@ export function createWorkspaceRoutes( } // Choosing the terminal surface is an explicit handoff. Never leave Pi's // RPC host and PTY alive against the same native session file. - if (svc.webPi?.has(token)) await svc.webPi.stop(token, 'switch to terminal'); + if (svc.web?.has(token)) await svc.web.stop(token, 'switch to terminal'); const meta = svc.resolveRuntimeWorkspace?.(id) ?? svc.registry.get(id); if (!meta) return c.json({ error: 'workspace_not_found' }, 404); const adapter = svc.adapters.get(record.agent); @@ -2483,38 +2484,49 @@ export function createWorkspaceRoutes( } }); - // WebPi is a presentation of an existing Pi Session, not another runtime. - // The four routes below expose Pi's own RPC state/messages without adapting - // them into OpenAlice message blocks. - app.post('/:id/sessions/:sid/webpi/open', async (c) => { + // The Web surface is a presentation of an existing Session through its + // runtime's structured protocol, not another runtime. The routes below + // expose the neutral live snapshot; adapters and transports own the wire. + const webSessionContext = (c: Context) => { const id = c.req.param('id'); const token = c.req.param('sid'); - if (!validId(id) || !validId(token)) return c.json({ error: 'not_found' }, 404); + if (!validId(id) || !validId(token)) return null; + const record = svc.sessionRegistry.get(id, token); + if (!record) return null; + return { id, token, record }; + }; + + app.post('/:id/sessions/:sid/web/open', async (c) => { + const ctx = webSessionContext(c); + if (!ctx) return c.json({ error: 'not_found' }, 404); + const { id, token, record } = ctx; // Older embedders/tests may provide only the business registry. Keep the // ordinary Workspace path compatible while the launcher-owned manager is // resolved through the newer service seam. const meta = svc.resolveRuntimeWorkspace?.(id) ?? svc.registry.get(id); - const record = svc.sessionRegistry.get(id, token); - if (!meta || !record) return c.json({ error: 'not_found' }, 404); - if (record.agent !== 'pi') { - return c.json({ error: 'unsupported_surface', message: 'WebPi is available only for Pi Sessions' }, 409); + if (!meta) return c.json({ error: 'not_found' }, 404); + const adapter = svc.adapters.get(record.agent); + if (!adapter) return c.json({ error: 'unknown_agent' }, 500); + if (!adapter.capabilities.web || !adapter.composeWebCommand) { + return c.json({ + error: 'unsupported_surface', + message: `${adapter.displayName} has no Web conversation surface; open it in the terminal instead`, + }, 409); } if (svc.isResumeActive(record.resumeId)) { return c.json({ error: 'resume_busy', message: 'this conversation has a running headless turn' }, 409); } - const adapter = svc.adapters.get('pi'); - if (!adapter) return c.json({ error: 'unknown_agent' }, 500); try { await prepareAgentRuntimeWorkspace(adapter, { wsId: id, cwd: meta.dir, launcherRepoRoot: svc.config.launcherRepoRoot, }); - if (svc.pool.get(token)) svc.pool.disposeToken(token, 'switch to WebPi'); - const snapshot = await svc.startWebPiSession( + if (svc.pool.get(token)) svc.pool.disposeToken(token, 'switch to Web'); + const snapshot = await svc.startWebSession( meta, record, - id === svc.managerWorkspace?.id ? managerWebPiOptions : undefined, + id === svc.managerWorkspace?.id ? managerWebOptions : undefined, ); return c.json({ ok: true, snapshot, session: publicSession(record) }); } catch (err) { @@ -2524,19 +2536,16 @@ export function createWorkspaceRoutes( lastActiveAt: new Date().toISOString(), }).catch(() => undefined); if (err instanceof AgentCredentialError) return c.json(err.toBody(), 400); - launcherLogger.error('webpi.open_failed', { id, token, err }); - return c.json({ error: 'webpi_open_failed', message: (err as Error).message }, 500); + launcherLogger.error('web_session.open_failed', { id, token, err }); + return c.json({ error: 'web_open_failed', message: (err as Error).message }, 500); } }); - app.get('/:id/sessions/:sid/webpi', (c) => { - const id = c.req.param('id'); - const token = c.req.param('sid'); - if (!validId(id) || !validId(token)) return c.json({ error: 'not_found' }, 404); - const record = svc.sessionRegistry.get(id, token); - if (!record) return c.json({ error: 'not_found' }, 404); - const snapshot = svc.webPi.get(token); - if (!snapshot) return c.json({ error: 'webpi_not_running' }, 409); + app.get('/:id/sessions/:sid/web', (c) => { + const ctx = webSessionContext(c); + if (!ctx) return c.json({ error: 'not_found' }, 404); + const snapshot = svc.web.get(ctx.token); + if (!snapshot) return c.json({ error: 'web_not_running' }, 409); const knownRevision = Number.parseInt(c.req.query('revision') ?? '', 10); if (Number.isSafeInteger(knownRevision) && knownRevision === snapshot.revision) { return c.json({ unchanged: true, revision: snapshot.revision }); @@ -2544,36 +2553,51 @@ export function createWorkspaceRoutes( return c.json({ snapshot }); }); - app.post('/:id/sessions/:sid/webpi/prompt', async (c) => { - const id = c.req.param('id'); - const token = c.req.param('sid'); - if (!validId(id) || !validId(token)) return c.json({ error: 'not_found' }, 404); - const record = svc.sessionRegistry.get(id, token); - if (!record || record.agent !== 'pi') return c.json({ error: 'not_found' }, 404); + app.post('/:id/sessions/:sid/web/prompt', async (c) => { + const ctx = webSessionContext(c); + if (!ctx) return c.json({ error: 'not_found' }, 404); const body = await safeJson(c).catch(() => null); const message = body && typeof body === 'object' ? (body as Record)['message'] : null; if (typeof message !== 'string' || !message.trim()) { return c.json({ error: 'bad_request', message: 'message is required' }, 400); } try { - const snapshot = await svc.webPi.prompt(token, message); - await svc.sessionRegistry.update(id, token, { lastActiveAt: new Date().toISOString() }); + const snapshot = await svc.web.prompt(ctx.token, message); + await svc.sessionRegistry.update(ctx.id, ctx.token, { lastActiveAt: new Date().toISOString() }); return c.json({ ok: true, snapshot }); } catch (err) { - return c.json({ error: 'webpi_prompt_failed', message: (err as Error).message }, 409); + return c.json({ error: 'web_prompt_failed', message: (err as Error).message }, 409); } }); - app.post('/:id/sessions/:sid/webpi/abort', async (c) => { - const id = c.req.param('id'); - const token = c.req.param('sid'); - if (!validId(id) || !validId(token)) return c.json({ error: 'not_found' }, 404); - const record = svc.sessionRegistry.get(id, token); - if (!record || record.agent !== 'pi') return c.json({ error: 'not_found' }, 404); + app.post('/:id/sessions/:sid/web/abort', async (c) => { + const ctx = webSessionContext(c); + if (!ctx) return c.json({ error: 'not_found' }, 404); try { - return c.json({ ok: true, snapshot: await svc.webPi.abort(token) }); + return c.json({ ok: true, snapshot: await svc.web.abort(ctx.token) }); + } catch (err) { + return c.json({ error: 'web_abort_failed', message: (err as Error).message }, 409); + } + }); + + // Answer a runtime permission/question request with one of the options the + // runtime itself offered. The transport validates the option id. + app.post('/:id/sessions/:sid/web/respond', async (c) => { + const ctx = webSessionContext(c); + if (!ctx) return c.json({ error: 'not_found' }, 404); + const body = await safeJson(c).catch(() => null); + const fields = body && typeof body === 'object' ? body as Record : {}; + const requestId = fields['requestId']; + const optionId = fields['optionId']; + if (typeof requestId !== 'string' || !requestId || typeof optionId !== 'string') { + return c.json({ error: 'bad_request', message: 'requestId and optionId are required' }, 400); + } + try { + const snapshot = await svc.web.respond(ctx.token, requestId, optionId); + await svc.sessionRegistry.update(ctx.id, ctx.token, { lastActiveAt: new Date().toISOString() }); + return c.json({ ok: true, snapshot }); } catch (err) { - return c.json({ error: 'webpi_abort_failed', message: (err as Error).message }, 409); + return c.json({ error: 'web_respond_failed', message: (err as Error).message }, 409); } }); @@ -2911,8 +2935,8 @@ export function createWorkspaceRoutes( const record = svc.sessionRegistry.get(id, token); if (!record) return c.json({ error: 'not_found' }, 404); const wasTerminalRunning = svc.pool.disposeToken(token, 'session deleted'); - const wasWebPiRunning = await svc.webPi?.stop(token, 'session deleted') ?? false; - const wasRunning = wasTerminalRunning || wasWebPiRunning; + const wasWebRunning = await svc.web?.stop(token, 'session deleted') ?? false; + const wasRunning = wasTerminalRunning || wasWebRunning; if (record.scrollbackFile) { await svc.scrollbackStore.remove(record.scrollbackFile); } @@ -2928,7 +2952,7 @@ export function createWorkspaceRoutes( resumeId: record.resumeId, agent: record.agent, sessionRecordId: record.id, - surface: wasWebPiRunning && !wasTerminalRunning ? 'webpi' : 'terminal', + surface: wasWebRunning && !wasTerminalRunning ? 'webpi' : 'terminal', status: 'interrupted', }); } diff --git a/src/workspaces/adapters/claude.ts b/src/workspaces/adapters/claude.ts index 53897f64b..1177dba8a 100644 --- a/src/workspaces/adapters/claude.ts +++ b/src/workspaces/adapters/claude.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { readFile, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -188,6 +189,11 @@ export const claudeAdapter: CliAdapter = { resumeById: true, transcriptDiscovery: 'fs-watch', headless: true, + // Bidirectional stream-json keeps one `claude -p` alive across turns and + // routes tool permission prompts over stdio (`--permission-prompt-tool + // stdio`). `--session-id ` creates the session on a fresh Session so + // the same id resumes in the TUI later. + web: { wire: 'claude-stream-json', permissionPrompts: true, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['anthropic'], @@ -284,6 +290,31 @@ export const claudeAdapter: CliAdapter = { ]; }, + // Web surface: bidirectional stream-json. `--input-format stream-json` keeps + // the process alive between turns, `--include-partial-messages` streams text + // deltas, and `--permission-prompt-tool stdio` turns tool permission prompts + // into `control_request` frames the transport can present in the browser + // (no `--allowedTools` here: a human is attached). A fresh Session mints its + // uuid up front so `--resume ` reopens the identical conversation in the + // TUI afterwards. MCP still rides the workspace `.mcp.json` via the same + // autotrust settings as the TUI. + composeWebCommand(base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete Claude session id or a fresh Session'); + return [ + ...base, + '--settings', AUTOTRUST_SETTINGS, + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + ...(ctx.appendSystemPrompt ? ['--append-system-prompt', ctx.appendSystemPrompt] : []), + ...(ctx.resume ? ['--resume', ctx.resume.sessionId] : ['--session-id', randomUUID()]), + '-p', + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--include-partial-messages', + '--permission-prompt-tool', 'stdio', + ]; + }, + extractHeadlessSessionId(line: string): string | null { try { const evt = JSON.parse(line) as Record; diff --git a/src/workspaces/adapters/codex.ts b/src/workspaces/adapters/codex.ts index dfb773a37..5c2f953e9 100644 --- a/src/workspaces/adapters/codex.ts +++ b/src/workspaces/adapters/codex.ts @@ -188,6 +188,10 @@ export const codexAdapter: CliAdapter = { // resumeHint. Then `codex resume ` (composeCommand) resumes by id. transcriptDiscovery: 'subprocess', headless: true, + // `codex app-server` speaks JSON-RPC over stdio; threads are created or + // resumed in-band, and command/file-change approvals round-trip to the + // browser under `approvalPolicy: onRequest`. + web: { wire: 'codex-app-server', permissionPrompts: true, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['openai-responses'], @@ -291,6 +295,26 @@ export const codexAdapter: CliAdapter = { ]; }, + // Web surface: `codex app-server --listen stdio://`. Session identity, + // approval policy, and sandbox are selected in-band by the transport + // (`thread/start` / `thread/resume` with `approvalPolicy: onRequest` and a + // workspace-write sandbox), so no TUI permission flags belong here. The + // network override keeps the injected `alice*` CLIs reachable from inside + // that sandbox, same as headless. MCP registration mirrors the TUI. + composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete Codex thread id or a fresh Session'); + return [ + 'codex', + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + ...codexMcpConfigArgs(ctx), + '-c', + 'sandbox_workspace_write.network_access=true', + 'app-server', + '--listen', + 'stdio://', + ]; + }, + // `codex exec --json` line 1 is `{"type":"thread.started","thread_id":…}`; // the thread_id EQUALS the rollout's `session_meta.id` (verified 0.137.0, // 2026-06-11 — same uuid in ~/.codex/sessions/…/rollout-*.jsonl), so it @@ -667,18 +691,18 @@ function codexMcpHead(ctx: SpawnContext): string[] { `model_provider=${tomlString(CODEX_PROVIDER_NAME)}`, ] : []; + return ['codex', ...selection, ...CODEX_INTERACTIVE_PERMISSION_ARGS, ...codexMcpConfigArgs(ctx)]; +} + +/** `-c mcp_servers.*` overrides that register the launcher's MCP gateway. */ +function codexMcpConfigArgs(ctx: SpawnContext): string[] { const mcpUrl = ctx.env['OPENALICE_MCP_URL']; - if (!mcpUrl) { - return ['codex', ...selection, ...CODEX_INTERACTIVE_PERMISSION_ARGS]; - } + if (!mcpUrl) return []; const workspaceId = ctx.env['AQ_WS_ID']; if (!workspaceId) { throw new Error('codex adapter: AQ_WS_ID missing from spawn env'); } return [ - 'codex', - ...selection, - ...CODEX_INTERACTIVE_PERMISSION_ARGS, '-c', `mcp_servers.openalice.url="${mcpUrl}"`, '-c', diff --git a/src/workspaces/adapters/cursor.ts b/src/workspaces/adapters/cursor.ts index ac6be8692..9f026b98a 100644 --- a/src/workspaces/adapters/cursor.ts +++ b/src/workspaces/adapters/cursor.ts @@ -199,6 +199,10 @@ export const cursorAdapter: CliAdapter = { resumeById: true, transcriptDiscovery: 'subprocess', headless: true, + // `cursor-agent acp` is a native Agent Client Protocol agent: sessions are + // created/loaded in-band and tool permissions arrive as + // `session/request_permission`. + web: { wire: 'acp', permissionPrompts: true, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', // Cursor Dashboard credentials stay in the shared provider vault, but @@ -234,6 +238,20 @@ export const cursorAdapter: CliAdapter = { return [...cmd, ...cursorResumeArgs(ctx.resume)]; }, + // Web surface: `cursor-agent [global flags] acp`. Global options (model, + // trust) precede the subcommand, as in Cursor's own ACP documentation + // (`agent --api-key … acp`). Resume/new is negotiated in ACP + // (`session/load` / `session/new`), so no resume flag belongs here. + composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete Cursor session id or a fresh Session'); + return [ + 'cursor-agent', + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + ...(ctx.approveProject ? ['--trust'] : []), + 'acp', + ]; + }, + composeHeadlessCommand( _base: readonly string[], ctx: SpawnContext, diff --git a/src/workspaces/adapters/grok.ts b/src/workspaces/adapters/grok.ts index 1799b70ea..f5b07f2bf 100644 --- a/src/workspaces/adapters/grok.ts +++ b/src/workspaces/adapters/grok.ts @@ -304,6 +304,10 @@ export const grokAdapter: CliAdapter = { resumeById: true, transcriptDiscovery: 'subprocess', headless: true, + // `grok agent stdio` serves the Agent Client Protocol natively; Grok's + // on-disk `updates.jsonl` is already ACP-wrapped, so this is the same + // conversation the TUI resumes. + web: { wire: 'acp', permissionPrompts: true, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['openai-chat', 'openai-responses'], @@ -349,6 +353,20 @@ export const grokAdapter: CliAdapter = { return [...cmd, ...grokResumeArgs(ctx.resume)]; }, + // Web surface: `grok --no-leader [model/effort] [--rules …] agent stdio`. + // Session identity is negotiated over ACP, so no `--resume`/`--continue`. + composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete Grok session id or a fresh Session'); + return [ + 'grok', + '--no-leader', + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + ...grokRulesArgs(ctx), + 'agent', + 'stdio', + ]; + }, + composeHeadlessCommand( _base: readonly string[], ctx: SpawnContext, diff --git a/src/workspaces/adapters/omp.ts b/src/workspaces/adapters/omp.ts index 17708084c..f997b4233 100644 --- a/src/workspaces/adapters/omp.ts +++ b/src/workspaces/adapters/omp.ts @@ -225,6 +225,11 @@ export const ompAdapter: CliAdapter = { resumeById: true, transcriptDiscovery: 'subprocess', headless: true, + // omp keeps Pi's `--mode rpc` protocol (plus a `ready` frame). Like Pi it + // has no per-tool prompt in RPC mode, so the surface launches with + // `--auto-approve`; a fresh Session lets omp mint its snowflake id and the + // transport reads it back from `get_state`. + web: { wire: 'pi-rpc', permissionPrompts: false, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['google-generative-ai', 'openai-chat', 'anthropic', 'openai-responses'], @@ -288,6 +293,23 @@ export const ompAdapter: CliAdapter = { ]; }, + // Web surface: omp's `--mode rpc` is Pi's RPC protocol. `--resume ` + // reopens the recorded conversation; a fresh Session omits it and omp mints + // the id. `--continue` is never used here because the surface must reopen + // exactly the Session the registry owns. + composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete omp session id or a fresh Session'); + return [ + 'omp', + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + ...ompRoleArgs(ctx), + '--mode', + 'rpc', + '--auto-approve', + ...ompResumeArgs(ctx.resume), + ]; + }, + extractHeadlessSessionId(line: string): string | null { const evt = parseJsonRecord(line); if (!evt || evt['type'] !== 'session') return null; diff --git a/src/workspaces/adapters/opencode.ts b/src/workspaces/adapters/opencode.ts index cd933877e..4d37ca050 100644 --- a/src/workspaces/adapters/opencode.ts +++ b/src/workspaces/adapters/opencode.ts @@ -296,6 +296,9 @@ export const opencodeAdapter: CliAdapter = { // `opencode --session ` (composeCommand) resumes by id. transcriptDiscovery: 'subprocess', headless: true, + // `opencode acp` serves the Agent Client Protocol from the same SQLite + // session store the TUI uses, so `session/load` reopens `ses_…` ids. + web: { wire: 'acp', permissionPrompts: true, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['google-generative-ai', 'openai-chat', 'anthropic', 'openai-responses'], @@ -366,6 +369,17 @@ export const opencodeAdapter: CliAdapter = { return [...head, '--session', ctx.resume.sessionId]; }, + // Web surface: `opencode [--model …] acp`. Model selection stays on the + // top-level flag/env projection; session identity is negotiated over ACP. + composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete opencode session id or a fresh Session'); + return [ + 'opencode', + ...(ctx.sessionRuntime?.webArgs ?? ctx.sessionRuntime?.interactiveArgs ?? []), + 'acp', + ]; + }, + // Headless: `opencode run ` is non-interactive and exits at the turn // boundary. Tool access is via the injected CLI shims and bundled skills; // prompt is the trailing positional after a `--` end-of-options terminator diff --git a/src/workspaces/adapters/pi.ts b/src/workspaces/adapters/pi.ts index 5a8fa5361..d86baad28 100644 --- a/src/workspaces/adapters/pi.ts +++ b/src/workspaces/adapters/pi.ts @@ -231,6 +231,10 @@ export const piAdapter: CliAdapter = { // immune to pi's lazy transcript write. assignsSessionId: true, headless: true, + // Pi's RPC mode has no per-tool permission prompt; the Web surface launches + // it approved like headless does. The launcher mints the id at spawn, so + // a fresh Session always arrives here with a concrete `--session-id`. + web: { wire: 'pi-rpc', permissionPrompts: false, freshSession: true }, aiProvider: { credentialSource: 'runtime-or-workspace', wirePreference: ['google-generative-ai', 'openai-chat', 'anthropic', 'openai-responses'], @@ -327,14 +331,14 @@ export const piAdapter: CliAdapter = { : null; }, - // WebPi is a second VIEW over the same Pi session, not another runtime. - // RPC stays completely separate from the TUI argv above: selecting WebPi - // cannot change ordinary Pi startup, trust prompts, input handling, or PTY - // behavior. It is always by-id so switching surfaces reopens the exact + // The Web surface is a second VIEW over the same Pi session, not another + // runtime. RPC stays completely separate from the TUI argv above: selecting + // it cannot change ordinary Pi startup, trust prompts, input handling, or + // PTY behavior. It is always by-id so switching surfaces reopens the exact // conversation that the OpenAlice resume registry already owns. composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { if (!ctx.resume || ctx.resume === 'last') { - throw new Error('WebPi requires a concrete Pi session id'); + throw new Error('the Pi Web surface requires a concrete Pi session id'); } return [ ...piCommandHead(ctx.env), diff --git a/src/workspaces/adapters/web-command.spec.ts b/src/workspaces/adapters/web-command.spec.ts new file mode 100644 index 000000000..94ee44e97 --- /dev/null +++ b/src/workspaces/adapters/web-command.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import type { CliAdapter, SpawnContext } from '../cli-adapter.js' +import { agyAdapter } from './agy.js' +import { claudeAdapter } from './claude.js' +import { codexAdapter } from './codex.js' +import { cursorAdapter } from './cursor.js' +import { grokAdapter } from './grok.js' +import { ompAdapter } from './omp.js' +import { opencodeAdapter } from './opencode.js' +import { piAdapter } from './pi.js' + +const ctx = (overrides: Partial = {}): SpawnContext => ({ + cwd: '/w', + env: { AQ_WS_ID: 'ws-1' }, + resume: undefined, + ...overrides, +}) + +const webAdapters: readonly CliAdapter[] = [piAdapter, ompAdapter, claudeAdapter, codexAdapter, cursorAdapter, grokAdapter, opencodeAdapter] + +describe('Web surface command composition', () => { + it('declares a wire for every adapter that composes a Web command, and vice versa', () => { + for (const adapter of webAdapters) { + expect(adapter.capabilities.web, adapter.id).toBeDefined() + expect(typeof adapter.composeWebCommand, adapter.id).toBe('function') + } + expect(agyAdapter.capabilities.web).toBeUndefined() + expect(agyAdapter.composeWebCommand).toBeUndefined() + }) + + it('never resumes "last": the surface must reopen the exact recorded Session', () => { + for (const adapter of webAdapters) { + expect(() => adapter.composeWebCommand!(['x'], ctx({ resume: 'last' })), adapter.id).toThrow() + } + }) + + it('composes omp RPC with auto-approve and by-id resume', () => { + expect(ompAdapter.composeWebCommand!([], ctx({ resume: { sessionId: 'omp-1' } }))) + .toEqual(['omp', '--mode', 'rpc', '--auto-approve', '--resume', 'omp-1']) + expect(ompAdapter.composeWebCommand!([], ctx())).toEqual(['omp', '--mode', 'rpc', '--auto-approve']) + }) + + it('composes Claude bidirectional stream-json with stdio permission prompts', () => { + const resumed = claudeAdapter.composeWebCommand!(['claude'], ctx({ resume: { sessionId: 'c-1' } })) + expect(resumed).toEqual([ + 'claude', '--settings', '{"enableAllProjectMcpServers":true}', '--resume', 'c-1', + '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', + '--include-partial-messages', '--permission-prompt-tool', 'stdio', + ]) + const fresh = claudeAdapter.composeWebCommand!(['claude'], ctx()) + expect(fresh).toContain('--session-id') + expect(fresh[fresh.indexOf('--session-id') + 1]).toMatch(/^[0-9a-f-]{36}$/) + expect(fresh).not.toContain('--allowedTools') + }) + + it('composes Codex app-server over stdio with MCP registration but no TUI permission flags', () => { + const argv = codexAdapter.composeWebCommand!([], ctx({ env: { AQ_WS_ID: 'ws-1', OPENALICE_MCP_URL: 'http://127.0.0.1:1/mcp' } })) + expect(argv.slice(-3)).toEqual(['app-server', '--listen', 'stdio://']) + expect(argv).toContain('mcp_servers.openalice.url="http://127.0.0.1:1/mcp"') + expect(argv).toContain('sandbox_workspace_write.network_access=true') + expect(argv).not.toContain('--ask-for-approval') + expect(argv).not.toContain('--sandbox') + }) + + it('composes the three native ACP agents', () => { + expect(cursorAdapter.composeWebCommand!([], ctx({ approveProject: true }))).toEqual(['cursor-agent', '--trust', 'acp']) + expect(grokAdapter.composeWebCommand!([], ctx())).toEqual(['grok', '--no-leader', 'agent', 'stdio']) + expect(opencodeAdapter.composeWebCommand!([], ctx())).toEqual(['opencode', 'acp']) + }) +}) diff --git a/src/workspaces/cli-adapter.ts b/src/workspaces/cli-adapter.ts index 3f315342d..fa89d6be2 100644 --- a/src/workspaces/cli-adapter.ts +++ b/src/workspaces/cli-adapter.ts @@ -85,6 +85,21 @@ export interface AgentProviderVendorPolicy { readonly legacyRequestedWireFallbacks?: Readonly>>; } +/** Alice-side transport that projects a runtime's live protocol onto the Web surface. */ +export type WebSessionWire = 'pi-rpc' | 'acp' | 'claude-stream-json' | 'codex-app-server'; + +export interface WebSurfaceCapability { + readonly wire: WebSessionWire; + /** + * The runtime asks before running tools and the transport routes those + * prompts to the browser. Runtimes without a per-tool prompt in their + * structured mode launch with their own approve-all flag instead. + */ + readonly permissionPrompts: boolean; + /** A Session with no native id yet may still open in the Web surface. */ + readonly freshSession: boolean; +} + export interface AgentProviderCapabilities { /** * Whether the runtime can start from its own native/global login, or needs a @@ -298,6 +313,14 @@ export interface CliAdapter { * set this; `shell` does not (no agent-turn concept). */ readonly headless?: boolean; + /** + * The adapter can serve the browser Web conversation surface through a + * long-lived structured process (`composeWebCommand`). `wire` selects the + * Alice transport that speaks the runtime's protocol; the UI reads this to + * decide whether a Session may open in the Web surface. Omit for runtimes + * whose only interactive mode is the TUI. + */ + readonly web?: WebSurfaceCapability; /** * Native AI-provider projection contract. Shared credential/model logic * consumes this declaration instead of branching on adapter ids. Omit for @@ -338,10 +361,11 @@ export interface CliAdapter { /** * Optional long-lived structured interactive surface. Unlike headless mode, * this process remains alive and accepts multiple prompts over stdin/stdout. - * WebPi is the first consumer: it opens the SAME native Pi session through - * Pi's documented RPC mode while the ordinary terminal keeps using - * `composeCommand`. Keeping this opt-in prevents any other runtime's launch - * path from changing merely because WebPi exists. + * It opens the SAME native session the ordinary terminal would resume, so + * switching surfaces never forks a conversation. `ctx.resume` is `undefined` + * when the Session has no native id yet; runtimes that create sessions + * in-band (ACP `session/new`, Codex `thread/start`) accept that, runtimes + * that need an id at launch must throw. Present iff `capabilities.web`. */ composeWebCommand?(base: readonly string[], ctx: SpawnContext): readonly string[]; diff --git a/src/workspaces/public-session.spec.ts b/src/workspaces/public-session.spec.ts index 2d3d8036f..7b62e42bf 100644 --- a/src/workspaces/public-session.spec.ts +++ b/src/workspaces/public-session.spec.ts @@ -46,9 +46,9 @@ describe('projectPublicSession', () => { expect(projected).not.toHaveProperty('runtime'); }); - it('derives live state and WebPi surface from the same process snapshot', () => { + it('derives live state and Web surface from the same process snapshot', () => { expect(projectPublicSession(record, { - webPi: { pid: 42, startedAt: 1_723_337_000_000 }, + web: { pid: 42, startedAt: 1_723_337_000_000 }, })).toMatchObject({ state: 'running', surface: 'webpi', diff --git a/src/workspaces/public-session.ts b/src/workspaces/public-session.ts index c1f55a220..b18832257 100644 --- a/src/workspaces/public-session.ts +++ b/src/workspaces/public-session.ts @@ -12,7 +12,7 @@ export interface PublicSessionRuntime { readonly reasoningEffort?: ModelReasoningEffort; } -/** A running terminal or WebPi record owns the interactive execution slot. +/** A running terminal or Web-surface record owns the interactive execution slot. * Headless records use the separate launcher lease so stale persisted state * cannot make an Issue owner look busy after its process has exited. */ export function isInteractiveSessionActive( @@ -63,8 +63,8 @@ interface LiveSessionProjection { export interface PublicSessionProjectionContext { readonly terminal?: LiveSessionProjection | null; - readonly webPi?: LiveSessionProjection | null; - /** A one-shot execution currently owns the Session without a PTY/WebPi pid. */ + readonly web?: LiveSessionProjection | null; + /** A one-shot execution currently owns the Session without a PTY/Web-surface pid. */ readonly headless?: boolean; readonly runtimeBinding?: SessionRuntimeBinding | null; readonly displayName?: string; @@ -85,7 +85,7 @@ export function projectPublicSession( context: PublicSessionProjectionContext = {}, ): PublicSession { const terminal = context.terminal ?? null; - const webPi = context.webPi ?? null; + const web = context.web ?? null; const headless = context.headless === true; const binding = context.runtimeBinding ?? null; @@ -96,11 +96,11 @@ export function projectPublicSession( name: record.name, createdAt: record.createdAt, lastActiveAt: record.lastActiveAt, - state: record.state === 'running' && (terminal || webPi || headless) ? 'running' : 'paused', - surface: webPi ? 'webpi' : terminal ? 'terminal' : (record.surface ?? 'terminal'), + state: record.state === 'running' && (terminal || web || headless) ? 'running' : 'paused', + surface: web ? 'webpi' : terminal ? 'terminal' : (record.surface ?? 'terminal'), resumeId: record.resumeId, - pid: terminal?.pid ?? webPi?.pid ?? null, - startedAt: terminal?.startedAt ?? webPi?.startedAt ?? null, + pid: terminal?.pid ?? web?.pid ?? null, + startedAt: terminal?.startedAt ?? web?.startedAt ?? null, title: projectSessionPresentationTitle({ record, ...(context.createdBy ? { createdBy: context.createdBy } : {}), diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index c69193b3a..9be9c35a1 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -380,7 +380,7 @@ import { WorkspaceHeadlessActivityTracker, type WorkspaceRuntimeActivity, } from './workspace-runtime-activity.js'; -import { WebPiSessionHost, type WebPiSnapshot } from './webpi-session-host.js'; +import { WebSessionHost, type WebSessionSnapshot } from './web-session-host.js'; import { WorkspaceRegistry, type WorkspaceMeta } from './workspace-registry.js'; import { readHarnessSource } from './harness-source.js'; import { HarnessSourceUpgradeManager } from './harness-source-upgrade.js'; @@ -430,7 +430,8 @@ export interface WorkspaceService { readonly adapters: AdapterRegistry; readonly creator: WorkspaceCreator; readonly pool: SessionPool; - readonly webPi: WebPiSessionHost; + /** Long-lived structured Agent processes presented in the browser (Web surface). */ + readonly web: WebSessionHost; /** Launcher-owned control plane. Not part of the business Workspace registry. */ readonly managerWorkspace: WorkspaceMeta; /** Resolve a runtime target, including the special manager control plane. */ @@ -453,8 +454,8 @@ export interface WorkspaceService { /** Resolve the Workspace default, installation fallback, then first registered runtime. */ resolveDefaultAgentId(meta: WorkspaceMeta): Promise; resolveAdapter(meta: WorkspaceMeta, agentId?: string): CliAdapter; - /** Open the same persisted Pi Session through Pi RPC instead of its PTY. */ - startWebPiSession( + /** Open the same persisted Session through its runtime's structured protocol instead of a PTY. */ + startWebSession( meta: WorkspaceMeta, record: SessionRecord, opts?: { @@ -462,7 +463,7 @@ export interface WorkspaceService { skills?: readonly string[]; approveProject?: boolean; }, - ): Promise; + ): Promise; /** Best-effort background reconciliation of native runtime Session titles. */ refreshSessionTitles?(meta: WorkspaceMeta): Promise; publicMeta(w: WorkspaceMeta): Promise; @@ -2851,20 +2852,20 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions transcriptWatcher, ); - const webPi = new WebPiSessionHost( - launcherLogger.child({ scope: 'webpi-host' }), + const web = new WebSessionHost( + launcherLogger.child({ scope: 'web-session-host' }), { onExit: (recordId, reason) => { // Intentional handoffs are followed by an explicit caller-owned state // update (paused, terminal-running, or deleted). Letting this async - // callback also write `paused` would race a WebPi -> TUI switch. + // callback also write `paused` would race a Web -> TUI switch. if (reason.intentional) return; const record = sessionRegistry.findById(recordId); if (!record) return; void sessionRegistry.update(record.wsId, record.id, { state: 'paused', lastActiveAt: new Date().toISOString(), - }).catch((err) => launcherLogger.warn('webpi.pause_update_failed', { recordId, err })); + }).catch((err) => launcherLogger.warn('web_session.pause_update_failed', { recordId, err })); void agentRuntimeLog.record('runtime.stopped', { workspaceId: record.wsId, resumeId: record.resumeId, @@ -2874,10 +2875,25 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions status: 'paused', }); }, + onNativeSessionId: (recordId, nativeSessionId) => { + // Runtimes that create sessions in-band (ACP, Codex, fresh omp/Claude) + // announce their id after spawn; bind it exactly like PTY discovery so + // the TUI can resume the same conversation later. + const record = sessionRegistry.findById(recordId); + if (!record) return; + void resumeRegistry.bindAgentSessionId(record.resumeId, nativeSessionId).catch((err) => + launcherLogger.warn('web_session.native_id_bind_failed', { recordId, resumeId: record.resumeId, err }), + ); + if (record.resumeHint?.value !== nativeSessionId) { + void sessionRegistry.update(record.wsId, record.id, { + resumeHint: { kind: 'agent-session-id', value: nativeSessionId }, + }).catch((err) => launcherLogger.warn('web_session.resume_hint_update_failed', { recordId, err })); + } + }, }, ); - const startWebPiSession = async ( + const startWebSession = async ( meta: WorkspaceMeta, record: SessionRecord, opts: { @@ -2885,17 +2901,22 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions skills?: readonly string[]; approveProject?: boolean; } = {}, - ): Promise => { - const operationLease = workspaceOperationGuard.acquire(meta.id, 'webpi-start'); + ): Promise => { + const operationLease = workspaceOperationGuard.acquire(meta.id, 'web-session-start'); if (!operationLease) throw new Error(`workspace is busy with ${workspaceOperationGuard.current(meta.id)}`); try { - if (record.agent !== 'pi') throw new Error('WebPi is available only for Pi Sessions'); - const adapter = adapters.get('pi'); - if (!adapter?.composeWebCommand) throw new Error('installed Pi adapter has no WebPi surface'); + const adapter = adapters.get(record.agent); + if (!adapter) throw new Error(`unknown agent runtime: ${record.agent}`); + const webCapability = adapter.capabilities.web; + if (!webCapability || !adapter.composeWebCommand) { + throw new Error(`${adapter.displayName} has no Web conversation surface; open it in the terminal instead`); + } const nativeSessionId = resumeRegistry.get(record.resumeId)?.agentSessionId ?? record.resumeHint?.value; - if (!nativeSessionId) throw new Error('Pi Session has no resumable native session id'); - const resume = { sessionId: nativeSessionId } as const; + if (!nativeSessionId && !webCapability.freshSession) { + throw new Error(`${adapter.displayName} Session has no resumable native session id`); + } + const resume = nativeSessionId ? { sessionId: nativeSessionId } as const : undefined; const identity = resumeRegistry.get(record.resumeId); const sessionRuntime = identity?.runtimeBinding ? await resolveSessionRuntimeBinding({ @@ -2927,21 +2948,28 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions ...(opts.approveProject ? { approveProject: true } : {}), }); launcherLogger.event('path.trace', { - where: 'webpi.spawn', + where: 'web_session.spawn', wsId: meta.id, recordId: record.id, resumeId: record.resumeId, - nativeSessionId, + agent: record.agent, + wire: webCapability.wire, + nativeSessionId: nativeSessionId ?? null, spawnCwd: cwd, composedCommand: command, }); - const snapshot = await webPi.start({ + const snapshot = await web.start({ recordId: record.id, wsId: record.wsId, resumeId: record.resumeId, + agent: record.agent, + wire: webCapability.wire, command, cwd, env, + ...(nativeSessionId ? { nativeSessionId } : {}), + ...(sessionRuntime.binding.model ? { model: sessionRuntime.binding.model } : {}), + ...(sessionRuntime.binding.reasoningEffort ? { reasoningEffort: sessionRuntime.binding.reasoningEffort } : {}), }); await sessionRegistry.update(record.wsId, record.id, { state: 'running', @@ -2965,7 +2993,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions const workspaceRuntimeActivityMethod = (workspaceId: string): WorkspaceRuntimeActivity => { const sessions = sessionRegistry.listFor(workspaceId).flatMap((record) => { const terminal = pool.get(record.id); - const browser = webPi.get(record.id); + const browser = web.get(record.id); if (!terminal && !browser) return []; return [{ sessionId: record.id, @@ -2992,7 +3020,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions scrollbackStore, headlessTasks, pool, - webPi, + web, isWorkspaceHeadlessActive: (id) => headlessActivity.has(id), operationGuard: workspaceOperationGuard, cleanupWorkspaceState: async (_record, cwd) => { @@ -3059,7 +3087,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions if (!identity || identity.lifecycle === 'retired' || sessionPresence(identity) === 'deleted') return []; return [projectPublicSession(record, { terminal: pool.get(record.id), - webPi: webPi.get(record.id), + web: web.get(record.id), headless: activeResumeIds.has(record.resumeId), runtimeBinding: identity.runtimeBinding, displayName: identity.displayName, @@ -3148,7 +3176,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions stopInboxActivity?.(); await harnessSurfaces.dispose(); pool.disposeAll('plugin shutdown'); - await webPi.stopAll('plugin shutdown'); + await web.stopAll('plugin shutdown'); transcriptWatcher.disposeAll(); }; @@ -3246,7 +3274,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions adapters, creator, pool, - webPi, + web, managerWorkspace, resolveRuntimeWorkspace, transcriptWatcher, @@ -3256,7 +3284,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions resolveOrCreateAutoPredictionWorkspace: resolveOrCreateAutoPredictionWorkspaceMethod, resolveDefaultAgentId, resolveAdapter, - startWebPiSession, + startWebSession, refreshSessionTitles, publicMeta, detectAgents, diff --git a/src/workspaces/web-session-host.spec.ts b/src/workspaces/web-session-host.spec.ts new file mode 100644 index 000000000..38295d8c0 --- /dev/null +++ b/src/workspaces/web-session-host.spec.ts @@ -0,0 +1,556 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' + +import { describe, expect, it, vi } from 'vitest' + +import type { Logger } from './logger.js' +import { WebSessionHost, type StartWebSessionInput, type WebSessionSnapshot } from './web-session-host.js' + +type Json = Record + +/** Minimal stdio child: parses JSONL commands from stdin, answers on stdout. */ +class FakeProcess extends EventEmitter { + readonly pid = 4242 + readonly stdin = new PassThrough() + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + readonly received: Json[] = [] + + constructor(private readonly onCommand: (command: Json, self: FakeProcess) => void) { + super() + this.stdin.setEncoding('utf8') + let buffer = '' + this.stdin.on('data', (chunk: string) => { + buffer += chunk + let nl = buffer.indexOf('\n') + while (nl >= 0) { + const line = buffer.slice(0, nl) + buffer = buffer.slice(nl + 1) + if (line) { + const parsed = JSON.parse(line) as Json + this.received.push(parsed) + this.onCommand(parsed, this) + } + nl = buffer.indexOf('\n') + } + }) + queueMicrotask(() => this.emit('spawn')) + } + + kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { + queueMicrotask(() => this.emit('exit', 0, signal)) + return true + } + + line(value: unknown): void { + this.stdout.write(`${JSON.stringify(value)}\n`) + } +} + +const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + event: vi.fn(), +} as unknown as Logger + +const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)) + +function input(overrides: Partial): StartWebSessionInput { + return { + recordId: 'record-1', + wsId: 'chat-ws', + resumeId: 'resume-1', + agent: 'pi', + wire: 'pi-rpc', + command: ['pi', '--mode', 'rpc'], + cwd: '/tmp/workspace', + env: {}, + ...overrides, + } +} + +function texts(snapshot: WebSessionSnapshot | null): string[] { + return (snapshot?.messages ?? []).map((message) => { + if (message.role === 'user' || message.role === 'assistant') { + return typeof message.content === 'string' + ? `${message.role}:${message.content}` + : `${message.role}:${message.content.map((part) => (part.type === 'text' ? part.text : part.type === 'toolCall' ? `[${part.name}]` : part.type)).join('|')}` + } + if (message.role === 'toolResult') return `toolResult:${message.toolName}:${message.isError ? 'error' : 'ok'}` + if (message.role === 'notice') return `notice:${message.text}` + return 'unknown' + }) +} + +// ── pi-rpc ───────────────────────────────────────────────────────────────── + +function piRpcProcess(state: Json = {}, options: { rejectPrompt?: string } = {}): FakeProcess { + let messages: unknown[] = [] + const rpcState = { sessionId: 'native-pi', isStreaming: false, isCompacting: false, ...state } + return new FakeProcess((command, self) => { + const id = command['id'] + const type = command['type'] + if (type === 'get_state') self.line({ type: 'response', id, command: type, success: true, data: { ...rpcState, messageCount: messages.length } }) + if (type === 'get_messages') self.line({ type: 'response', id, command: type, success: true, data: { messages } }) + if (type === 'prompt' && options.rejectPrompt) { + self.line({ type: 'response', id, command: type, success: false, error: options.rejectPrompt }) + return + } + if (type === 'prompt') { + const user = { role: 'user', content: command['message'] } + const assistant = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + messages = [...messages, user, assistant] + self.line({ type: 'response', id, command: type, success: true }) + self.line({ type: 'agent_start' }) + self.line({ type: 'message_update', message: { role: 'assistant', content: [{ type: 'text', text: 'hel' }] } }) + self.line({ type: 'message_end', message: assistant }) + self.line({ type: 'agent_settled' }) + } + if (type === 'abort') self.line({ type: 'response', id, command: type, success: true }) + }) +} + +describe('WebSessionHost with the pi-rpc transport', () => { + it('projects Pi messages into the neutral model without accumulating update frames', async () => { + const host = new WebSessionHost(logger, {}, () => piRpcProcess() as never) + const started = await host.start(input({ nativeSessionId: 'native-pi' })) + expect(started.phase).toBe('idle') + expect(started.wire).toBe('pi-rpc') + expect(started.messages).toEqual([]) + + await host.prompt('record-1', 'hi') + await settle(80) + const snapshot = host.get('record-1') + expect(snapshot?.phase).toBe('idle') + expect(snapshot?.messages).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }, + ]) + expect(snapshot?.streamingMessage).toBeNull() + }) + + it('binds the runtime-minted session id for a fresh omp session', async () => { + const onNativeSessionId = vi.fn() + const host = new WebSessionHost(logger, { onNativeSessionId }, () => piRpcProcess({ sessionId: 'omp-777' }) as never) + const started = await host.start(input({ agent: 'omp', command: ['omp', '--mode', 'rpc'] })) + expect(started.nativeSessionId).toBe('omp-777') + expect(onNativeSessionId).toHaveBeenCalledWith('record-1', 'omp-777') + }) + + it('deduplicates repeated opens and stops intentionally', async () => { + let spawns = 0 + const onExit = vi.fn() + const host = new WebSessionHost(logger, { onExit }, () => { + spawns += 1 + return piRpcProcess() as never + }) + await host.start(input({})) + await host.start(input({})) + expect(spawns).toBe(1) + expect(await host.stop('record-1', 'switch to TUI')).toBe(true) + expect(host.has('record-1')).toBe(false) + expect(onExit).toHaveBeenCalledWith('record-1', expect.objectContaining({ intentional: true })) + }) + + it('follows Pi compaction events until the agent settles', async () => { + const rpc = piRpcProcess({ isCompacting: true }) + const host = new WebSessionHost(logger, {}, () => rpc as never) + expect((await host.start(input({}))).phase).toBe('compacting') + rpc.line({ type: 'compaction_end', reason: 'threshold', willRetry: false }) + await settle(10) + expect(host.get('record-1')?.phase).toBe('working') + rpc.line({ type: 'agent_settled' }) + await settle(10) + expect(host.get('record-1')?.phase).toBe('idle') + }) + + it('returns to idle with the reason when Pi rejects a prompt before the turn starts', async () => { + const rpc = piRpcProcess({}, { rejectPrompt: 'No API key found for the selected model.' }) + const host = new WebSessionHost(logger, {}, () => rpc as never) + await host.start(input({})) + await expect(host.prompt('record-1', 'hi')).rejects.toThrow('No API key found') + const snapshot = host.get('record-1')! + expect(snapshot.phase).toBe('idle') + expect(snapshot.error).toBe('No API key found for the selected model.') + expect(snapshot.messages).toEqual([]) + }) + + it('marks the session failed when the process dies unexpectedly', async () => { + const rpc = piRpcProcess() + const onExit = vi.fn() + const host = new WebSessionHost(logger, { onExit }, () => rpc as never) + await host.start(input({})) + rpc.emit('exit', 1, null) + await settle(10) + expect(host.has('record-1')).toBe(false) + expect(onExit).toHaveBeenCalledWith('record-1', expect.objectContaining({ intentional: false, code: 1 })) + }) +}) + +// ── acp ──────────────────────────────────────────────────────────────────── + +function acpProcess(options: { loadSession?: boolean; failAuth?: boolean } = {}): FakeProcess { + return new FakeProcess((command, self) => { + const id = command['id'] + const method = command['method'] + const params = (command['params'] ?? {}) as Json + if (method === 'initialize') { + self.line({ jsonrpc: '2.0', id, result: { protocolVersion: 1, agentCapabilities: { loadSession: options.loadSession ?? true }, authMethods: [] } }) + return + } + if (method === 'session/new') { + if (options.failAuth) { + self.line({ jsonrpc: '2.0', id, error: { code: -32000, message: 'Authentication required' } }) + return + } + self.line({ jsonrpc: '2.0', id, result: { sessionId: 'ses_new' } }) + return + } + if (method === 'session/load') { + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: params['sessionId'], update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'earlier ' } } } }) + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: params['sessionId'], update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'question' } } } }) + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: params['sessionId'], update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'earlier answer' } } } }) + self.line({ jsonrpc: '2.0', id, result: {} }) + return + } + if (method === 'session/prompt') { + const sessionId = params['sessionId'] + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'let me look' } } } }) + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Reading ' } } } }) + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'tool_call', toolCallId: 'call_1', title: 'Read README.md', kind: 'read', status: 'pending', rawInput: { path: 'README.md' } } } }) + self.line({ jsonrpc: '2.0', id: 'srv-1', method: 'session/request_permission', params: { + sessionId, + toolCall: { toolCallId: 'call_1', title: 'Read README.md', kind: 'read', rawInput: { path: 'README.md' } }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + } }) + ;(self as FakeProcess & { promptId?: unknown }).promptId = id + return + } + if ('result' in command && command['id'] === 'srv-1') { + const outcome = ((command['result'] as Json)['outcome'] as Json) + const promptId = (self as FakeProcess & { promptId?: unknown }).promptId + if (outcome['outcome'] === 'selected' && outcome['optionId'] === 'allow-once') { + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'ses_new', update: { sessionUpdate: 'tool_call_update', toolCallId: 'call_1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: '# OpenAlice' } }] } } }) + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'ses_new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'the README.' } } } }) + self.line({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } }) + } else { + self.line({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'ses_new', update: { sessionUpdate: 'tool_call_update', toolCallId: 'call_1', status: 'failed' } } }) + self.line({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } }) + } + return + } + if (method === 'session/cancel') { + const promptId = (self as FakeProcess & { promptId?: unknown }).promptId + self.line({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'cancelled' } }) + } + }) +} + +describe('WebSessionHost with the acp transport', () => { + const acpInput = input({ agent: 'cursor', wire: 'acp', command: ['cursor-agent', 'acp'] }) + + it('creates a session, streams a turn, and routes permission requests to the browser', async () => { + const onNativeSessionId = vi.fn() + const process = acpProcess() + const host = new WebSessionHost(logger, { onNativeSessionId }, () => process as never) + const started = await host.start(acpInput) + expect(started.nativeSessionId).toBe('ses_new') + expect(onNativeSessionId).toHaveBeenCalledWith('record-1', 'ses_new') + expect(process.received[0]).toMatchObject({ method: 'initialize', params: { protocolVersion: 1 } }) + + await host.prompt('record-1', 'summarize the readme') + await settle() + const waiting = host.get('record-1')! + expect(waiting.phase).toBe('awaiting-input') + expect(waiting.requests).toHaveLength(1) + expect(waiting.requests[0]).toMatchObject({ + kind: 'permission', + title: 'Read README.md', + tool: { name: 'Read README.md', input: { path: 'README.md' } }, + options: [ + { id: 'allow-once', label: 'Allow once', tone: 'allow' }, + { id: 'reject-once', label: 'Reject', tone: 'deny' }, + ], + }) + expect(waiting.streamingMessage).toMatchObject({ role: 'assistant' }) + + await expect(host.respond('record-1', waiting.requests[0]!.id, 'nope')).rejects.toThrow(/not offered/) + await host.respond('record-1', waiting.requests[0]!.id, 'allow-once') + await settle() + const done = host.get('record-1')! + expect(done.phase).toBe('idle') + expect(done.requests).toEqual([]) + expect(texts(done)).toEqual([ + 'user:summarize the readme', + 'assistant:thinking|Reading |[Read README.md]', + 'toolResult:Read README.md:ok', + 'assistant:the README.', + ]) + expect(process.received.find((c) => c['id'] === 'srv-1')).toMatchObject({ result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } }) + }) + + it('reloads a known session and replays its history', async () => { + const host = new WebSessionHost(logger, {}, () => acpProcess() as never) + const started = await host.start({ ...acpInput, nativeSessionId: 'ses_old' }) + expect(started.nativeSessionId).toBe('ses_old') + expect(texts(started)).toEqual(['user:earlier question', 'assistant:earlier answer']) + }) + + it('cancels outstanding permission requests when the turn is aborted', async () => { + const process = acpProcess() + const host = new WebSessionHost(logger, {}, () => process as never) + await host.start(acpInput) + await host.prompt('record-1', 'go') + await settle() + expect(host.get('record-1')?.requests).toHaveLength(1) + await host.abort('record-1') + await settle() + const snapshot = host.get('record-1')! + expect(snapshot.requests).toEqual([]) + expect(snapshot.phase).toBe('idle') + expect(process.received.find((c) => c['id'] === 'srv-1')).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + }) + + it('explains authentication failures instead of a bare JSON-RPC error', async () => { + const host = new WebSessionHost(logger, {}, () => acpProcess({ failAuth: true }) as never) + await expect(host.start(acpInput)).rejects.toThrow(/cursor requires authentication/) + expect(host.has('record-1')).toBe(false) + }) +}) + +// ── claude-stream-json ───────────────────────────────────────────────────── + +function claudeProcess(): FakeProcess { + return new FakeProcess((command, self) => { + if (command['type'] === 'user') { + self.line({ type: 'system', subtype: 'init', session_id: 'claude-sess', model: 'claude' }) + self.line({ type: 'stream_event', session_id: 'claude-sess', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Let me ' } } }) + self.line({ type: 'stream_event', session_id: 'claude-sess', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'check.' } } }) + self.line({ type: 'assistant', session_id: 'claude-sess', message: { id: 'msg_1', role: 'assistant', content: [{ type: 'text', text: 'Let me check.' }] } }) + self.line({ type: 'assistant', session_id: 'claude-sess', message: { id: 'msg_1', role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] } }) + self.line({ type: 'control_request', request_id: 'req_1', request: { subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'ls' } } }) + return + } + if (command['type'] === 'control_response') { + const response = command['response'] as Json + const inner = response['response'] as Json + if (inner['behavior'] === 'allow') { + self.line({ type: 'user', session_id: 'claude-sess', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'README.md\nsrc', is_error: false }] } }) + self.line({ type: 'assistant', session_id: 'claude-sess', message: { id: 'msg_2', role: 'assistant', content: [{ type: 'text', text: 'Two entries.' }] } }) + self.line({ type: 'result', subtype: 'success', session_id: 'claude-sess', is_error: false, result: 'Two entries.' }) + } else { + self.line({ type: 'user', session_id: 'claude-sess', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'denied', is_error: true }] } }) + self.line({ type: 'result', subtype: 'success', session_id: 'claude-sess', is_error: false, result: 'ok' }) + } + return + } + if (command['type'] === 'control_request') { + const request = command['request'] as Json + if (request['subtype'] === 'interrupt') { + self.line({ type: 'control_response', response: { subtype: 'success', request_id: command['request_id'] } }) + self.line({ type: 'result', subtype: 'error_during_execution', session_id: 'claude-sess', is_error: true, result: 'interrupted' }) + } + } + }) +} + +describe('WebSessionHost with the claude-stream-json transport', () => { + const claudeInput = input({ agent: 'claude', wire: 'claude-stream-json', command: ['claude', '-p'] }) + + it('streams deltas, asks for tool permission, and finishes on the result frame', async () => { + const onNativeSessionId = vi.fn() + const process = claudeProcess() + const host = new WebSessionHost(logger, { onNativeSessionId }, () => process as never) + const started = await host.start(claudeInput) + expect(started.phase).toBe('idle') + + await host.prompt('record-1', 'list files') + await settle() + expect(onNativeSessionId).toHaveBeenCalledWith('record-1', 'claude-sess') + expect(process.received[0]).toMatchObject({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'list files' }] } }) + const waiting = host.get('record-1')! + expect(waiting.phase).toBe('awaiting-input') + expect(waiting.requests[0]).toMatchObject({ title: 'Bash', tool: { name: 'Bash', input: { command: 'ls' } } }) + expect(waiting.streamingMessage).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'Let me check.' }, { type: 'toolCall', id: 'toolu_1', name: 'Bash' }] }) + + await host.respond('record-1', waiting.requests[0]!.id, 'allow') + await settle() + const done = host.get('record-1')! + expect(done.phase).toBe('idle') + expect(texts(done)).toEqual(['user:list files', 'assistant:Let me check.|[Bash]', 'toolResult:Bash:ok', 'assistant:Two entries.']) + expect(process.received.at(-1)).toMatchObject({ type: 'control_response', response: { request_id: 'req_1', response: { behavior: 'allow', updatedInput: { command: 'ls' } } } }) + }) + + it('sends an interrupt control request on abort', async () => { + const process = claudeProcess() + const host = new WebSessionHost(logger, {}, () => process as never) + await host.start(claudeInput) + await host.prompt('record-1', 'list files') + await settle() + await host.abort('record-1') + await settle() + expect(process.received.some((c) => c['type'] === 'control_request' && (c['request'] as Json)['subtype'] === 'interrupt')).toBe(true) + const snapshot = host.get('record-1')! + expect(snapshot.phase).toBe('idle') + expect(snapshot.requests).toEqual([]) + expect(snapshot.error).toBe('interrupted') + }) +}) + +// ── codex-app-server ─────────────────────────────────────────────────────── + +function codexProcess(options: { history?: boolean; permissions?: boolean } = {}): FakeProcess { + return new FakeProcess((command, self) => { + const id = command['id'] + const method = command['method'] + const params = (command['params'] ?? {}) as Json + if (method === 'initialize') { self.line({ id, result: { userAgent: 'codex' } }); return } + if (options.permissions) { + if (method === 'thread/start') { self.line({ id, result: { thread: { id: 'thr_perm', turns: [] } } }); return } + if (method === 'turn/start') { + self.line({ id, result: { turn: { id: 'turn_p', status: 'inProgress', items: [] } } }) + self.line({ id: 'perm-1', method: 'item/permissions/requestApproval', params: { + itemId: 'p1', threadId: 'thr_perm', turnId: 'turn_p', cwd: '/w', reason: 'needs to reach the registry', + permissions: { network: { enabled: true } }, + } }) + return + } + if (command['id'] === 'perm-1' && 'result' in command) { + self.line({ method: 'turn/completed', params: { turn: { id: 'turn_p', status: 'completed' } } }) + } + if (method === 'turn/interrupt') { + self.line({ id, result: {} }) + self.line({ method: 'turn/completed', params: { turn: { id: 'turn_p', status: 'interrupted' } } }) + } + return + } + if (method === 'thread/start') { self.line({ id, result: { thread: { id: 'thr_new', turns: [] } } }); return } + if (method === 'thread/resume') { + self.line({ id, result: { thread: { id: params['threadId'], turns: options.history ? [{ + id: 'turn_0', + items: [ + { type: 'userMessage', id: 'u0', content: [{ type: 'text', text: 'old question' }] }, + { type: 'commandExecution', id: 'c0', command: 'pwd', cwd: '/w', status: 'completed', aggregatedOutput: '/w\n', exitCode: 0 }, + { type: 'agentMessage', id: 'a0', text: 'old answer' }, + ], + }] : [] } } }) + return + } + if (method === 'turn/start') { + self.line({ id, result: { turn: { id: 'turn_1', status: 'inProgress', items: [] } } }) + self.line({ method: 'turn/started', params: { turn: { id: 'turn_1' } } }) + self.line({ method: 'item/started', params: { item: { type: 'agentMessage', id: 'a1', text: '' } } }) + self.line({ method: 'item/agentMessage/delta', params: { itemId: 'a1', delta: 'Running ' } }) + self.line({ method: 'item/agentMessage/delta', params: { itemId: 'a1', delta: 'tests.' } }) + self.line({ method: 'item/completed', params: { item: { type: 'agentMessage', id: 'a1', text: 'Running tests.' } } }) + self.line({ method: 'item/started', params: { item: { type: 'commandExecution', id: 'c1', command: 'pnpm test', cwd: '/w', status: 'inProgress' } } }) + self.line({ id: 'approval-1', method: 'item/commandExecution/requestApproval', params: { itemId: 'c1', threadId: 'thr_new', turnId: 'turn_1', command: 'pnpm test', cwd: '/w', reason: 'runs outside the sandbox' } }) + return + } + if (command['id'] === 'approval-1' && 'result' in command) { + const decision = (command['result'] as Json)['decision'] + const status = decision === 'accept' || decision === 'acceptForSession' ? 'completed' : 'declined' + self.line({ method: 'item/completed', params: { item: { type: 'commandExecution', id: 'c1', command: 'pnpm test', cwd: '/w', status, aggregatedOutput: status === 'completed' ? '12 passed\n' : '', exitCode: status === 'completed' ? 0 : null } } }) + self.line({ method: 'turn/completed', params: { turn: { id: 'turn_1', status: decision === 'cancel' ? 'interrupted' : 'completed' } } }) + return + } + if (method === 'turn/interrupt') { + self.line({ id, result: {} }) + self.line({ method: 'turn/completed', params: { turn: { id: 'turn_1', status: 'interrupted' } } }) + } + }) +} + +describe('WebSessionHost with the codex-app-server transport', () => { + const codexInput = input({ agent: 'codex', wire: 'codex-app-server', command: ['codex', 'app-server'] }) + + it('starts a thread, streams items, and routes command approvals to the browser', async () => { + const onNativeSessionId = vi.fn() + const process = codexProcess() + const host = new WebSessionHost(logger, { onNativeSessionId }, () => process as never) + const started = await host.start(codexInput) + expect(started.nativeSessionId).toBe('thr_new') + expect(onNativeSessionId).toHaveBeenCalledWith('record-1', 'thr_new') + expect(process.received.map((c) => c['method'])).toEqual(['initialize', 'initialized', 'thread/start']) + expect(process.received[2]).toMatchObject({ params: { cwd: '/tmp/workspace', approvalPolicy: 'on-request', sandbox: 'workspace-write' } }) + + await host.prompt('record-1', 'run the tests') + await settle() + const waiting = host.get('record-1')! + expect(waiting.phase).toBe('awaiting-input') + expect(waiting.requests[0]).toMatchObject({ + title: 'Run pnpm test', + description: 'runs outside the sandbox', + options: [{ id: 'accept', tone: 'allow' }, { id: 'acceptForSession', tone: 'allow' }, { id: 'decline', tone: 'deny' }], + }) + await host.respond('record-1', waiting.requests[0]!.id, 'accept') + await settle() + const done = host.get('record-1')! + expect(done.phase).toBe('idle') + expect(texts(done)).toEqual(['user:run the tests', 'assistant:Running tests.|[shell]', 'toolResult:shell:ok']) + expect(process.received.find((c) => c['id'] === 'approval-1')).toEqual({ id: 'approval-1', result: { decision: 'accept' } }) + }) + + it('answers permission requests with the granted profile, not a decision enum', async () => { + const process = codexProcess({ permissions: true }) + const host = new WebSessionHost(logger, {}, () => process as never) + await host.start(codexInput) + await host.prompt('record-1', 'install deps') + await settle() + const waiting = host.get('record-1')! + expect(waiting.phase).toBe('awaiting-input') + expect(waiting.requests[0]).toMatchObject({ + title: 'Grant additional permissions', + description: 'needs to reach the registry', + tool: { name: 'request_permissions', input: { network: { enabled: true } } }, + options: [{ id: 'grant', tone: 'allow' }, { id: 'grantForSession', tone: 'allow' }, { id: 'decline', tone: 'deny' }], + }) + await host.respond('record-1', waiting.requests[0]!.id, 'grantForSession') + await settle() + expect(process.received.find((c) => c['id'] === 'perm-1')).toEqual({ + id: 'perm-1', + result: { permissions: { network: { enabled: true } }, scope: 'session' }, + }) + expect(host.get('record-1')!.phase).toBe('idle') + }) + + it('cancels a pending permission request with an empty grant when the turn ends first', async () => { + const process = codexProcess({ permissions: true }) + const host = new WebSessionHost(logger, {}, () => process as never) + await host.start(codexInput) + await host.prompt('record-1', 'install deps') + await settle() + expect(host.get('record-1')!.requests).toHaveLength(1) + await host.abort('record-1') + await settle() + expect(process.received.find((c) => c['id'] === 'perm-1')).toEqual({ id: 'perm-1', result: { permissions: {} } }) + expect(host.get('record-1')!.requests).toEqual([]) + }) + + it('replays thread history on resume', async () => { + const host = new WebSessionHost(logger, {}, () => codexProcess({ history: true }) as never) + const started = await host.start({ ...codexInput, nativeSessionId: 'thr_old' }) + expect(started.nativeSessionId).toBe('thr_old') + expect(texts(started)).toEqual(['user:old question', 'assistant:[shell]', 'toolResult:shell:ok', 'assistant:old answer']) + }) + + it('interrupts the active turn and cancels pending approvals', async () => { + const process = codexProcess() + const host = new WebSessionHost(logger, {}, () => process as never) + await host.start(codexInput) + await host.prompt('record-1', 'run the tests') + await settle() + await host.abort('record-1') + await settle() + expect(process.received.some((c) => c['method'] === 'turn/interrupt')).toBe(true) + const snapshot = host.get('record-1')! + expect(snapshot.requests).toEqual([]) + expect(snapshot.phase).toBe('idle') + expect(texts(snapshot).at(-1)).toBe('notice:Turn interrupted.') + }) +}) diff --git a/src/workspaces/web-session-host.ts b/src/workspaces/web-session-host.ts new file mode 100644 index 000000000..8c1b7209d --- /dev/null +++ b/src/workspaces/web-session-host.ts @@ -0,0 +1,369 @@ +/** + * Web conversation surface — one long-lived structured Agent process per + * Session record, presented in the browser instead of a PTY. + * + * The host owns process supervision (spawn, stdio framing, stderr tail, exit) + * and hands each process to the transport that speaks its protocol. Transports + * project the runtime's live protocol onto the neutral model in + * `web-session/model.ts`; nothing here knows any vendor event name. + */ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { StringDecoder } from 'node:string_decoder' + +import type { Logger } from './logger.js' +import { AcpTransport } from './web-session/acp-transport.js' +import { ClaudeStreamJsonTransport } from './web-session/claude-stream-json-transport.js' +import { CodexAppServerTransport } from './web-session/codex-app-server-transport.js' +import { isJsonObject, type JsonObject, type WebSessionSnapshot, type WebSessionWire } from './web-session/model.js' +import { PiRpcTransport } from './web-session/pi-rpc-transport.js' +import { + WebSessionState, + type JsonlChannel, + type StartWebSessionInput, + type WebSessionTransport, + type WebTransportFactory, +} from './web-session/transport.js' +import { resolveLaunchCommand } from './win-command.js' + +export type { StartWebSessionInput } from './web-session/transport.js' +export type { + WebConversationMessage, + WebPermissionRequest, + WebSessionPhase, + WebSessionSnapshot, + WebSessionWire, +} from './web-session/model.js' + +const STDERR_MAX_CHARS = 64 * 1024 + +export interface WebSessionProcess { + readonly pid?: number + readonly stdin: ChildProcessWithoutNullStreams['stdin'] + readonly stdout: ChildProcessWithoutNullStreams['stdout'] + readonly stderr: ChildProcessWithoutNullStreams['stderr'] + once(event: 'spawn', listener: () => void): this + once(event: 'error', listener: (error: Error) => void): this + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this + on(event: 'error', listener: (error: Error) => void): this + kill(signal?: NodeJS.Signals): boolean +} + +export interface WebSessionExitReason { + readonly code: number | null + readonly signal: NodeJS.Signals | null + readonly intentional: boolean +} + +interface HostCallbacks { + readonly onExit?: (recordId: string, reason: WebSessionExitReason) => void + /** Fired once a transport learns (or confirms) the runtime's session id. */ + readonly onNativeSessionId?: (recordId: string, nativeSessionId: string) => void +} + +type SpawnProcess = (input: StartWebSessionInput) => WebSessionProcess + +export const WEB_TRANSPORTS: Readonly> = { + 'pi-rpc': (ctx) => new PiRpcTransport(ctx), + acp: (ctx) => new AcpTransport(ctx), + 'claude-stream-json': (ctx) => new ClaudeStreamJsonTransport(ctx), + 'codex-app-server': (ctx) => new CodexAppServerTransport(ctx), +} + +export class WebSessionHost { + private readonly sessions = new Map() + + constructor( + private readonly logger: Logger, + private readonly callbacks: HostCallbacks = {}, + private readonly spawnProcess: SpawnProcess = defaultSpawnProcess, + private readonly transports: Readonly> = WEB_TRANSPORTS, + ) {} + + has(recordId: string): boolean { + return this.sessions.has(recordId) + } + + get(recordId: string): WebSessionSnapshot | null { + return this.sessions.get(recordId)?.snapshot() ?? null + } + + async start(input: StartWebSessionInput): Promise { + const existing = this.sessions.get(input.recordId) + if (existing) return existing.snapshot() + const factory = this.transports[input.wire] + if (!factory) throw new Error(`no Web transport for wire ${String(input.wire)}`) + const session = new LiveWebSession( + input, + this.spawnProcess(input), + factory, + this.logger.child({ scope: 'web-session', wsId: input.wsId, recordId: input.recordId, wire: input.wire }), + { + onExit: (reason) => { + if (this.sessions.get(input.recordId) === session) this.sessions.delete(input.recordId) + this.callbacks.onExit?.(input.recordId, reason) + }, + onNativeSessionId: (id) => this.callbacks.onNativeSessionId?.(input.recordId, id), + }, + ) + this.sessions.set(input.recordId, session) + try { + await session.start() + return session.snapshot() + } catch (error) { + this.sessions.delete(input.recordId) + await session.stop('startup failed').catch(() => undefined) + throw error + } + } + + async prompt(recordId: string, message: string): Promise { + const session = this.require(recordId) + await session.prompt(message) + return session.snapshot() + } + + async abort(recordId: string): Promise { + const session = this.require(recordId) + await session.abort() + return session.snapshot() + } + + async respond(recordId: string, requestId: string, optionId: string): Promise { + const session = this.require(recordId) + await session.respond(requestId, optionId) + return session.snapshot() + } + + async stop(recordId: string, reason = 'stopped'): Promise { + const session = this.sessions.get(recordId) + if (!session) return false + this.sessions.delete(recordId) + await session.stop(reason) + return true + } + + async stopAll(reason = 'host disposed'): Promise { + const sessions = Array.from(this.sessions.values()) + this.sessions.clear() + await Promise.allSettled(sessions.map((session) => session.stop(reason))) + } + + private require(recordId: string): LiveWebSession { + const session = this.sessions.get(recordId) + if (!session) throw new Error(`Web session is not running: ${recordId}`) + return session + } +} + +class LiveWebSession { + private readonly state: WebSessionState + private readonly channel: ChildJsonlChannel + private readonly transport: WebSessionTransport + private stderrTail = '' + private intentionalStop = false + private exited = false + private readonly startedAt = Date.now() + + constructor( + private readonly input: StartWebSessionInput, + private readonly child: WebSessionProcess, + factory: WebTransportFactory, + private readonly logger: Logger, + private readonly callbacks: { + onExit: (reason: WebSessionExitReason) => void + onNativeSessionId: (id: string) => void + }, + ) { + let lastNativeId: string | null = input.nativeSessionId ?? null + this.state = new WebSessionState(() => { + if (this.state.nativeSessionId && this.state.nativeSessionId !== lastNativeId) { + lastNativeId = this.state.nativeSessionId + this.callbacks.onNativeSessionId(lastNativeId) + } + }) + if (input.nativeSessionId) this.state.nativeSessionId = input.nativeSessionId + this.channel = new ChildJsonlChannel(child, logger) + this.transport = factory({ input, state: this.state, channel: this.channel, logger }) + } + + async start(): Promise { + this.child.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk)) + this.child.on('error', (error) => this.fail(error)) + this.child.once('exit', (code, signal) => this.handleExit(code, signal)) + this.channel.attach() + await new Promise((resolve, reject) => { + this.child.once('spawn', resolve) + this.child.once('error', reject) + }) + this.logger.info('web_session.started', { pid: this.child.pid ?? null, command: this.input.command }) + await this.transport.start() + if (this.exited) throw new Error(this.state.error ?? 'Web session process exited during startup') + if (this.state.phase === 'starting') this.state.setPhase('idle') + } + + snapshot(): WebSessionSnapshot { + return { + recordId: this.input.recordId, + wsId: this.input.wsId, + resumeId: this.input.resumeId, + agent: this.input.agent, + wire: this.input.wire, + nativeSessionId: this.state.nativeSessionId, + pid: this.exited ? null : this.child.pid ?? null, + startedAt: this.startedAt, + phase: this.state.phase, + messages: this.state.messages, + streamingMessage: this.state.streamingMessage, + requests: this.state.requests, + error: this.state.error, + stderrTail: this.stderrTail, + revision: this.state.revision, + } + } + + prompt(message: string): Promise { + this.assertLive() + return this.transport.prompt(message) + } + + abort(): Promise { + this.assertLive() + return this.transport.abort() + } + + respond(requestId: string, optionId: string): Promise { + this.assertLive() + return this.transport.respond(requestId, optionId) + } + + async stop(reason: string): Promise { + if (this.exited) return + this.intentionalStop = true + this.logger.info('web_session.stopping', { reason }) + try { + this.transport.dispose?.() + } catch (error) { + this.logger.warn('web_session.dispose_failed', { error }) + } + this.channel.close() + this.child.kill('SIGTERM') + await Promise.race([ + new Promise((resolve) => this.child.once('exit', () => resolve())), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]) + if (!this.exited) this.child.kill('SIGKILL') + } + + private assertLive(): void { + if (this.exited) throw new Error(this.state.error ?? 'Web session process exited') + } + + private onStderr(chunk: Buffer): void { + this.stderrTail = `${this.stderrTail}${chunk.toString('utf8')}`.slice(-STDERR_MAX_CHARS) + this.state.bump() + } + + private fail(error: Error): void { + this.state.fail(error.message) + this.logger.error('web_session.failed', { error }) + } + + private handleExit(code: number | null, signal: NodeJS.Signals | null): void { + if (this.exited) return + this.exited = true + this.channel.close() + try { + this.transport.dispose?.() + } catch { + // The process is already gone; a transport cleanup failure is not actionable. + } + this.state.phase = this.intentionalStop ? 'stopped' : 'failed' + if (!this.intentionalStop && !this.state.error) { + this.state.error = `${this.input.agent} exited (code=${String(code)}, signal=${String(signal)})` + } + this.state.clearRequests() + this.state.bump() + this.logger.info('web_session.exited', { code, signal, intentional: this.intentionalStop }) + this.callbacks.onExit({ code, signal, intentional: this.intentionalStop }) + } +} + +class ChildJsonlChannel implements JsonlChannel { + private readonly decoder = new StringDecoder('utf8') + private buffer = '' + private handlers: Array<(value: JsonObject) => void> = [] + closed = false + + constructor(private readonly child: WebSessionProcess, private readonly logger: Logger) {} + + attach(): void { + this.child.stdout.on('data', (chunk: Buffer) => this.onData(chunk)) + } + + send(value: unknown): Promise { + if (this.closed) return Promise.reject(new Error('process stdin is closed')) + const line = `${JSON.stringify(value)}\n` + return new Promise((resolve, reject) => { + this.child.stdin.write(line, (error) => (error ? reject(error) : resolve())) + }) + } + + onMessage(handler: (value: JsonObject) => void): void { + this.handlers.push(handler) + } + + close(): void { + if (this.closed) return + this.closed = true + try { + this.child.stdin.end() + } catch { + // stdin may already be destroyed by the exiting process + } + } + + private onData(chunk: Buffer): void { + this.buffer += this.decoder.write(chunk) + let newline = this.buffer.indexOf('\n') + while (newline >= 0) { + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + this.handleLine(line) + newline = this.buffer.indexOf('\n') + } + } + + private handleLine(raw: string): void { + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw + if (!line.trim()) return + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch (error) { + // Some runtimes print banners or progress text on stdout before/around + // their protocol frames; keep them out of the transcript but visible. + this.logger.warn('web_session.non_json_stdout', { error, line: line.slice(0, 500) }) + return + } + if (!isJsonObject(parsed)) return + for (const handler of this.handlers) { + try { + handler(parsed) + } catch (error) { + this.logger.error('web_session.handler_failed', { error }) + } + } + } +} + +function defaultSpawnProcess(input: StartWebSessionInput): WebSessionProcess { + const resolved = resolveLaunchCommand(input.command, { env: input.env, cwd: input.cwd }) + const [file, ...args] = resolved.argv + if (!file) throw new Error('Web session command is empty') + return spawn(file, args, { + cwd: input.cwd, + env: { ...input.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) +} diff --git a/src/workspaces/web-session/acp-transport.ts b/src/workspaces/web-session/acp-transport.ts new file mode 100644 index 000000000..d8e9e7e0b --- /dev/null +++ b/src/workspaces/web-session/acp-transport.ts @@ -0,0 +1,291 @@ +/** + * Agent Client Protocol (https://agentclientprotocol.com) over stdio. + * + * Cursor Agent (`agent acp`), Grok Build (`grok agent stdio`) and opencode + * (`opencode acp`) implement the agent side natively. Alice is the client: it + * advertises no filesystem or terminal capabilities, so agents keep using + * their own tools, and only permission requests round-trip to the browser. + */ +import { JsonRpcPeer, type JsonRpcError } from './json-rpc.js' +import { + isJsonObject, + stringOrNull, + type JsonObject, + type WebContentPart, + type WebPermissionRequest, + type WebRequestOption, + type WebRequestOptionTone, +} from './model.js' +import { TranscriptBuilder } from './transcript-builder.js' +import type { WebSessionTransport, WebTransportContext } from './transport.js' + +const ACP_PROTOCOL_VERSION = 1 +const SESSION_LOAD_TIMEOUT_MS = 120_000 + +interface PendingPermission { + readonly rpcId: string | number + readonly resolve: (outcome: JsonObject) => void +} + +export class AcpTransport implements WebSessionTransport { + private readonly peer: JsonRpcPeer + private readonly builder: TranscriptBuilder + private readonly permissions = new Map() + private sessionId: string | null + private pendingUserText: string | null = null + private turnActive = false + + constructor(private readonly ctx: WebTransportContext) { + this.sessionId = ctx.input.nativeSessionId ?? null + this.builder = new TranscriptBuilder(ctx.state) + this.peer = new JsonRpcPeer(ctx.channel, ctx.logger, { + onNotification: (method, params) => this.onNotification(method, params), + onRequest: (method, params, id) => this.onRequest(method, params, id), + }) + } + + async start(): Promise { + const init = await this.peer.request('initialize', { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: 'openalice', title: 'OpenAlice', version: '1' }, + }) + const capabilities = isJsonObject(init) && isJsonObject(init['agentCapabilities']) ? init['agentCapabilities'] : {} + const canLoad = capabilities['loadSession'] === true + const params = { cwd: this.ctx.input.cwd, mcpServers: [] } + try { + if (this.sessionId && canLoad) { + await this.peer.request('session/load', { sessionId: this.sessionId, ...params }, SESSION_LOAD_TIMEOUT_MS) + this.flushUser() + this.builder.endTurn() + } else { + if (this.sessionId) { + this.builder.notice(`${this.ctx.input.agent} cannot reload session ${this.sessionId} over ACP; a new native session was started.`) + } + const created = await this.peer.request('session/new', params) + const id = isJsonObject(created) ? stringOrNull(created['sessionId']) : null + if (!id) throw new Error('ACP session/new returned no sessionId') + this.sessionId = id + } + } catch (error) { + throw describeAcpFailure(error, this.ctx.input.agent) + } + this.ctx.state.setNativeSessionId(this.sessionId) + this.ctx.state.setPhase('idle') + } + + async prompt(message: string): Promise { + const text = message.trim() + if (!text) throw new Error('prompt cannot be empty') + if (!this.sessionId) throw new Error('ACP session is not established') + if (this.turnActive) throw new Error(`${this.ctx.input.agent} is still working on the previous prompt`) + this.turnActive = true + this.ctx.state.error = null + this.builder.user(text) + this.ctx.state.setPhase('working') + const turn = this.peer.request('session/prompt', { + sessionId: this.sessionId, + prompt: [{ type: 'text', text }], + }, null) + void turn.then( + (result) => this.finishTurn(isJsonObject(result) ? stringOrNull(result['stopReason']) : null, null), + (error: Error) => this.finishTurn(null, error), + ) + } + + async abort(): Promise { + if (!this.sessionId) return + await this.peer.notify('session/cancel', { sessionId: this.sessionId }) + } + + async respond(requestId: string, optionId: string): Promise { + const request = this.ctx.state.requests.find((r) => r.id === requestId) + const pending = this.permissions.get(requestId) + if (!request || !pending) throw new Error(`no pending request ${requestId}`) + if (!request.options.some((option) => option.id === optionId)) { + throw new Error(`option ${optionId} is not offered by request ${requestId}`) + } + this.permissions.delete(requestId) + this.ctx.state.removeRequest(requestId) + pending.resolve({ outcome: { outcome: 'selected', optionId } }) + } + + dispose(): void { + this.cancelPermissions() + this.peer.dispose(new Error('ACP session stopped')) + } + + private finishTurn(stopReason: string | null, error: Error | null): void { + this.turnActive = false + this.cancelPermissions() + this.flushUser() + this.builder.endTurn() + if (error) { + if (!this.ctx.channel.closed) { + this.ctx.state.error = error.message + this.ctx.state.setPhase('idle') + } + return + } + if (stopReason === 'refusal') this.builder.notice(`${this.ctx.input.agent} refused to continue this turn.`) + if (stopReason === 'max_tokens') this.builder.notice('The turn stopped at the model output limit.') + if (stopReason === 'max_turn_requests') this.builder.notice('The turn stopped at the request limit.') + this.ctx.state.setPhase('idle') + } + + private onNotification(method: string, params: unknown): void { + if (method !== 'session/update' || !isJsonObject(params)) return + const update = params['update'] + if (!isJsonObject(update)) return + const kind = update['sessionUpdate'] + if (kind !== 'user_message_chunk') this.flushUser() + switch (kind) { + case 'user_message_chunk': + this.pendingUserText = `${this.pendingUserText ?? ''}${contentBlockText(update['content'])}` + break + case 'agent_message_chunk': + this.builder.text(contentBlockText(update['content'])) + break + case 'agent_thought_chunk': + this.builder.thinking(contentBlockText(update['content'])) + break + case 'tool_call': { + const id = stringOrNull(update['toolCallId']) + if (!id) break + this.builder.toolCall(id, toolTitle(update), update['rawInput'] ?? {}) + this.applyToolStatus(id, update) + break + } + case 'tool_call_update': { + const id = stringOrNull(update['toolCallId']) + if (!id) break + const name = stringOrNull(update['title']) + this.builder.toolCallUpdate(id, { + ...(name ? { name } : {}), + ...(update['rawInput'] !== undefined ? { args: update['rawInput'] } : {}), + }) + this.applyToolStatus(id, update) + break + } + default: + // plan, available_commands_update, current_mode_update, config_option_update + break + } + } + + private applyToolStatus(id: string, update: JsonObject): void { + const status = update['status'] + if (status !== 'completed' && status !== 'failed') return + const content = toolCallContent(update['content']) + const output = content.length > 0 + ? content + : update['rawOutput'] !== undefined + ? [{ type: 'data', value: update['rawOutput'] } satisfies WebContentPart] + : '' + this.builder.toolResult(id, output, status === 'failed') + } + + private async onRequest(method: string, params: unknown, id: string | number): Promise { + if (method === 'session/request_permission' && isJsonObject(params)) { + return this.requestPermission(params, id) + } + const error = new Error(`OpenAlice does not implement ${method}`) as Error & { code: number } + error.code = -32601 + throw error + } + + private requestPermission(params: JsonObject, rpcId: string | number): Promise { + const toolCall = isJsonObject(params['toolCall']) ? params['toolCall'] : {} + const requestId = `acp-${String(rpcId)}` + const options = Array.isArray(params['options']) + ? params['options'].flatMap((option): WebRequestOption[] => { + if (!isJsonObject(option)) return [] + const optionId = stringOrNull(option['optionId']) + if (!optionId) return [] + return [{ id: optionId, label: stringOrNull(option['name']) ?? optionId, tone: toneFromKind(option['kind']) }] + }) + : [] + const toolName = toolTitle(toolCall) + const request: WebPermissionRequest = { + id: requestId, + kind: 'permission', + title: toolName === 'tool' ? `${this.ctx.input.agent} requests permission` : toolName, + description: `${this.ctx.input.agent} wants to run this tool.`, + tool: { name: toolName, input: toolCall['rawInput'] ?? {} }, + options: options.length > 0 + ? options + : [{ id: 'allow', label: 'Allow', tone: 'allow' }, { id: 'reject', label: 'Reject', tone: 'deny' }], + createdAt: Date.now(), + } + return new Promise((resolve) => { + this.permissions.set(requestId, { rpcId, resolve }) + this.ctx.state.addRequest(request) + }) + } + + private cancelPermissions(): void { + for (const [requestId, pending] of this.permissions) { + this.permissions.delete(requestId) + this.ctx.state.removeRequest(requestId) + pending.resolve({ outcome: { outcome: 'cancelled' } }) + } + } + + private flushUser(): void { + if (this.pendingUserText === null) return + const text = this.pendingUserText + this.pendingUserText = null + if (text.trim()) this.builder.user(text) + } +} + +function toolTitle(record: JsonObject): string { + return stringOrNull(record['title']) ?? stringOrNull(record['kind']) ?? 'tool' +} + +function toneFromKind(kind: unknown): WebRequestOptionTone { + if (kind === 'allow_once' || kind === 'allow_always') return 'allow' + if (kind === 'reject_once' || kind === 'reject_always') return 'deny' + return 'neutral' +} + +function contentBlockText(block: unknown): string { + if (!isJsonObject(block)) return '' + if (block['type'] === 'text') return stringOrNull(block['text']) ?? '' + if (block['type'] === 'resource_link') return stringOrNull(block['uri']) ?? '' + if (block['type'] === 'resource' && isJsonObject(block['resource'])) return stringOrNull(block['resource']['text']) ?? '' + return '' +} + +function toolCallContent(value: unknown): WebContentPart[] { + if (!Array.isArray(value)) return [] + return value.flatMap((entry): WebContentPart[] => { + if (!isJsonObject(entry)) return [] + if (entry['type'] === 'content') { + const text = contentBlockText(entry['content']) + return text ? [{ type: 'text', text }] : [] + } + if (entry['type'] === 'diff') { + const path = stringOrNull(entry['path']) ?? 'file' + const oldText = stringOrNull(entry['oldText']) + const newText = stringOrNull(entry['newText']) ?? '' + return [{ type: 'text', text: `Edited ${path}\n\n\`\`\`diff\n${diffPreview(oldText, newText)}\n\`\`\`` }] + } + if (entry['type'] === 'terminal') return [{ type: 'data', value: entry }] + return [{ type: 'data', value: entry }] + }) +} + +function diffPreview(oldText: string | null, newText: string): string { + const removed = oldText ? oldText.split('\n').map((line) => `- ${line}`) : [] + const added = newText.split('\n').map((line) => `+ ${line}`) + return [...removed, ...added].join('\n') +} + +function describeAcpFailure(error: unknown, agent: string): Error { + const rpc = error as Partial + if (rpc.code === -32000 || /auth/i.test(rpc.message ?? '')) { + return new Error(`${agent} requires authentication before it can open a Web session: ${rpc.message ?? 'auth required'}`) + } + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/src/workspaces/web-session/claude-stream-json-transport.ts b/src/workspaces/web-session/claude-stream-json-transport.ts new file mode 100644 index 000000000..b3370c666 --- /dev/null +++ b/src/workspaces/web-session/claude-stream-json-transport.ts @@ -0,0 +1,259 @@ +/** + * Claude Code's bidirectional stream-json mode: + * `claude -p --input-format stream-json --output-format stream-json --verbose + * --permission-prompt-tool stdio`. + * + * The process stays alive between turns. Tool permission prompts arrive as + * `control_request` frames (`can_use_tool`) and are answered with + * `control_response`; `interrupt` is a client-initiated control request. + */ +import { + isJsonObject, + stringOrNull, + type JsonObject, + type WebContentPart, + type WebPermissionRequest, +} from './model.js' +import { partsFromUnknownContent, TranscriptBuilder } from './transcript-builder.js' +import { PendingRequests, type WebSessionTransport, type WebTransportContext } from './transport.js' + +const ALLOW = 'allow' +const DENY = 'deny' + +interface PendingControl { + readonly requestId: string + readonly input: unknown +} + +export class ClaudeStreamJsonTransport implements WebSessionTransport { + private readonly builder: TranscriptBuilder + private readonly controls = new PendingRequests('web-claude') + private readonly permissions = new Map() + private turnActive = false + private blocks: WebContentPart[] = [] + private partial: WebContentPart[] = [] + private assistantMessageId: string | null = null + + constructor(private readonly ctx: WebTransportContext) { + this.builder = new TranscriptBuilder(ctx.state) + ctx.channel.onMessage((event) => this.handleEvent(event)) + } + + async start(): Promise { + // Claude prints `system/init` only once the first user message arrives, + // so there is nothing to await here beyond a live process. + this.ctx.state.setPhase('idle') + } + + async prompt(message: string): Promise { + const text = message.trim() + if (!text) throw new Error('prompt cannot be empty') + if (this.turnActive) throw new Error('Claude is still working on the previous prompt') + this.turnActive = true + this.ctx.state.error = null + this.builder.user(text) + this.ctx.state.setPhase('working') + try { + await this.ctx.channel.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + ...(this.ctx.state.nativeSessionId ? { session_id: this.ctx.state.nativeSessionId } : {}), + }) + } catch (error) { + this.turnActive = false + this.ctx.state.error = error instanceof Error ? error.message : String(error) + this.ctx.state.setPhase(this.ctx.channel.closed ? 'failed' : 'idle') + throw error + } + } + + async abort(): Promise { + if (!this.turnActive) return + const requestId = this.controls.nextId() + const wait = this.controls.wait(requestId, 'Claude interrupt') + await this.ctx.channel.send({ type: 'control_request', request_id: requestId, request: { subtype: 'interrupt' } }) + await wait.catch(() => undefined) + } + + async respond(requestId: string, optionId: string): Promise { + const pending = this.permissions.get(requestId) + if (!pending) throw new Error(`no pending request ${requestId}`) + if (optionId !== ALLOW && optionId !== DENY) throw new Error(`option ${optionId} is not offered by request ${requestId}`) + this.permissions.delete(requestId) + this.ctx.state.removeRequest(requestId) + await this.ctx.channel.send({ + type: 'control_response', + response: { + subtype: 'success', + request_id: pending.requestId, + response: optionId === ALLOW + ? { behavior: 'allow', updatedInput: pending.input } + : { behavior: 'deny', message: 'The user declined this tool use in OpenAlice.' }, + }, + }) + } + + dispose(): void { + this.permissions.clear() + this.controls.rejectAll(new Error('Claude session stopped')) + } + + private handleEvent(event: JsonObject): void { + const sessionId = stringOrNull(event['session_id']) + if (sessionId) this.ctx.state.setNativeSessionId(sessionId) + switch (event['type']) { + case 'system': + if (event['subtype'] === 'compact_boundary') this.builder.notice('Claude compacted the conversation context.') + break + case 'stream_event': + this.handleStreamEvent(event['event']) + break + case 'assistant': + this.handleAssistant(event['message']) + break + case 'user': + this.handleUser(event['message']) + break + case 'result': + this.finishTurn(event) + break + case 'control_request': + this.handleControlRequest(event) + break + case 'control_response': { + const response = isJsonObject(event['response']) ? event['response'] : null + const id = stringOrNull(response?.['request_id']) + if (id) this.controls.resolve(id, response ?? {}) + break + } + default: + break + } + } + + private handleStreamEvent(raw: unknown): void { + if (!isJsonObject(raw)) return + if (raw['type'] !== 'content_block_delta' || !isJsonObject(raw['delta'])) return + const delta = raw['delta'] + const last = this.partial[this.partial.length - 1] + if (delta['type'] === 'text_delta' && typeof delta['text'] === 'string') { + if (last?.type === 'text') this.partial[this.partial.length - 1] = { type: 'text', text: last.text + delta['text'] } + else this.partial.push({ type: 'text', text: delta['text'] }) + } else if (delta['type'] === 'thinking_delta' && typeof delta['thinking'] === 'string') { + if (last?.type === 'thinking') this.partial[this.partial.length - 1] = { type: 'thinking', thinking: last.thinking + delta['thinking'] } + else this.partial.push({ type: 'thinking', thinking: delta['thinking'] }) + } else { + return + } + this.render() + } + + private handleAssistant(message: unknown): void { + if (!isJsonObject(message) || !Array.isArray(message['content'])) return + const id = stringOrNull(message['id']) + if (id !== this.assistantMessageId) { + this.assistantMessageId = id + this.blocks = [] + } + // Each stream-json `assistant` frame carries the content blocks that + // completed so far for one API message; deltas for those blocks are now + // authoritative in `blocks` and no longer needed as partial text. + this.partial = [] + for (const block of message['content']) { + const part = convertClaudeBlock(block) + if (part) this.blocks.push(part) + } + this.render() + for (const part of this.blocks) { + if (part.type === 'toolCall' && !this.builder.hasOpenToolCall(part.id)) { + this.builder.trackToolCall(part.id, part.name) + } + } + } + + private handleUser(message: unknown): void { + if (!isJsonObject(message) || !Array.isArray(message['content'])) return + for (const block of message['content']) { + if (!isJsonObject(block) || block['type'] !== 'tool_result') continue + const toolUseId = stringOrNull(block['tool_use_id']) + if (!toolUseId) continue + this.commitBlocks() + this.builder.toolResult(toolUseId, partsFromUnknownContent(block['content'] ?? ''), block['is_error'] === true) + } + } + + private handleControlRequest(event: JsonObject): void { + const requestId = stringOrNull(event['request_id']) + const request = isJsonObject(event['request']) ? event['request'] : null + if (!requestId || !request) return + if (request['subtype'] !== 'can_use_tool') { + void this.ctx.channel.send({ + type: 'control_response', + response: { subtype: 'error', request_id: requestId, error: `OpenAlice does not handle ${String(request['subtype'])}` }, + }) + return + } + const toolName = stringOrNull(request['tool_name']) ?? 'tool' + const id = `claude-${requestId}` + this.permissions.set(id, { requestId, input: request['input'] ?? {} }) + const permission: WebPermissionRequest = { + id, + kind: 'permission', + title: toolName, + description: stringOrNull(request['description']) ?? 'Claude wants to use this tool.', + tool: { name: toolName, input: request['input'] ?? {} }, + options: [ + { id: ALLOW, label: 'Allow', tone: 'allow' }, + { id: DENY, label: 'Deny', tone: 'deny' }, + ], + createdAt: Date.now(), + } + this.ctx.state.addRequest(permission) + } + + private finishTurn(event: JsonObject): void { + this.turnActive = false + for (const [id] of this.permissions) this.ctx.state.removeRequest(id) + this.permissions.clear() + this.commitBlocks() + this.builder.endTurn() + if (event['is_error'] === true) { + const errors = Array.isArray(event['errors']) ? event['errors'].map(String).join('\n') : '' + const detail = stringOrNull(event['result']) || errors || stringOrNull(event['subtype']) || 'Claude turn failed' + this.ctx.state.error = detail + } + this.ctx.state.setPhase('idle') + } + + private render(): void { + this.ctx.state.setStreaming({ role: 'assistant', content: [...this.blocks, ...this.partial], timestamp: Date.now() }) + } + + private commitBlocks(): void { + if (this.blocks.length === 0 && this.partial.length === 0) return + this.render() + this.ctx.state.commitStreaming() + this.blocks = [] + this.partial = [] + this.assistantMessageId = null + } +} + +function convertClaudeBlock(block: unknown): WebContentPart | null { + if (!isJsonObject(block)) return null + switch (block['type']) { + case 'text': + return typeof block['text'] === 'string' && block['text'] ? { type: 'text', text: block['text'] } : null + case 'thinking': + return { type: 'thinking', thinking: stringOrNull(block['thinking']) ?? '' } + case 'tool_use': + return { + type: 'toolCall', + id: stringOrNull(block['id']) ?? `tool-${Date.now()}`, + name: stringOrNull(block['name']) ?? 'tool', + arguments: block['input'] ?? {}, + } + default: + return { type: 'data', value: block } + } +} diff --git a/src/workspaces/web-session/codex-app-server-transport.ts b/src/workspaces/web-session/codex-app-server-transport.ts new file mode 100644 index 000000000..786ba1754 --- /dev/null +++ b/src/workspaces/web-session/codex-app-server-transport.ts @@ -0,0 +1,418 @@ +/** + * Codex `app-server` over stdio (JSON-RPC 2.0 without the version header). + * + * Threads are Codex's sessions; a Web session resumes the recorded thread or + * starts a new one, drives turns with `turn/start`, and answers command, + * file-change, permission, and user-input requests from the browser. + */ +import { JsonRpcPeer } from './json-rpc.js' +import { + isJsonObject, + stringOrNull, + type JsonObject, + type WebContentPart, + type WebPermissionRequest, + type WebRequestOption, +} from './model.js' +import { TranscriptBuilder } from './transcript-builder.js' +import type { WebSessionTransport, WebTransportContext } from './transport.js' + +const THREAD_TIMEOUT_MS = 60_000 + +type Answer = (value: unknown) => void + +interface PendingApproval { + readonly answer: Answer + readonly respondWith: (optionId: string) => unknown + /** Payload sent when the turn ends or stops before the browser answers. */ + readonly cancelWith: unknown +} + +const DEFAULT_DECISIONS: readonly WebRequestOption[] = [ + { id: 'accept', label: 'Approve', tone: 'allow' }, + { id: 'acceptForSession', label: 'Approve for this session', tone: 'allow' }, + { id: 'decline', label: 'Decline', tone: 'deny' }, +] + +export class CodexAppServerTransport implements WebSessionTransport { + private readonly peer: JsonRpcPeer + private readonly builder: TranscriptBuilder + private readonly approvals = new Map() + private readonly deltaItems = new Set() + private threadId: string | null + private turnId: string | null = null + private requestSeq = 0 + + constructor(private readonly ctx: WebTransportContext) { + this.threadId = ctx.input.nativeSessionId ?? null + this.builder = new TranscriptBuilder(ctx.state) + this.peer = new JsonRpcPeer(ctx.channel, ctx.logger, { + onNotification: (method, params) => this.onNotification(method, params), + onRequest: (method, params, id) => this.onRequest(method, params, id), + }, { header: false }) + } + + async start(): Promise { + await this.peer.request('initialize', { + clientInfo: { name: 'openalice', title: 'OpenAlice', version: '1' }, + capabilities: {}, + }) + await this.peer.notify('initialized', {}) + // `AskForApproval` / `SandboxMode` are kebab-case wire enums (verified + // against `codex app-server generate-json-schema`, 0.153.x). + const options = { + cwd: this.ctx.input.cwd, + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + ...(this.ctx.input.model ? { model: this.ctx.input.model } : {}), + } + const result = this.threadId + ? await this.peer.request('thread/resume', { threadId: this.threadId, ...options }, THREAD_TIMEOUT_MS) + : await this.peer.request('thread/start', options, THREAD_TIMEOUT_MS) + const thread = isJsonObject(result) && isJsonObject(result['thread']) ? result['thread'] : null + const id = thread ? stringOrNull(thread['id']) : null + if (!id) throw new Error('Codex app-server returned no thread id') + this.threadId = id + if (thread && Array.isArray(thread['turns'])) this.replayHistory(thread['turns']) + this.ctx.state.setNativeSessionId(id) + this.ctx.state.setPhase('idle') + } + + async prompt(message: string): Promise { + const text = message.trim() + if (!text) throw new Error('prompt cannot be empty') + if (!this.threadId) throw new Error('Codex thread is not established') + if (this.turnId) throw new Error('Codex is still working on the previous prompt') + this.ctx.state.error = null + this.builder.user(text) + this.ctx.state.setPhase('working') + const result = await this.peer.request('turn/start', { + threadId: this.threadId, + input: [{ type: 'text', text }], + }).catch((error: Error) => { + this.ctx.state.error = error.message + this.ctx.state.setPhase('idle') + throw error + }) + const turn = isJsonObject(result) && isJsonObject(result['turn']) ? result['turn'] : null + this.turnId = (turn ? stringOrNull(turn['id']) : null) ?? 'pending' + } + + async abort(): Promise { + if (!this.threadId || !this.turnId || this.turnId === 'pending') return + await this.peer.request('turn/interrupt', { threadId: this.threadId, turnId: this.turnId }) + } + + async respond(requestId: string, optionId: string): Promise { + const request = this.ctx.state.requests.find((r) => r.id === requestId) + const pending = this.approvals.get(requestId) + if (!request || !pending) throw new Error(`no pending request ${requestId}`) + if (!request.options.some((option) => option.id === optionId)) { + throw new Error(`option ${optionId} is not offered by request ${requestId}`) + } + this.approvals.delete(requestId) + this.ctx.state.removeRequest(requestId) + pending.answer(pending.respondWith(optionId)) + } + + dispose(): void { + this.cancelApprovals() + this.peer.dispose(new Error('Codex session stopped')) + } + + private onNotification(method: string, params: unknown): void { + const p = isJsonObject(params) ? params : {} + switch (method) { + case 'turn/started': { + const turn = isJsonObject(p['turn']) ? p['turn'] : null + const id = turn ? stringOrNull(turn['id']) : null + if (id) this.turnId = id + this.ctx.state.setPhase('working') + break + } + case 'turn/completed': { + const turn = isJsonObject(p['turn']) ? p['turn'] : null + this.turnId = null + this.cancelApprovals() + this.builder.endTurn() + this.deltaItems.clear() + if (turn?.['status'] === 'failed') { + const error = isJsonObject(turn['error']) ? turn['error'] : null + this.ctx.state.error = stringOrNull(error?.['message']) ?? 'Codex turn failed' + } else if (turn?.['status'] === 'interrupted') { + this.builder.notice('Turn interrupted.') + } + this.ctx.state.setPhase('idle') + break + } + case 'item/started': + this.onItemStarted(p['item']) + break + case 'item/completed': + this.onItemCompleted(p['item']) + break + case 'item/agentMessage/delta': { + const itemId = stringOrNull(p['itemId']) + if (itemId) this.deltaItems.add(itemId) + this.builder.text(stringOrNull(p['delta']) ?? '') + break + } + case 'item/reasoning/summaryTextDelta': + case 'item/reasoning/textDelta': { + const itemId = stringOrNull(p['itemId']) + if (itemId) this.deltaItems.add(itemId) + this.builder.thinking(stringOrNull(p['delta']) ?? '') + break + } + case 'item/reasoning/summaryPartAdded': + this.builder.thinking('\n\n') + break + case 'error': { + const error = isJsonObject(p['error']) ? p['error'] : p + this.ctx.state.error = stringOrNull(error['message']) ?? 'Codex reported an error' + this.ctx.state.bump() + break + } + case 'warning': + this.ctx.logger.warn('web_session.codex_warning', { message: p['message'] }) + break + default: + break + } + } + + private onItemStarted(raw: unknown): void { + if (!isJsonObject(raw)) return + const id = stringOrNull(raw['id']) + if (!id) return + switch (raw['type']) { + case 'commandExecution': + this.builder.toolCall(id, 'shell', { command: raw['command'], cwd: raw['cwd'] }) + break + case 'fileChange': + this.builder.toolCall(id, 'apply_patch', { changes: raw['changes'] ?? [] }) + break + case 'mcpToolCall': + this.builder.toolCall(id, `${stringOrNull(raw['server']) ?? 'mcp'}/${stringOrNull(raw['tool']) ?? 'tool'}`, raw['arguments'] ?? {}) + break + case 'webSearch': + this.builder.toolCall(id, 'web_search', { query: raw['query'] }) + break + case 'imageGeneration': + this.builder.toolCall(id, 'image_generation', {}) + break + case 'contextCompaction': + this.ctx.state.setPhase('compacting') + break + default: + break + } + } + + private onItemCompleted(raw: unknown): void { + if (!isJsonObject(raw)) return + const id = stringOrNull(raw['id']) + if (!id) return + const streamed = this.deltaItems.has(id) + switch (raw['type']) { + case 'agentMessage': + if (!streamed) this.builder.text(stringOrNull(raw['text']) ?? '') + break + case 'reasoning': + if (!streamed) { + const summary = Array.isArray(raw['summary']) ? raw['summary'].map(String).join('\n\n') : '' + const content = Array.isArray(raw['content']) ? raw['content'].map(String).join('\n\n') : '' + this.builder.thinking(summary || content) + } + break + case 'commandExecution': { + const command = commandLabel(raw['command']) + const output = stringOrNull(raw['aggregatedOutput']) ?? '' + const exitCode = raw['exitCode'] + const text = [ + `$ ${command}`, + output.trimEnd(), + exitCode === undefined || exitCode === null ? '' : `(exit ${String(exitCode)})`, + ].filter(Boolean).join('\n') + this.builder.toolResult(id, text, raw['status'] === 'failed' || raw['status'] === 'declined') + break + } + case 'fileChange': { + const changes = Array.isArray(raw['changes']) ? raw['changes'] : [] + const text = changes.map((change) => { + if (!isJsonObject(change)) return '' + const diff = stringOrNull(change['diff']) + return `${stringOrNull(change['kind']) ?? 'edit'} ${stringOrNull(change['path']) ?? ''}${diff ? `\n\`\`\`diff\n${diff}\n\`\`\`` : ''}` + }).filter(Boolean).join('\n\n') + this.builder.toolResult(id, text || 'No file changes', raw['status'] === 'failed' || raw['status'] === 'declined') + break + } + case 'mcpToolCall': { + const failed = raw['status'] === 'failed' + const body: readonly WebContentPart[] = failed + ? [{ type: 'text', text: stringOrNull(isJsonObject(raw['error']) ? raw['error']['message'] : raw['error']) ?? 'MCP call failed' }] + : [{ type: 'data', value: raw['result'] ?? null }] + this.builder.toolResult(id, body, failed) + break + } + case 'webSearch': + this.builder.toolResult(id, [{ type: 'data', value: raw['results'] ?? raw['action'] ?? null }], false) + break + case 'imageGeneration': + this.builder.toolResult(id, stringOrNull(raw['savedPath']) ?? 'Image generated', raw['status'] === 'failed') + break + case 'contextCompaction': + this.builder.notice('Codex compacted the conversation context.') + if (this.turnId) this.ctx.state.setPhase('working') + break + default: + break + } + } + + private replayHistory(turns: readonly unknown[]): void { + for (const turn of turns) { + if (!isJsonObject(turn) || !Array.isArray(turn['items'])) continue + for (const item of turn['items']) { + if (!isJsonObject(item)) continue + if (item['type'] === 'userMessage') { + const content = Array.isArray(item['content']) ? item['content'] : [] + const text = content.map((part) => (isJsonObject(part) && typeof part['text'] === 'string' ? part['text'] : '')).filter(Boolean).join('\n') + this.builder.user(text) + continue + } + this.onItemStarted(item) + this.onItemCompleted(item) + } + this.builder.endTurn() + } + this.deltaItems.clear() + } + + private async onRequest(method: string, params: unknown, _id: string | number): Promise { + const p = isJsonObject(params) ? params : {} + switch (method) { + case 'item/commandExecution/requestApproval': + return this.approval(p, { + title: `Run ${commandLabel(p['command'])}`, + description: stringOrNull(p['reason']) ?? `Codex wants to run a command in ${stringOrNull(p['cwd']) ?? 'the workspace'}.`, + tool: { name: 'shell', input: { command: p['command'], cwd: p['cwd'] } }, + }) + case 'item/fileChange/requestApproval': + return this.approval(p, { + title: 'Apply file changes', + description: stringOrNull(p['reason']) ?? 'Codex wants to edit files in the workspace.', + tool: { name: 'apply_patch', input: { itemId: p['itemId'], grantRoot: p['grantRoot'] } }, + }) + case 'item/permissions/requestApproval': { + // Unlike command/file approvals, this request answers with the granted + // profile (`{ permissions, scope }`), not a decision enum. Granting + // echoes the requested profile; declining grants nothing. + const requested = isJsonObject(p['permissions']) ? p['permissions'] : {} + return this.enqueue({ + kind: 'permission', + title: 'Grant additional permissions', + description: stringOrNull(p['reason']) ?? 'Codex requests network or filesystem access beyond its sandbox.', + tool: { name: 'request_permissions', input: requested }, + options: [ + { id: 'grant', label: 'Grant for this turn', tone: 'allow' }, + { id: 'grantForSession', label: 'Grant for this session', tone: 'allow' }, + { id: 'decline', label: 'Decline', tone: 'deny' }, + ], + }, (optionId) => optionId === 'decline' + ? { permissions: {} } + : { permissions: requested, scope: optionId === 'grantForSession' ? 'session' : 'turn' }, + { permissions: {} }) + } + case 'item/tool/requestUserInput': + return this.userInput(p) + default: { + const error = new Error(`OpenAlice does not implement ${method}`) as Error & { code: number } + error.code = -32601 + throw error + } + } + } + + private approval( + params: JsonObject, + shape: { title: string; description: string; tool: WebPermissionRequest['tool'] }, + fallback: readonly WebRequestOption[] = DEFAULT_DECISIONS, + ): Promise { + const available = Array.isArray(params['availableDecisions']) + ? params['availableDecisions'].flatMap((decision): WebRequestOption[] => { + const id = typeof decision === 'string' ? decision : null + if (!id) return [] + return [{ id, label: decisionLabel(id), tone: id.startsWith('accept') || id.startsWith('apply') ? 'allow' : 'deny' }] + }) + : [] + const options = available.length > 0 ? available : fallback + return this.enqueue({ + kind: 'permission', + title: shape.title, + description: shape.description, + ...(shape.tool ? { tool: shape.tool } : {}), + options, + }, (optionId) => ({ decision: optionId }), { decision: 'cancel' }) + } + + private async userInput(params: JsonObject): Promise { + const questions = Array.isArray(params['questions']) ? params['questions'].filter(isJsonObject) : [] + const answers: Record = {} + for (const question of questions) { + const questionId = stringOrNull(question['id']) ?? `q${Object.keys(answers).length}` + const options = Array.isArray(question['options']) + ? question['options'].flatMap((option): WebRequestOption[] => { + if (!isJsonObject(option)) return [] + const label = stringOrNull(option['label']) + return label ? [{ id: label, label, tone: 'neutral' }] : [] + }) + : [] + const choice = await this.enqueue({ + kind: 'question', + title: stringOrNull(question['header']) ?? 'Codex has a question', + description: stringOrNull(question['question']) ?? '', + options: options.length > 0 ? options : [{ id: '', label: 'Continue without an answer', tone: 'neutral' }], + }, (optionId) => optionId, '') + answers[questionId] = { answers: typeof choice === 'string' && choice ? [choice] : [] } + } + return { answers } + } + + private enqueue( + request: Omit, + respondWith: (optionId: string) => unknown, + cancelWith: unknown, + ): Promise { + this.requestSeq += 1 + const id = `codex-${this.requestSeq}` + return new Promise((resolve) => { + this.approvals.set(id, { answer: resolve, respondWith, cancelWith }) + this.ctx.state.addRequest({ ...request, id, createdAt: Date.now() }) + }) + } + + private cancelApprovals(): void { + for (const [id, pending] of this.approvals) { + this.approvals.delete(id) + this.ctx.state.removeRequest(id) + pending.answer(pending.cancelWith) + } + } +} + +function commandLabel(command: unknown): string { + if (typeof command === 'string') return command + if (Array.isArray(command)) return command.map(String).join(' ') + return 'command' +} + +function decisionLabel(id: string): string { + switch (id) { + case 'accept': return 'Approve' + case 'acceptForSession': return 'Approve for this session' + case 'decline': return 'Decline' + case 'cancel': return 'Cancel' + default: return id + } +} diff --git a/src/workspaces/web-session/json-rpc.ts b/src/workspaces/web-session/json-rpc.ts new file mode 100644 index 000000000..0584ccb16 --- /dev/null +++ b/src/workspaces/web-session/json-rpc.ts @@ -0,0 +1,99 @@ +import type { Logger } from '../logger.js' +import { isJsonObject, type JsonObject } from './model.js' +import { PendingRequests, REQUEST_TIMEOUT_MS, type JsonlChannel } from './transport.js' + +export interface JsonRpcError extends Error { + readonly code: number + readonly data?: unknown +} + +export interface JsonRpcPeerHandlers { + readonly onNotification: (method: string, params: unknown) => void + /** Server-initiated request; the returned value becomes the JSON-RPC result. */ + readonly onRequest: (method: string, params: unknown, id: string | number) => Promise +} + +/** + * Minimal bidirectional JSON-RPC 2.0 over a JSONL channel. ACP and Codex + * app-server both use it; Codex omits the `jsonrpc` header on the wire, so the + * header is optional on input and configurable on output. + */ +export class JsonRpcPeer { + private readonly pending = new PendingRequests('web-rpc') + + constructor( + private readonly channel: JsonlChannel, + private readonly logger: Logger, + private readonly handlers: JsonRpcPeerHandlers, + private readonly options: { readonly header: boolean } = { header: true }, + ) { + channel.onMessage((message) => this.handle(message)) + } + + request(method: string, params: unknown = {}, timeoutMs: number | null = REQUEST_TIMEOUT_MS): Promise { + if (this.channel.closed) return Promise.reject(new Error(`${method}: process exited`)) + const id = this.pending.nextId() + const wait = this.pending.wait(id, method, timeoutMs) + void this.channel.send(this.frame({ id, method, params })).catch((error: Error) => this.pending.reject(id, error)) + return wait + } + + notify(method: string, params: unknown = {}): Promise { + if (this.channel.closed) return Promise.resolve() + return this.channel.send(this.frame({ method, params })).catch((error: Error) => { + this.logger.warn('web_session.notify_failed', { method, error }) + }) + } + + respond(id: string | number, result: unknown): Promise { + return this.channel.send(this.frame({ id, result })).catch((error: Error) => { + this.logger.warn('web_session.respond_failed', { id, error }) + }) + } + + respondError(id: string | number, code: number, message: string): Promise { + return this.channel.send(this.frame({ id, error: { code, message } })).catch((error: Error) => { + this.logger.warn('web_session.respond_failed', { id, error }) + }) + } + + dispose(error: Error): void { + this.pending.rejectAll(error) + } + + private frame(body: JsonObject): JsonObject { + return this.options.header ? { jsonrpc: '2.0', ...body } : body + } + + private handle(message: JsonObject): void { + const id = message['id'] + const hasId = typeof id === 'string' || typeof id === 'number' + if (typeof message['method'] === 'string') { + if (hasId) { + void this.handlers.onRequest(message['method'], message['params'], id) + .then((result) => this.respond(id, result ?? {})) + .catch((error: unknown) => { + const rpc = error as Partial + return this.respondError(id, typeof rpc.code === 'number' ? rpc.code : -32603, rpc.message ?? String(error)) + }) + } else { + this.handlers.onNotification(message['method'], message['params']) + } + return + } + if (!hasId) return + const key = String(id) + if ('error' in message && message['error'] !== null && message['error'] !== undefined) { + const error = message['error'] + const detail = isJsonObject(error) ? error : { message: String(error) } + const failure = new Error( + `${this.pending.label(key) ?? 'request'} failed: ${String(detail['message'] ?? 'unknown error')}`, + ) as JsonRpcError & { code: number; data?: unknown } + failure.code = typeof detail['code'] === 'number' ? detail['code'] : -32000 + failure.data = detail['data'] + this.pending.reject(key, failure) + return + } + this.pending.resolve(key, message['result']) + } +} diff --git a/src/workspaces/web-session/model.ts b/src/workspaces/web-session/model.ts new file mode 100644 index 000000000..07f9f80c2 --- /dev/null +++ b/src/workspaces/web-session/model.ts @@ -0,0 +1,105 @@ +/** + * Neutral live-conversation model shared by every Web transport. + * + * This is presentation-grade state for the browser, not a persisted store. + * Each runtime's own transcript (Pi JSONL, Claude project files, Codex + * rollouts, ACP agent storage) stays the durable conversation; Alice keeps one + * live process per Session record and projects its protocol into this shape. + * The shape borrows Pi's minimal message model on purpose: every supported + * wire maps onto it without inventing a new schema, and the browser presenter + * already groups it into turns. + */ + +import type { WebSessionWire } from '../cli-adapter.js' + +export type { WebSessionWire } + +export type WebContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'thinking'; readonly thinking: string } + | { readonly type: 'toolCall'; readonly id: string; readonly name: string; readonly arguments: unknown } + | { readonly type: 'data'; readonly value: unknown } + +export type WebConversationMessage = + | { readonly role: 'user'; readonly content: readonly WebContentPart[] | string; readonly timestamp?: number } + | { readonly role: 'assistant'; readonly content: readonly WebContentPart[]; readonly timestamp?: number } + | { + readonly role: 'toolResult' + readonly toolCallId: string + readonly toolName: string + readonly content: readonly WebContentPart[] | string + readonly isError: boolean + readonly timestamp?: number + } + | { readonly role: 'notice'; readonly text: string; readonly timestamp?: number } + /** A runtime record the transport could not classify; kept for the audit trail. */ + | { readonly role: 'unknown'; readonly value: unknown; readonly timestamp?: number } + +export type WebRequestOptionTone = 'allow' | 'deny' | 'neutral' + +export interface WebRequestOption { + readonly id: string + readonly label: string + readonly tone: WebRequestOptionTone +} + +/** + * A question the runtime cannot answer on its own: a tool permission, a file + * change approval, or a free-form user question. The browser presents the + * options verbatim and answers with one `optionId`. + */ +export interface WebPermissionRequest { + readonly id: string + readonly kind: 'permission' | 'question' + readonly title: string + readonly description?: string + readonly tool?: { readonly name: string; readonly input: unknown } + readonly options: readonly WebRequestOption[] + readonly createdAt: number +} + +export type WebSessionPhase = + | 'starting' + | 'idle' + | 'working' + | 'awaiting-input' + | 'compacting' + | 'retrying' + | 'stopped' + | 'failed' + +export interface WebSessionSnapshot { + readonly recordId: string + readonly wsId: string + readonly resumeId: string + readonly agent: string + readonly wire: WebSessionWire + /** Runtime-owned session identity once the transport has learned it. */ + readonly nativeSessionId: string | null + readonly pid: number | null + readonly startedAt: number + readonly phase: WebSessionPhase + readonly messages: readonly WebConversationMessage[] + /** Current cumulative in-flight assistant message; replaced, never accumulated. */ + readonly streamingMessage: WebConversationMessage | null + readonly requests: readonly WebPermissionRequest[] + readonly error: string | null + readonly stderrTail: string + readonly revision: number +} + +export type JsonObject = Record + +export function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +/** Plain text of a neutral message body, for presenters and summaries. */ +export function webContentText(content: readonly WebContentPart[] | string): string { + if (typeof content === 'string') return content + return content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\n') +} diff --git a/src/workspaces/web-session/pi-rpc-transport.ts b/src/workspaces/web-session/pi-rpc-transport.ts new file mode 100644 index 000000000..40aa922f3 --- /dev/null +++ b/src/workspaces/web-session/pi-rpc-transport.ts @@ -0,0 +1,221 @@ +/** + * Pi's documented `--mode rpc` (and Oh My Pi's protocol-compatible fork). + * + * Pi exposes a canonical message list through `get_messages`, so this + * transport refreshes that list at turn boundaries instead of accumulating + * deltas. `message_update` frames only replace the streaming message. + */ +import { + isJsonObject, + stringOrNull, + type JsonObject, + type WebContentPart, + type WebConversationMessage, +} from './model.js' +import { + PendingRequests, + type WebSessionTransport, + type WebTransportContext, +} from './transport.js' + +const MAX_PROMPT_CHARS = 16_000 + +export class PiRpcTransport implements WebSessionTransport { + private readonly pending = new PendingRequests('web-rpc') + private refreshTimer: ReturnType | null = null + private disposed = false + + constructor(private readonly ctx: WebTransportContext) { + ctx.channel.onMessage((event) => this.handleMessage(event)) + } + + async start(): Promise { + const rpcState = await this.refresh() + this.ctx.state.setPhase(phaseFromRpcState(rpcState)) + } + + async prompt(message: string): Promise { + const trimmed = message.trim() + if (!trimmed) throw new Error('prompt cannot be empty') + if (trimmed.length > MAX_PROMPT_CHARS) throw new Error(`prompt exceeds ${MAX_PROMPT_CHARS} characters`) + this.ctx.state.error = null + this.ctx.state.setPhase('working') + try { + await this.request('prompt', { message: trimmed }) + } catch (error) { + // Pi rejects prompts synchronously for missing credentials or a bad + // model; the turn never started, so fall back to idle and keep the + // reason visible in the snapshot as well as in the thrown error. + this.ctx.state.error = error instanceof Error ? error.message : String(error) + this.ctx.state.setPhase(this.ctx.channel.closed ? 'failed' : 'idle') + throw error + } + this.scheduleRefresh(50) + } + + async abort(): Promise { + await this.request('abort') + this.scheduleRefresh(0) + } + + async respond(requestId: string): Promise { + throw new Error(`Pi RPC has no pending request ${requestId}`) + } + + dispose(): void { + this.disposed = true + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = null + this.pending.rejectAll(new Error('Pi RPC stopped')) + } + + private async refresh(): Promise { + const [stateResponse, messageResponse] = await Promise.all([ + this.request('get_state'), + this.request('get_messages'), + ]) + const nextState = isJsonObject(stateResponse['data']) ? stateResponse['data'] : null + if (nextState) { + const id = stringOrNull(nextState['sessionId']) ?? stringOrNull(nextState['session_id']) + if (id) this.ctx.state.setNativeSessionId(id) + } + const data = messageResponse['data'] + if (isJsonObject(data) && Array.isArray(data['messages'])) { + this.ctx.state.replaceMessages(data['messages'].map(convertPiMessage)) + } + this.ctx.state.bump() + return nextState + } + + private scheduleRefresh(delayMs: number): void { + if (this.disposed) return + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null + void this.refresh().catch((error: Error) => { + if (!this.disposed && !this.ctx.channel.closed) this.ctx.state.fail(error.message) + }) + }, delayMs) + } + + private request(command: string, payload: JsonObject = {}): Promise { + if (this.ctx.channel.closed) return Promise.reject(new Error('Pi RPC process exited')) + const id = this.pending.nextId() + const wait = this.pending.wait(id, `Pi RPC ${command}`) + void this.ctx.channel.send({ id, type: command, ...payload }).catch((error: Error) => { + this.pending.reject(id, error) + }) + return wait + } + + private handleMessage(event: JsonObject): void { + if (event['type'] === 'response' && typeof event['id'] === 'string') { + const label = this.pending.label(event['id']) + if (!label) return + if (event['success'] === false) { + this.pending.reject(event['id'], new Error(stringOrNull(event['error']) ?? `${label} failed`)) + } else { + this.pending.resolve(event['id'], event) + } + return + } + this.handleEvent(event) + } + + private handleEvent(event: JsonObject): void { + const state = this.ctx.state + switch (event['type']) { + case 'ready': + // Oh My Pi announces protocol versions before serving commands. + break + case 'agent_start': + case 'turn_start': + state.phase = 'working' + break + case 'message_update': + state.streamingMessage = isJsonObject(event['message']) ? convertPiMessage(event['message']) : null + break + case 'message_end': + case 'tool_execution_end': + case 'queue_update': + this.scheduleRefresh(30) + break + case 'agent_settled': + state.phase = 'idle' + state.streamingMessage = null + this.scheduleRefresh(0) + break + case 'compaction_start': + state.phase = 'compacting' + break + case 'compaction_end': + state.phase = 'working' + this.scheduleRefresh(0) + break + case 'auto_retry_start': + state.phase = 'retrying' + break + case 'auto_retry_end': + state.phase = 'working' + break + case 'extension_error': + state.error = stringOrNull(event['error']) ?? 'Pi extension failed' + break + default: + break + } + state.bump() + } +} + +function phaseFromRpcState(state: JsonObject | null): 'compacting' | 'working' | 'idle' { + if (state?.['isCompacting'] === true) return 'compacting' + return state?.['isStreaming'] === true ? 'working' : 'idle' +} + +/** Pi's AgentMessage already matches the neutral shape; validate rather than translate. */ +export function convertPiMessage(value: unknown): WebConversationMessage { + if (!isJsonObject(value)) return { role: 'unknown', value } + const timestamp = typeof value['timestamp'] === 'number' ? { timestamp: value['timestamp'] } : {} + switch (value['role']) { + case 'user': + return { role: 'user', content: piContent(value['content']), ...timestamp } + case 'assistant': { + const content = piContent(value['content']) + return { role: 'assistant', content: typeof content === 'string' ? [{ type: 'text', text: content }] : content, ...timestamp } + } + case 'toolResult': + case 'tool': + return { + role: 'toolResult', + toolCallId: stringOrNull(value['toolCallId']) ?? '', + toolName: stringOrNull(value['toolName']) ?? 'tool', + content: piContent(value['content']), + isError: value['isError'] === true, + ...timestamp, + } + default: + return { role: 'unknown', value, ...timestamp } + } +} + +function piContent(value: unknown): readonly WebContentPart[] | string { + if (typeof value === 'string') return value + if (!Array.isArray(value)) return value === undefined ? '' : [{ type: 'data', value }] + return value.map((part): WebContentPart => { + if (!isJsonObject(part)) return { type: 'data', value: part } + if (part['type'] === 'text' && typeof part['text'] === 'string') return { type: 'text', text: part['text'] } + if (part['type'] === 'thinking') { + return { type: 'thinking', thinking: stringOrNull(part['thinking']) ?? stringOrNull(part['text']) ?? '' } + } + if (part['type'] === 'toolCall') { + return { + type: 'toolCall', + id: stringOrNull(part['id']) ?? stringOrNull(part['toolCallId']) ?? '', + name: stringOrNull(part['name']) ?? 'tool', + arguments: part['arguments'] ?? {}, + } + } + return { type: 'data', value: part } + }) +} diff --git a/src/workspaces/web-session/transcript-builder.ts b/src/workspaces/web-session/transcript-builder.ts new file mode 100644 index 000000000..115925bad --- /dev/null +++ b/src/workspaces/web-session/transcript-builder.ts @@ -0,0 +1,132 @@ +import type { WebContentPart, WebConversationMessage } from './model.js' +import type { WebSessionState } from './transport.js' + +/** + * Turns an event stream (deltas, tool starts, tool results) into the neutral + * message list. Protocols that expose a canonical message list (Pi RPC) do not + * need this; ACP, Claude stream-json, and Codex app-server all stream. + * + * The in-flight assistant message lives in `state.streamingMessage`. A tool + * result closes it (matching how Pi persists assistant/toolResult hops), and + * later text opens a fresh one so the browser groups the whole exchange into a + * single turn. + */ +export class TranscriptBuilder { + private readonly toolNames = new Map() + private readonly openToolCalls = new Set() + + constructor(private readonly state: WebSessionState) {} + + user(content: string | readonly WebContentPart[]): void { + this.state.commitStreaming() + this.state.append({ role: 'user', content, timestamp: Date.now() }) + } + + text(delta: string): void { + if (!delta) return + const parts = [...this.streamingParts()] + const last = parts[parts.length - 1] + if (last?.type === 'text') parts[parts.length - 1] = { type: 'text', text: last.text + delta } + else parts.push({ type: 'text', text: delta }) + this.setParts(parts) + } + + /** Replace the accumulated text with a full snapshot (cumulative protocols). */ + textSnapshot(text: string): void { + const parts: WebContentPart[] = this.streamingParts().filter((part) => part.type !== 'text') + if (text) parts.push({ type: 'text', text }) + this.setParts(parts) + } + + thinking(delta: string): void { + if (!delta) return + const parts = [...this.streamingParts()] + const last = parts[parts.length - 1] + if (last?.type === 'thinking') parts[parts.length - 1] = { type: 'thinking', thinking: last.thinking + delta } + else parts.push({ type: 'thinking', thinking: delta }) + this.setParts(parts) + } + + toolCall(id: string, name: string, args: unknown): void { + this.toolNames.set(id, name) + this.openToolCalls.add(id) + const parts = this.streamingParts().filter((part) => !(part.type === 'toolCall' && part.id === id)) + parts.push({ type: 'toolCall', id, name, arguments: args }) + this.setParts(parts) + } + + /** Record a call that the caller already rendered into the streaming message. */ + trackToolCall(id: string, name: string): void { + this.toolNames.set(id, name) + this.openToolCalls.add(id) + } + + /** Update an in-flight call whose name or input arrived after it started. */ + toolCallUpdate(id: string, update: { name?: string; args?: unknown }): void { + if (!this.openToolCalls.has(id)) { + this.toolCall(id, update.name ?? this.toolNames.get(id) ?? 'tool', update.args ?? {}) + return + } + if (update.name) this.toolNames.set(id, update.name) + const parts = this.streamingParts().map((part): WebContentPart => ( + part.type === 'toolCall' && part.id === id + ? { type: 'toolCall', id, name: update.name ?? part.name, arguments: update.args ?? part.arguments } + : part + )) + this.setParts(parts) + } + + toolResult(id: string, content: string | readonly WebContentPart[], isError: boolean, name?: string): void { + const toolName = name ?? this.toolNames.get(id) ?? 'tool' + if (!this.openToolCalls.has(id)) this.toolCall(id, toolName, {}) + this.openToolCalls.delete(id) + this.state.commitStreaming() + this.state.append({ role: 'toolResult', toolCallId: id, toolName, content, isError, timestamp: Date.now() }) + } + + notice(text: string): void { + this.state.commitStreaming() + this.state.append({ role: 'notice', text, timestamp: Date.now() }) + } + + hasOpenToolCall(id: string): boolean { + return this.openToolCalls.has(id) + } + + toolName(id: string): string | null { + return this.toolNames.get(id) ?? null + } + + endTurn(): void { + // Calls that never reported a result are closed as failed so the browser + // does not show a spinner forever after an interrupted turn. + for (const id of [...this.openToolCalls]) { + this.toolResult(id, 'No result: the turn ended before this call finished.', true) + } + this.state.commitStreaming() + } + + private streamingParts(): readonly WebContentPart[] { + const streaming = this.state.streamingMessage + return streaming?.role === 'assistant' ? streaming.content : [] + } + + private setParts(parts: readonly WebContentPart[]): void { + this.state.setStreaming({ role: 'assistant', content: parts, timestamp: Date.now() }) + } +} + +export function partsFromUnknownContent(value: unknown): readonly WebContentPart[] | string { + if (typeof value === 'string') return value + if (!Array.isArray(value)) return [{ type: 'data', value }] + return value.map((entry): WebContentPart => { + if (typeof entry === 'string') return { type: 'text', text: entry } + if (entry && typeof entry === 'object') { + const record = entry as Record + if (typeof record['text'] === 'string' && (record['type'] === 'text' || record['type'] === undefined)) { + return { type: 'text', text: record['text'] } + } + } + return { type: 'data', value: entry } + }) +} diff --git a/src/workspaces/web-session/transport.ts b/src/workspaces/web-session/transport.ts new file mode 100644 index 000000000..5f83e2816 --- /dev/null +++ b/src/workspaces/web-session/transport.ts @@ -0,0 +1,216 @@ +import type { Logger } from '../logger.js' +import { + type JsonObject, + type WebConversationMessage, + type WebPermissionRequest, + type WebSessionPhase, + type WebSessionWire, +} from './model.js' + +export interface StartWebSessionInput { + readonly recordId: string + readonly wsId: string + readonly resumeId: string + readonly agent: string + readonly wire: WebSessionWire + readonly command: readonly string[] + readonly cwd: string + readonly env: Readonly> + /** + * Native session to reopen. Absent means the transport must create a new + * runtime session and report its id through `WebSessionState.nativeSessionId`. + */ + readonly nativeSessionId?: string + /** Runtime-specific launch options the transport may forward in-band. */ + readonly model?: string + readonly reasoningEffort?: string +} + +/** + * Mutable live state one transport owns. Every mutation bumps `revision` so + * the browser's `?revision=` long-poll sees each change exactly once. + */ +export class WebSessionState { + phase: WebSessionPhase = 'starting' + nativeSessionId: string | null = null + messages: readonly WebConversationMessage[] = [] + streamingMessage: WebConversationMessage | null = null + requests: readonly WebPermissionRequest[] = [] + error: string | null = null + revision = 0 + + constructor(private readonly onChange: () => void = () => undefined) {} + + bump(): void { + this.revision += 1 + this.onChange() + } + + setPhase(phase: WebSessionPhase): void { + this.phase = phase + this.bump() + } + + setNativeSessionId(id: string | null): void { + if (id === this.nativeSessionId) return + this.nativeSessionId = id + this.bump() + } + + replaceMessages(messages: readonly WebConversationMessage[]): void { + this.messages = messages + this.bump() + } + + append(message: WebConversationMessage): void { + this.messages = [...this.messages, message] + this.bump() + } + + setStreaming(message: WebConversationMessage | null): void { + this.streamingMessage = message + this.bump() + } + + /** Move the in-flight assistant message into the committed transcript. */ + commitStreaming(): void { + const streaming = this.streamingMessage + if (!streaming) return + this.streamingMessage = null + if (streaming.role === 'assistant' && streaming.content.length === 0) { + this.bump() + return + } + this.messages = [...this.messages, streaming] + this.bump() + } + + addRequest(request: WebPermissionRequest): void { + this.requests = [...this.requests.filter((r) => r.id !== request.id), request] + this.phase = 'awaiting-input' + this.bump() + } + + removeRequest(requestId: string): WebPermissionRequest | null { + const request = this.requests.find((r) => r.id === requestId) ?? null + if (!request) return null + this.requests = this.requests.filter((r) => r.id !== requestId) + if (this.phase === 'awaiting-input' && this.requests.length === 0) this.phase = 'working' + this.bump() + return request + } + + clearRequests(): readonly WebPermissionRequest[] { + const cleared = this.requests + this.requests = [] + if (cleared.length > 0) this.bump() + return cleared + } + + fail(message: string): void { + this.error = message + this.phase = 'failed' + this.bump() + } +} + +/** + * Line-oriented JSON channel over the child's stdio. The host owns the + * process; transports only read complete JSON objects and write complete + * lines, so framing, decoding, and stderr capture stay in one place. + */ +export interface JsonlChannel { + send(value: unknown): Promise + onMessage(handler: (value: JsonObject) => void): void + readonly closed: boolean +} + +export interface WebTransportContext { + readonly input: StartWebSessionInput + readonly state: WebSessionState + readonly channel: JsonlChannel + readonly logger: Logger +} + +/** + * One runtime protocol projected onto the neutral live model. Transports are + * created after the process spawns and must be idempotent to a `stop` that + * arrives before `start` resolves. + */ +export interface WebSessionTransport { + /** Establish or reopen the native session; resolves when prompts may be sent. */ + start(): Promise + prompt(message: string): Promise + abort(): Promise + /** Answer one outstanding request with the chosen option id. */ + respond(requestId: string, optionId: string): Promise + /** Polite shutdown before the host closes stdin and signals the process. */ + dispose?(): void +} + +export type WebTransportFactory = (ctx: WebTransportContext) => WebSessionTransport + +export const REQUEST_TIMEOUT_MS = 15_000 + +interface Pending { + readonly label: string + readonly resolve: (value: T) => void + readonly reject: (error: Error) => void + readonly timer: ReturnType | null +} + +/** Request/response correlation shared by every protocol on this channel. */ +export class PendingRequests { + private readonly pending = new Map>() + private seq = 0 + + constructor(private readonly prefix: string) {} + + nextId(): string { + this.seq += 1 + return `${this.prefix}-${this.seq}` + } + + /** `timeoutMs: null` waits indefinitely (long-running turns). */ + wait(id: string, label: string, timeoutMs: number | null = REQUEST_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = timeoutMs === null + ? null + : setTimeout(() => { + this.pending.delete(id) + reject(new Error(`${label} timed out`)) + }, timeoutMs) + this.pending.set(id, { label, resolve, reject, timer }) + }) + } + + has(id: string): boolean { + return this.pending.has(id) + } + + label(id: string): string | null { + return this.pending.get(id)?.label ?? null + } + + resolve(id: string, value: T): boolean { + const pending = this.pending.get(id) + if (!pending) return false + if (pending.timer) clearTimeout(pending.timer) + this.pending.delete(id) + pending.resolve(value) + return true + } + + reject(id: string, error: Error): boolean { + const pending = this.pending.get(id) + if (!pending) return false + if (pending.timer) clearTimeout(pending.timer) + this.pending.delete(id) + pending.reject(error) + return true + } + + rejectAll(error: Error): void { + for (const [id] of this.pending) this.reject(id, error) + } +} diff --git a/src/workspaces/webpi-session-host.spec.ts b/src/workspaces/webpi-session-host.spec.ts deleted file mode 100644 index 1b1d82ff5..000000000 --- a/src/workspaces/webpi-session-host.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' - -import { describe, expect, it, vi } from 'vitest' - -import type { Logger } from './logger.js' -import { WebPiSessionHost, type StartWebPiInput } from './webpi-session-host.js' - -class FakeRpcProcess extends EventEmitter { - readonly pid = 4242 - readonly stdin = new PassThrough() - readonly stdout = new PassThrough() - readonly stderr = new PassThrough() - private messages: unknown[] = [] - private state: Record - - constructor(state: Record = {}) { - super() - this.state = { - sessionId: 'native-pi', thinkingLevel: 'medium', isStreaming: false, - isCompacting: false, steeringMode: 'all', followUpMode: 'one-at-a-time', - autoCompactionEnabled: true, messageCount: 0, pendingMessageCount: 0, - ...state, - } - this.stdin.setEncoding('utf8') - let buffer = '' - this.stdin.on('data', (chunk: string) => { - buffer += chunk - let nl = buffer.indexOf('\n') - while (nl >= 0) { - const line = buffer.slice(0, nl) - buffer = buffer.slice(nl + 1) - if (line) this.command(JSON.parse(line) as Record) - nl = buffer.indexOf('\n') - } - }) - queueMicrotask(() => this.emit('spawn')) - } - - kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { - queueMicrotask(() => this.emit('exit', 0, signal)) - return true - } - - event(value: Record): void { - this.line(value) - } - - private command(command: Record): void { - const id = command['id'] - const type = command['type'] - if (type === 'get_state') { - this.line({ - type: 'response', id, command: type, success: true, - data: { ...this.state, messageCount: this.messages.length }, - }) - return - } - if (type === 'get_messages') { - this.line({ type: 'response', id, command: type, success: true, data: { messages: this.messages } }) - return - } - if (type === 'prompt') { - const user = { role: 'user', content: command['message'] } - const partialA = { role: 'assistant', content: [{ type: 'text', text: 'hel' }] } - const partialB = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } - const assistant = partialB - this.messages = [...this.messages, user, assistant] - this.line({ type: 'response', id, command: type, success: true }) - this.line({ type: 'agent_start' }) - this.line({ type: 'message_update', message: partialA }) - this.line({ type: 'message_update', message: partialB }) - this.line({ type: 'message_end', message: assistant }) - this.line({ type: 'agent_settled' }) - return - } - if (type === 'abort') { - this.line({ type: 'response', id, command: type, success: true }) - } - } - - private line(value: unknown): void { - this.stdout.write(`${JSON.stringify(value)}\n`) - } -} - -const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} as unknown as Logger - -const input: StartWebPiInput = { - recordId: 'pi-record', - wsId: 'chat-ws', - resumeId: 'resume-pi', - command: ['pi', '--session-id', 'native-pi', '--mode', 'rpc'], - cwd: '/tmp/workspace', - env: {}, -} - -describe('WebPiSessionHost', () => { - it('opens one RPC view and exposes Pi messages without accumulating update frames', async () => { - const host = new WebPiSessionHost(logger, {}, () => new FakeRpcProcess() as never) - const started = await host.start(input) - expect(started.phase).toBe('idle') - expect(started.messages).toEqual([]) - - await host.prompt(input.recordId, 'hi') - await new Promise((resolve) => setTimeout(resolve, 80)) - const snapshot = host.get(input.recordId) - expect(snapshot?.phase).toBe('idle') - expect(snapshot?.messages).toEqual([ - { role: 'user', content: 'hi' }, - { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }, - ]) - expect(snapshot?.streamingMessage).toBeNull() - }) - - it('deduplicates repeated opens and stops intentionally', async () => { - let spawns = 0 - const onExit = vi.fn() - const host = new WebPiSessionHost(logger, { onExit }, () => { - spawns += 1 - return new FakeRpcProcess() as never - }) - await host.start(input) - await host.start(input) - expect(spawns).toBe(1) - expect(await host.stop(input.recordId, 'switch to TUI')).toBe(true) - expect(host.has(input.recordId)).toBe(false) - expect(onExit).toHaveBeenCalledWith(input.recordId, expect.objectContaining({ intentional: true })) - }) - - it('restores Pi compaction state reported at startup', async () => { - const rpc = new FakeRpcProcess({ isCompacting: true }) - const host = new WebPiSessionHost(logger, {}, () => rpc as never) - - expect((await host.start(input)).phase).toBe('compacting') - }) - - it('follows Pi compaction events until the agent settles', async () => { - const rpc = new FakeRpcProcess() - const host = new WebPiSessionHost(logger, {}, () => rpc as never) - await host.start(input) - - rpc.event({ type: 'compaction_start', reason: 'threshold' }) - expect(host.get(input.recordId)?.phase).toBe('compacting') - - rpc.event({ type: 'compaction_end', reason: 'threshold', willRetry: false }) - expect(host.get(input.recordId)?.phase).toBe('working') - - rpc.event({ type: 'agent_settled' }) - expect(host.get(input.recordId)?.phase).toBe('idle') - }) -}) diff --git a/src/workspaces/webpi-session-host.ts b/src/workspaces/webpi-session-host.ts deleted file mode 100644 index 24b622b9a..000000000 --- a/src/workspaces/webpi-session-host.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * WebPi — Pi's documented RPC mode supervised as a second interactive surface. - * - * This deliberately does NOT translate Pi messages into an OpenAlice message - * model. The browser receives Pi's own AgentMessage objects plus the current - * cumulative streaming message. Pi's JSONL session remains the only durable - * conversation store; this host owns only one live RPC process per record. - */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { StringDecoder } from 'node:string_decoder' - -import type { Logger } from './logger.js' -import { resolveLaunchCommand } from './win-command.js' - -const REQUEST_TIMEOUT_MS = 15_000 -const STDERR_MAX_CHARS = 64 * 1024 - -type JsonObject = Record - -interface RpcProcess { - readonly pid?: number - readonly stdin: ChildProcessWithoutNullStreams['stdin'] - readonly stdout: ChildProcessWithoutNullStreams['stdout'] - readonly stderr: ChildProcessWithoutNullStreams['stderr'] - once(event: 'spawn', listener: () => void): this - once(event: 'error', listener: (error: Error) => void): this - once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this - on(event: 'error', listener: (error: Error) => void): this - kill(signal?: NodeJS.Signals): boolean -} - -export interface WebPiSnapshot { - readonly recordId: string - readonly wsId: string - readonly resumeId: string - readonly pid: number | null - readonly startedAt: number - readonly phase: 'starting' | 'idle' | 'working' | 'compacting' | 'retrying' | 'stopped' | 'failed' - readonly state: JsonObject | null - readonly messages: readonly unknown[] - /** Pi's current cumulative assistant message; replaced, never accumulated. */ - readonly streamingMessage: unknown | null - readonly error: string | null - readonly stderrTail: string - readonly revision: number -} - -export interface StartWebPiInput { - readonly recordId: string - readonly wsId: string - readonly resumeId: string - readonly command: readonly string[] - readonly cwd: string - readonly env: Readonly> -} - -interface PendingRequest { - readonly command: string - readonly resolve: (response: JsonObject) => void - readonly reject: (error: Error) => void - readonly timer: ReturnType -} - -interface HostCallbacks { - readonly onExit?: (recordId: string, reason: { code: number | null; signal: NodeJS.Signals | null; intentional: boolean }) => void -} - -type SpawnProcess = (input: StartWebPiInput) => RpcProcess - -export class WebPiSessionHost { - private readonly sessions = new Map() - - constructor( - private readonly logger: Logger, - private readonly callbacks: HostCallbacks = {}, - private readonly spawnProcess: SpawnProcess = defaultSpawnProcess, - ) {} - - has(recordId: string): boolean { - return this.sessions.has(recordId) - } - - get(recordId: string): WebPiSnapshot | null { - return this.sessions.get(recordId)?.snapshot() ?? null - } - - async start(input: StartWebPiInput): Promise { - const existing = this.sessions.get(input.recordId) - if (existing) return existing.snapshot() - const session = new LiveWebPiSession( - input, - this.spawnProcess(input), - this.logger.child({ scope: 'webpi', wsId: input.wsId, recordId: input.recordId }), - (reason) => { - if (this.sessions.get(input.recordId) === session) this.sessions.delete(input.recordId) - this.callbacks.onExit?.(input.recordId, reason) - }, - ) - this.sessions.set(input.recordId, session) - try { - await session.start() - return session.snapshot() - } catch (error) { - this.sessions.delete(input.recordId) - await session.stop('startup failed').catch(() => undefined) - throw error - } - } - - async prompt(recordId: string, message: string): Promise { - const session = this.require(recordId) - await session.prompt(message) - return session.snapshot() - } - - async abort(recordId: string): Promise { - const session = this.require(recordId) - await session.abort() - return session.snapshot() - } - - async stop(recordId: string, reason = 'stopped'): Promise { - const session = this.sessions.get(recordId) - if (!session) return false - this.sessions.delete(recordId) - await session.stop(reason) - return true - } - - async stopAll(reason = 'host disposed'): Promise { - const sessions = Array.from(this.sessions.values()) - this.sessions.clear() - await Promise.allSettled(sessions.map((session) => session.stop(reason))) - } - - private require(recordId: string): LiveWebPiSession { - const session = this.sessions.get(recordId) - if (!session) throw new Error(`WebPi session is not running: ${recordId}`) - return session - } -} - -class LiveWebPiSession { - private readonly pending = new Map() - private requestSeq = 0 - private decoder = new StringDecoder('utf8') - private stdoutBuffer = '' - private stderrTail = '' - private phase: WebPiSnapshot['phase'] = 'starting' - private state: JsonObject | null = null - private messages: readonly unknown[] = [] - private streamingMessage: unknown | null = null - private error: string | null = null - private revision = 0 - private intentionalStop = false - private refreshTimer: ReturnType | null = null - private exited = false - private readonly startedAt = Date.now() - - constructor( - private readonly input: StartWebPiInput, - private readonly child: RpcProcess, - private readonly logger: Logger, - private readonly onExit: (reason: { code: number | null; signal: NodeJS.Signals | null; intentional: boolean }) => void, - ) {} - - async start(): Promise { - this.child.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk)) - this.child.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk)) - this.child.on('error', (error) => this.fail(error)) - this.child.once('exit', (code, signal) => this.handleExit(code, signal)) - await new Promise((resolve, reject) => { - this.child.once('spawn', resolve) - this.child.once('error', reject) - }) - this.logger.info('webpi.started', { pid: this.child.pid ?? null, command: this.input.command }) - await this.refresh() - this.phase = phaseFromRpcState(this.state) - this.bump() - } - - snapshot(): WebPiSnapshot { - return { - recordId: this.input.recordId, - wsId: this.input.wsId, - resumeId: this.input.resumeId, - pid: this.exited ? null : this.child.pid ?? null, - startedAt: this.startedAt, - phase: this.phase, - state: this.state, - messages: this.messages, - streamingMessage: this.streamingMessage, - error: this.error, - stderrTail: this.stderrTail, - revision: this.revision, - } - } - - async prompt(message: string): Promise { - const trimmed = message.trim() - if (!trimmed) throw new Error('WebPi prompt cannot be empty') - if (trimmed.length > 16_000) throw new Error('WebPi prompt exceeds 16000 characters') - this.phase = 'working' - this.error = null - this.bump() - await this.request('prompt', { message: trimmed }) - this.scheduleRefresh(50) - } - - async abort(): Promise { - await this.request('abort') - this.scheduleRefresh(0) - } - - async stop(reason: string): Promise { - if (this.exited) return - this.intentionalStop = true - this.logger.info('webpi.stopping', { reason }) - this.child.stdin.end() - this.child.kill('SIGTERM') - await Promise.race([ - new Promise((resolve) => this.child.once('exit', () => resolve())), - new Promise((resolve) => setTimeout(resolve, 2_000)), - ]) - if (!this.exited) this.child.kill('SIGKILL') - } - - private async refresh(): Promise { - const [stateResponse, messageResponse] = await Promise.all([ - this.request('get_state'), - this.request('get_messages'), - ]) - const nextState = stateResponse['data'] - if (isObject(nextState)) this.state = nextState - const data = messageResponse['data'] - if (isObject(data) && Array.isArray(data['messages'])) this.messages = data['messages'] - this.bump() - } - - private scheduleRefresh(delayMs: number): void { - if (this.refreshTimer) clearTimeout(this.refreshTimer) - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null - void this.refresh().catch((error) => this.fail(error)) - }, delayMs) - } - - private request(command: string, payload: JsonObject = {}): Promise { - if (this.exited) return Promise.reject(new Error('WebPi process exited')) - const id = `webpi-${++this.requestSeq}` - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id) - reject(new Error(`WebPi RPC ${command} timed out`)) - }, REQUEST_TIMEOUT_MS) - this.pending.set(id, { command, resolve, reject, timer }) - const line = `${JSON.stringify({ id, type: command, ...payload })}\n` - this.child.stdin.write(line, (error) => { - if (!error) return - const pending = this.pending.get(id) - if (!pending) return - clearTimeout(pending.timer) - this.pending.delete(id) - pending.reject(error) - }) - }) - } - - private onStdout(chunk: Buffer): void { - this.stdoutBuffer += this.decoder.write(chunk) - let newline = this.stdoutBuffer.indexOf('\n') - while (newline >= 0) { - const line = this.stdoutBuffer.slice(0, newline) - this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1) - this.handleLine(line) - newline = this.stdoutBuffer.indexOf('\n') - } - } - - private handleLine(raw: string): void { - const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw - if (!line) return - let event: JsonObject - try { - const parsed: unknown = JSON.parse(line) - if (!isObject(parsed)) return - event = parsed - } catch (error) { - this.logger.warn('webpi.invalid_json', { error, line: line.slice(0, 500) }) - return - } - if (event['type'] === 'response' && typeof event['id'] === 'string') { - const pending = this.pending.get(event['id']) - if (!pending) return - clearTimeout(pending.timer) - this.pending.delete(event['id']) - if (event['success'] === false) { - pending.reject(new Error(typeof event['error'] === 'string' ? event['error'] : `WebPi RPC ${pending.command} failed`)) - } else { - pending.resolve(event) - } - return - } - this.handleEvent(event) - } - - private handleEvent(event: JsonObject): void { - switch (event['type']) { - case 'agent_start': - case 'turn_start': - this.phase = 'working' - break - case 'message_update': - this.streamingMessage = event['message'] ?? null - break - case 'message_end': - case 'tool_execution_end': - case 'queue_update': - this.scheduleRefresh(30) - break - case 'agent_settled': - this.phase = 'idle' - this.streamingMessage = null - this.scheduleRefresh(0) - break - case 'compaction_start': - this.phase = 'compacting' - break - case 'compaction_end': - this.phase = 'working' - this.scheduleRefresh(0) - break - case 'auto_retry_start': - this.phase = 'retrying' - break - case 'auto_retry_end': - this.phase = 'working' - break - case 'extension_error': - this.error = typeof event['error'] === 'string' ? event['error'] : 'Pi extension failed' - break - default: - break - } - this.bump() - } - - private onStderr(chunk: Buffer): void { - this.stderrTail = `${this.stderrTail}${chunk.toString('utf8')}`.slice(-STDERR_MAX_CHARS) - this.bump() - } - - private fail(error: Error): void { - this.error = error.message - this.phase = 'failed' - this.bump() - this.logger.error('webpi.failed', { error }) - } - - private handleExit(code: number | null, signal: NodeJS.Signals | null): void { - if (this.exited) return - this.exited = true - if (this.refreshTimer) clearTimeout(this.refreshTimer) - this.refreshTimer = null - this.phase = this.intentionalStop ? 'stopped' : 'failed' - if (!this.intentionalStop && !this.error) { - this.error = `Pi RPC exited (code=${String(code)}, signal=${String(signal)})` - } - for (const pending of this.pending.values()) { - clearTimeout(pending.timer) - pending.reject(new Error(this.error ?? 'WebPi stopped')) - } - this.pending.clear() - this.bump() - this.logger.info('webpi.exited', { code, signal, intentional: this.intentionalStop }) - this.onExit({ code, signal, intentional: this.intentionalStop }) - } - - private bump(): void { - this.revision += 1 - } -} - -function defaultSpawnProcess(input: StartWebPiInput): RpcProcess { - const resolved = resolveLaunchCommand(input.command, { env: input.env, cwd: input.cwd }) - const [file, ...args] = resolved.argv - if (!file) throw new Error('WebPi command is empty') - return spawn(file, args, { - cwd: input.cwd, - env: { ...input.env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }) -} - -function phaseFromRpcState(state: JsonObject | null): WebPiSnapshot['phase'] { - if (state?.['isCompacting'] === true) return 'compacting' - return state?.['isStreaming'] === true ? 'working' : 'idle' -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/workspaces/workspace-lifecycle.ts b/src/workspaces/workspace-lifecycle.ts index 1204b21a9..5f6ae0e99 100644 --- a/src/workspaces/workspace-lifecycle.ts +++ b/src/workspaces/workspace-lifecycle.ts @@ -9,7 +9,7 @@ import type { Logger } from './logger.js' import type { ResumeRegistry } from './resume-registry.js' import type { ScrollbackStore } from './scrollback-store.js' import type { SessionPool } from './session-pool.js' -import type { WebPiSessionHost } from './webpi-session-host.js' +import type { WebSessionHost } from './web-session-host.js' import type { SessionRecord, SessionRegistry } from './session-registry.js' import { catalogRecordToMeta, @@ -47,7 +47,7 @@ export interface WorkspaceLifecycleManagerDeps { scrollbackStore: ScrollbackStore headlessTasks: HeadlessTaskRegistry pool: SessionPool - webPi?: WebPiSessionHost + web?: WebSessionHost /** Includes synchronous wait:true/probe-style runs not yet in HeadlessTaskRegistry. */ isWorkspaceHeadlessActive?: (workspaceId: string) => boolean /** Serializes checkout-wide mutations with Template Upgrade and Merge. */ @@ -398,7 +398,7 @@ export class WorkspaceLifecycleManager { if (dump.length > 0) scrollbackFile = await this.deps.scrollbackStore.dump(wsId, record.id, dump) } this.deps.pool.disposeToken(record.id, 'workspace offboarded') - await this.deps.webPi?.stop(record.id, 'workspace offboarded') + await this.deps.web?.stop(record.id, 'workspace offboarded') if (record.state === 'running' || scrollbackFile) { await this.deps.sessionRegistry.update(wsId, record.id, { state: 'paused', diff --git a/ui/src/components/conversation/ConversationRequestCard.tsx b/ui/src/components/conversation/ConversationRequestCard.tsx new file mode 100644 index 000000000..cda2fc5fa --- /dev/null +++ b/ui/src/components/conversation/ConversationRequestCard.tsx @@ -0,0 +1,97 @@ +import { useState, type ReactElement } from 'react' +import { CircleHelp, LoaderCircle, ShieldQuestion } from 'lucide-react' +import { Button } from '../ui/button' + +export interface ConversationRequestOption { + readonly id: string + readonly label: string + readonly tone: 'allow' | 'deny' | 'neutral' +} + +export interface ConversationRequest { + readonly id: string + readonly kind: 'permission' | 'question' + readonly title: string + readonly description?: string + /** Pre-rendered tool detail; the adapter decides how the input is summarized. */ + readonly tool?: { readonly name: string; readonly summary: string | null; readonly input: string } + readonly options: readonly ConversationRequestOption[] +} + +export interface ConversationRequestCardProps { + readonly request: ConversationRequest + /** How many further requests wait behind this one. */ + readonly queued: number + readonly respond: (requestId: string, optionId: string) => Promise +} + +/** + * The runtime stopped to ask something. Rendered pinned above the composer + * rather than inline so the question is never scrolled away while the answer + * is the only way forward; the transcript stays a record, the card is the + * live decision. Options are shown verbatim from the runtime. + */ +export function ConversationRequestCard({ request, queued, respond }: ConversationRequestCardProps): ReactElement { + const [pendingOption, setPendingOption] = useState(null) + const [error, setError] = useState(null) + const permission = request.kind === 'permission' + + async function choose(optionId: string) { + if (pendingOption) return + setPendingOption(optionId) + setError(null) + try { await respond(request.id, optionId) } + catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } + finally { setPendingOption(null) } + } + + return ( +
+
+ +
+ + {permission ? 'Permission needed' : 'Question from the agent'} + {queued > 0 && +{queued} more} + +

{request.title}

+
+
+ {request.description &&

{request.description}

} + {request.tool && ( +
+ + {request.tool.name} + {request.tool.summary && {request.tool.summary}} + +
{request.tool.input}
+
+ )} +
+ {request.options.map((option) => ( + + ))} +
+ {error &&

{error}

} +
+ ) +} diff --git a/ui/src/components/conversation/ConversationTranscript.tsx b/ui/src/components/conversation/ConversationTranscript.tsx index c509dd168..b177d68f5 100644 --- a/ui/src/components/conversation/ConversationTranscript.tsx +++ b/ui/src/components/conversation/ConversationTranscript.tsx @@ -17,6 +17,13 @@ export function ConversationTranscriptItem({ ) } + if (item.kind === 'notice') { + return ( + + ) + } if (item.kind === 'unknown') { return (
diff --git a/ui/src/components/conversation/conversation.css b/ui/src/components/conversation/conversation.css index 4c841f4ee..03534d485 100644 --- a/ui/src/components/conversation/conversation.css +++ b/ui/src/components/conversation/conversation.css @@ -145,6 +145,32 @@ .conversation-reasoning-notes { display: grid; gap: 8px; margin: 0 0 7px; padding: 8px 10px; border-left: 2px solid color-mix(in srgb, var(--primary) 24%, var(--border)); color: color-mix(in srgb, var(--foreground) 82%, var(--muted-foreground)); font-size: 10px; } .conversation-reasoning pre { max-height: 220px; margin: 0 0 7px; overflow: auto; white-space: pre-wrap; font: 10px/1.5 var(--font-mono, monospace); } +.conversation-message.is-notice { justify-content: center; margin: 0 0 18px; } +.conversation-notice-body { max-width: 100%; padding: 4px 10px; border-radius: 999px; color: var(--muted-foreground); background: color-mix(in srgb, var(--secondary) 70%, transparent); font-size: 11px; line-height: 1.5; text-align: center; overflow-wrap: anywhere; } + +/* A runtime request is the live decision; the transcript stays a record. */ +.conversation-request { display: grid; gap: 9px; margin: 0 0 10px; padding: 12px 13px; border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--primary) 6%, var(--secondary)); box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 6%, transparent); animation: conversation-detail-reveal 150ms ease-out both; } +.conversation-request.is-question { border-color: color-mix(in srgb, var(--foreground) 18%, var(--border)); background: color-mix(in srgb, var(--secondary) 82%, transparent); } +.conversation-request-header { display: flex; align-items: flex-start; gap: 9px; min-width: 0; } +.conversation-request-icon { width: 26px; height: 26px; flex: 0 0 26px; display: grid; place-items: center; border-radius: 8px; color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); } +.conversation-request.is-question .conversation-request-icon { color: var(--foreground); background: color-mix(in srgb, var(--border) 60%, transparent); } +.conversation-request-heading { display: grid; gap: 1px; min-width: 0; } +.conversation-request-kicker { display: inline-flex; align-items: center; gap: 6px; color: var(--muted-foreground); font-size: 10px; font-weight: 650; letter-spacing: .02em; text-transform: uppercase; } +.conversation-request-queue { padding: 1px 6px; border-radius: 999px; color: var(--muted-foreground); background: color-mix(in srgb, var(--border) 70%, transparent); font-weight: 600; letter-spacing: 0; text-transform: none; } +.conversation-request-title { margin: 0; color: var(--foreground); font-size: 13px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.conversation-request-description { margin: 0; color: color-mix(in srgb, var(--foreground) 82%, var(--muted-foreground)); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.conversation-request-tool { min-width: 0; border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--background) 70%, transparent); font-size: 11px; } +.conversation-request-tool > summary { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 9px; cursor: pointer; user-select: none; color: var(--muted-foreground); } +.conversation-request-tool > summary code { flex: 0 0 auto; color: var(--foreground); font: 600 11px/1.4 var(--font-mono, monospace); } +.conversation-request-tool-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 10px/1.4 var(--font-mono, monospace); } +.conversation-request-tool > pre { max-height: 200px; margin: 0; padding: 0 9px 8px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: color-mix(in srgb, var(--foreground) 88%, var(--muted-foreground)); font: 10px/1.5 var(--font-mono, monospace); } +.conversation-request-actions { display: flex; flex-wrap: wrap; gap: 7px; } +.conversation-request-option[data-slot="button"] { gap: 6px; } +.conversation-request-option.is-deny[data-slot="button"] { color: var(--destructive); border-color: color-mix(in srgb, var(--destructive) 40%, var(--border)); } +.conversation-request-option.is-deny[data-slot="button"]:hover { background: color-mix(in srgb, var(--destructive) 8%, transparent); } +.conversation-request-error { margin: 0; color: var(--destructive); font-size: 11px; } +.conversation-request-tool summary:focus-visible { outline: 2px solid var(--oa-focus-ring); outline-offset: -2px; } + .conversation-error { display: grid; gap: 7px; margin: 20px 0; padding: 13px; border: 1px solid color-mix(in srgb, var(--destructive) 50%, var(--border)); border-radius: 10px; color: var(--destructive); font-size: 12px; } .conversation-error button { width: fit-content; color: var(--foreground); text-decoration: underline; } .conversation-jump-latest { position: absolute; right: max(18px, calc((100% - 736px) / 2)); bottom: 82px; z-index: 2; padding: 6px 10px; border: 1px solid color-mix(in srgb, var(--primary) 42%, var(--border)); border-radius: 999px; color: var(--foreground); background: color-mix(in srgb, var(--secondary) 92%, transparent); box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow-color) 16%, transparent); font-size: 10px; font-weight: 650; } @@ -175,7 +201,8 @@ @media (prefers-reduced-motion: reduce) { .conversation-activity[open] > .conversation-activity-body, - .conversation-tool-step[open] > .conversation-step-detail { animation: none; } + .conversation-tool-step[open] > .conversation-step-detail, + .conversation-request { animation: none; } } /* Conversation shares the viewport with the global rail and Workspace sidebar. Its diff --git a/ui/src/components/conversation/types.ts b/ui/src/components/conversation/types.ts index 082129db7..74ebcffd3 100644 --- a/ui/src/components/conversation/types.ts +++ b/ui/src/components/conversation/types.ts @@ -26,4 +26,6 @@ export interface ConversationActivity { export type ConversationItem = | { readonly kind: 'user'; readonly key: string; readonly content: ConversationContent } | { readonly kind: 'assistant-turn'; readonly key: string; readonly progress: readonly string[]; readonly final: string | null; readonly activity: ConversationActivity | null } + /** A runtime/system remark that is neither party speaking: mode changes, restarts, aborted turns. */ + | { readonly kind: 'notice'; readonly key: string; readonly text: string } | { readonly kind: 'unknown'; readonly key: string; readonly content: ConversationContent } diff --git a/ui/src/components/workspace/ChatWorkspaceSection.auto-quant.spec.tsx b/ui/src/components/workspace/ChatWorkspaceSection.auto-quant.spec.tsx index 927b2be64..894a184f0 100644 --- a/ui/src/components/workspace/ChatWorkspaceSection.auto-quant.spec.tsx +++ b/ui/src/components/workspace/ChatWorkspaceSection.auto-quant.spec.tsx @@ -114,7 +114,7 @@ function context(): WorkspacesContextValue { quickChat: vi.fn(async () => session.id), pauseSession: actions.pauseSession, resumeSession: actions.resumeSession, - openWebPiSession: vi.fn(async () => undefined), + openWebSession: vi.fn(async () => undefined), requestDeleteSession: actions.requestDeleteSession, setSessionPresence: actions.setSessionPresence, setSessionDisplayName: actions.setSessionDisplayName, diff --git a/ui/src/components/workspace/ChatWorkspaceSection.spec.tsx b/ui/src/components/workspace/ChatWorkspaceSection.spec.tsx index 2f63757fe..2139b5286 100644 --- a/ui/src/components/workspace/ChatWorkspaceSection.spec.tsx +++ b/ui/src/components/workspace/ChatWorkspaceSection.spec.tsx @@ -19,7 +19,7 @@ const actions = vi.hoisted(() => ({ openOrFocus: vi.fn(), pauseSession: vi.fn(async () => undefined), resumeSession: vi.fn(async () => undefined), - openWebPiSession: vi.fn(async () => undefined), + openWebSession: vi.fn(async () => undefined), openHeadlessRun: vi.fn(async () => undefined), requestDeleteSession: vi.fn(), setSessionPresence: vi.fn(async () => undefined), @@ -135,7 +135,7 @@ function workspaceContext( quickChat: vi.fn(async () => 'session-1'), pauseSession: actions.pauseSession, resumeSession: actions.resumeSession, - openWebPiSession: actions.openWebPiSession, + openWebSession: actions.openWebSession, requestDeleteSession: actions.requestDeleteSession, setSessionPresence: actions.setSessionPresence, setSessionDisplayName: actions.setSessionDisplayName, @@ -203,7 +203,7 @@ describe('ChatWorkspaceSection actions', () => { expect(screen.queryByRole('button', { name: 'Other office conversation' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Conversation 1' })) expect(actions.resumeSession).toHaveBeenCalledWith(current.id, current.sessions[0].id, 'chat') - expect(actions.openWebPiSession).not.toHaveBeenCalled() + expect(actions.openWebSession).not.toHaveBeenCalled() expect(screen.getByRole('button', { name: 'View all 9 conversations' })).toBeTruthy() }) @@ -764,7 +764,7 @@ describe('ChatWorkspaceSection actions', () => { expect(onNavigate).toHaveBeenCalledTimes(1) fireEvent.click(managerUi.getByRole('button', { name: 'Resume Coordinate owners' })) - expect(actions.openWebPiSession).toHaveBeenCalledWith(MANAGER_WORKSPACE_ID, 'manager-pi') + expect(actions.openWebSession).toHaveBeenCalledWith(MANAGER_WORKSPACE_ID, 'manager-pi') expect(onNavigate).toHaveBeenCalledTimes(2) const pausedRow = pausedSession.parentElement diff --git a/ui/src/components/workspace/ChatWorkspaceSection.tsx b/ui/src/components/workspace/ChatWorkspaceSection.tsx index 43ea22cb8..1d50cf16a 100644 --- a/ui/src/components/workspace/ChatWorkspaceSection.tsx +++ b/ui/src/components/workspace/ChatWorkspaceSection.tsx @@ -278,7 +278,7 @@ export function ChatWorkspaceSection({ if (row.headlessOccupying || !row.resumable) return rememberViewedWorkspace(row.workspaceId) if (row.session.surface === 'webpi') { - await ctx.openWebPiSession(row.workspaceId, row.session.id, source) + await ctx.openWebSession(row.workspaceId, row.session.id, source) } else { await ctx.resumeSession(row.workspaceId, row.session.id, source) } @@ -541,7 +541,7 @@ export function ChatWorkspaceSection({ onPauseSession={(sessionId) => void ctx.pauseSession(MANAGER_WORKSPACE_ID, sessionId)} onResumeSession={(sessionId, surface) => { if (surface === 'webpi') { - void ctx.openWebPiSession(MANAGER_WORKSPACE_ID, sessionId) + void ctx.openWebSession(MANAGER_WORKSPACE_ID, sessionId) } else { void ctx.resumeSession(MANAGER_WORKSPACE_ID, sessionId) } diff --git a/ui/src/components/workspace/ResumeCta.spec.tsx b/ui/src/components/workspace/ResumeCta.spec.tsx index 904d4b33e..81bd88226 100644 --- a/ui/src/components/workspace/ResumeCta.spec.tsx +++ b/ui/src/components/workspace/ResumeCta.spec.tsx @@ -3,9 +3,39 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionRecord } from './api' +import type { AgentInfo, SessionRecord } from './api' import { ResumeCta } from './ResumeCta' +const agents: readonly AgentInfo[] = [ + { + id: 'pi', + displayName: 'Pi', + capabilities: { + parallelPerCwd: true, + resumeLast: true, + resumeById: true, + transcriptDiscovery: 'none', + web: { wire: 'pi-rpc', permissionPrompts: false, freshSession: true }, + }, + }, + { + id: 'claude', + displayName: 'Claude Code', + capabilities: { + parallelPerCwd: true, + resumeLast: false, + resumeById: true, + transcriptDiscovery: 'fs-watch', + web: { wire: 'claude-stream-json', permissionPrompts: true, freshSession: true }, + }, + }, + { + id: 'agy', + displayName: 'Antigravity', + capabilities: { parallelPerCwd: true, resumeLast: true, resumeById: true, transcriptDiscovery: 'subprocess' }, + }, +] + function record(runtime?: SessionRecord['runtime']): SessionRecord { return { id: 'session-1', @@ -30,26 +60,59 @@ describe('ResumeCta runtime facts', () => { it('preserves a long title and all Pi actions without starting the session on mount', async () => { const title = 'Research the complete cross-market impact of a changing policy regime across multiple portfolios' const onResume = vi.fn(async () => {}) - const onOpenWebPi = vi.fn(async () => { throw new Error('Surface unavailable') }) + const onOpenWeb = vi.fn(async () => { throw new Error('Surface unavailable') }) render( {})} onResume={onResume} - onOpenWebPi={onOpenWebPi} + onOpenWeb={onOpenWeb} />) expect(screen.getByRole('heading', { name: title }).textContent).toBe(title) expect(screen.getByRole('button', { name: 'Resume in TUI' })).toBeTruthy() expect(document.querySelector('.resume-cta-actions')?.querySelectorAll('button')).toHaveLength(3) expect(onResume).not.toHaveBeenCalled() - expect(onOpenWebPi).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Open in WebPi' })) + expect(onOpenWeb).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Open in Web' })) expect((await screen.findByRole('alert')).textContent).toBe('Surface unavailable') expect(onResume).not.toHaveBeenCalled() expect((screen.getByRole('button', { name: 'Resume in TUI' }) as HTMLButtonElement).disabled).toBe(false) }) + it('offers the Web surface to every runtime that declares it, not only Pi', () => { + render( {})} + onOpenWeb={vi.fn(async () => {})} + />) + + expect(screen.getByRole('button', { name: 'Open in Web' })).toBeTruthy() + }) + + it('hides the Web surface for runtimes without a structured protocol', () => { + render( {})} + onOpenWeb={vi.fn(async () => {})} + />) + + expect(screen.queryByRole('button', { name: 'Open in Web' })).toBeNull() + }) + + it('hides the Web surface when the runtime list has not loaded', () => { + render( {})} + onOpenWeb={vi.fn(async () => {})} + />) + + expect(screen.queryByRole('button', { name: 'Open in Web' })).toBeNull() + }) + it('shows the persisted Vault binding', () => { render( Promise; readonly onSaveDisplayName?: (displayName: string | null) => Promise; readonly onResume: () => Promise; - readonly onOpenWebPi?: () => Promise; + /** Offered only when the runtime declares a Web conversation surface. */ + readonly onOpenWeb?: () => Promise; } /** @@ -44,20 +46,21 @@ export interface ResumeCtaProps { */ export function ResumeCta(props: ResumeCtaProps): ReactElement { const { t } = useTranslation(); - const [resuming, setResuming] = useState<'terminal' | 'webpi' | null>(null); + const [resuming, setResuming] = useState<'terminal' | 'web' | null>(null); const [settingsOpen, setSettingsOpen] = useState(false); const [error, setError] = useState(null); const r = props.record; const sessionTitle = sessionCoworkerLabel(r); const runtimeFacts = sessionRuntimeFacts(r); const canOpenSettings = Boolean(props.workspaceId && props.onSaveDisplayName); + const canOpenWeb = Boolean(props.onOpenWeb) && agentSupportsWeb(props.agents, r.agent); - const run = async (surface: 'terminal' | 'webpi'): Promise => { + const run = async (surface: 'terminal' | 'web'): Promise => { if (resuming) return; setError(null); setResuming(surface); try { - if (surface === 'webpi') await props.onOpenWebPi?.(); + if (surface === 'web') await props.onOpenWeb?.(); else await props.onResume(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -108,17 +111,17 @@ export function ResumeCta(props: ResumeCtaProps): ReactElement {