From b42b768d00956ca3f8c7bb2b028750061ded081e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 11:47:28 +0000 Subject: [PATCH 1/4] feat(workspaces): generalize WebPi host into multi-wire WebSessionHost Extract the Pi RPC session host into a transport-driven WebSessionHost with pi-rpc, acp, claude-stream-json, and codex-app-server transports. Declare web-surface capabilities on every adapter that exposes a structured stdio protocol and expose the surface through /api/workspaces/:id/web/* routes. Co-authored-by: RainMona --- PLANS.md | 5 + plans/web-conversation-surface.md | 103 ++++ src/webui/routes/workspaces.spec.ts | 98 ++-- src/webui/routes/workspaces.ts | 146 +++--- src/workspaces/adapters/claude.ts | 31 ++ src/workspaces/adapters/codex.ts | 36 +- src/workspaces/adapters/cursor.ts | 18 + src/workspaces/adapters/grok.ts | 18 + src/workspaces/adapters/omp.ts | 22 + src/workspaces/adapters/opencode.ts | 14 + src/workspaces/adapters/pi.ts | 14 +- src/workspaces/adapters/web-command.spec.ts | 71 +++ src/workspaces/cli-adapter.ts | 32 +- src/workspaces/public-session.spec.ts | 4 +- src/workspaces/public-session.ts | 16 +- src/workspaces/service.ts | 80 ++- src/workspaces/web-session-host.spec.ts | 486 ++++++++++++++++++ src/workspaces/web-session-host.ts | 369 +++++++++++++ src/workspaces/web-session/acp-transport.ts | 291 +++++++++++ .../claude-stream-json-transport.ts | 252 +++++++++ .../web-session/codex-app-server-transport.ts | 401 +++++++++++++++ src/workspaces/web-session/json-rpc.ts | 99 ++++ src/workspaces/web-session/model.ts | 105 ++++ .../web-session/pi-rpc-transport.ts | 212 ++++++++ .../web-session/transcript-builder.ts | 132 +++++ src/workspaces/web-session/transport.ts | 216 ++++++++ src/workspaces/webpi-session-host.spec.ts | 157 ------ src/workspaces/webpi-session-host.ts | 403 --------------- src/workspaces/workspace-lifecycle.ts | 6 +- 29 files changed, 3128 insertions(+), 709 deletions(-) create mode 100644 plans/web-conversation-surface.md create mode 100644 src/workspaces/adapters/web-command.spec.ts create mode 100644 src/workspaces/web-session-host.spec.ts create mode 100644 src/workspaces/web-session-host.ts create mode 100644 src/workspaces/web-session/acp-transport.ts create mode 100644 src/workspaces/web-session/claude-stream-json-transport.ts create mode 100644 src/workspaces/web-session/codex-app-server-transport.ts create mode 100644 src/workspaces/web-session/json-rpc.ts create mode 100644 src/workspaces/web-session/model.ts create mode 100644 src/workspaces/web-session/pi-rpc-transport.ts create mode 100644 src/workspaces/web-session/transcript-builder.ts create mode 100644 src/workspaces/web-session/transport.ts delete mode 100644 src/workspaces/webpi-session-host.spec.ts delete mode 100644 src/workspaces/webpi-session-host.ts diff --git a/PLANS.md b/PLANS.md index 4c6522b424..f84e02f7ea 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/plans/web-conversation-surface.md b/plans/web-conversation-surface.md new file mode 100644 index 0000000000..614f93ccbb --- /dev/null +++ b/plans/web-conversation-surface.md @@ -0,0 +1,103 @@ +# 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:** serial PR to `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 keeps its Pi-only WebPi quick start; other runtimes still + need a manager-contract injection path before they can join it. + +## 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 + +- [ ] Neutral message/request/snapshot model and transport contract +- [ ] `WebSessionHost` with process supervision shared by all transports +- [ ] `pi-rpc` transport (pi, omp) extracted from `WebPiSessionHost` +- [ ] `acp` transport (cursor, grok, opencode) with permission requests +- [ ] `claude-stream-json` transport with `can_use_tool` and interrupt +- [ ] `codex-app-server` transport with approvals and `turn/interrupt` +- [ ] Adapter capability declarations and `composeWebCommand` per runtime +- [ ] Service/routes: `/web/*`, `respond`, native-id binding, capability checks +- [ ] UI: generic hook/presenter/view, permission cards, capability gating, demo +- [ ] Owner guide + doc updates +- [ ] Live acceptance against each installed runtime (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. +- Live runtimes are not available in the authoring environment. Before + promotion, open one Session per installed runtime, send a prompt that needs + a tool, answer the permission card, stop mid-turn, and reopen the same + Session in the TUI to 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 37a2700c77..0c8700c389 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 a8ee5592b3..bed601f60d 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 53897f64bc..1177dba8ad 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 dfb773a37e..5c2f953e9b 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 ac6be86927..9f026b98aa 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 1799b70eaf..f5b07f2bfa 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 17708084cb..f997b42335 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 cd933877e2..4d37ca0501 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 5a8fa5361d..d86baad287 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 0000000000..94ee44e97b --- /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 3f315342d0..fa89d6be2c 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 2d3d8036fc..7b62e42bfc 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 c1f55a2205..b188322571 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 c69193b3ae..9be9c35a1f 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 0000000000..0ad6971905 --- /dev/null +++ b/src/workspaces/web-session-host.spec.ts @@ -0,0 +1,486 @@ +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 = {}): 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') { + 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('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 } = {}): 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 (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: 'onRequest', sandbox: 'workspaceWrite' } }) + + 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('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 0000000000..8c1b7209d9 --- /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 0000000000..d8e9e7e0bd --- /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 0000000000..7f97428baa --- /dev/null +++ b/src/workspaces/web-session/claude-stream-json-transport.ts @@ -0,0 +1,252 @@ +/** + * 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') + await this.ctx.channel.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + ...(this.ctx.state.nativeSessionId ? { session_id: this.ctx.state.nativeSessionId } : {}), + }) + } + + 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 0000000000..14c83568cd --- /dev/null +++ b/src/workspaces/web-session/codex-app-server-transport.ts @@ -0,0 +1,401 @@ +/** + * 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 +} + +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', {}) + const options = { + cwd: this.ctx.input.cwd, + approvalPolicy: 'onRequest', + sandbox: 'workspaceWrite', + ...(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.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': + return this.approval(p, { + title: 'Grant additional permissions', + description: 'Codex requests network or filesystem access beyond its sandbox.', + tool: { name: 'request_permissions', input: p['permissions'] ?? p }, + }, [ + { id: 'accept', label: 'Grant', tone: 'allow' }, + { id: 'decline', label: 'Decline', tone: 'deny' }, + ]) + 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 })) + } + + 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, + ): Promise { + this.requestSeq += 1 + const id = `codex-${this.requestSeq}` + return new Promise((resolve) => { + this.approvals.set(id, { answer: resolve, respondWith }) + 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({ decision: 'cancel' }) + } + } +} + +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 0000000000..0584ccb169 --- /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 0000000000..07f9f80c24 --- /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 0000000000..127ec2e29b --- /dev/null +++ b/src/workspaces/web-session/pi-rpc-transport.ts @@ -0,0 +1,212 @@ +/** + * 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') + await this.request('prompt', { message: trimmed }) + 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 0000000000..115925bad9 --- /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 0000000000..5f83e2816c --- /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 1b1d82ff5d..0000000000 --- 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 24b622b9a1..0000000000 --- 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 1204b21a96..5f6ae0e99c 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', From b2be6a0f84195178dae64b09e74eb0b584279192 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 12:07:23 +0000 Subject: [PATCH 2/4] feat(ui): present any web-capable runtime in the Web conversation surface Rename the WebPi browser modules to runtime-neutral names, read the new /web/* snapshot shape, gate Web launch on the runtime's declared capability, and add a pinned request card so permission prompts from Claude, Codex, and ACP runtimes can be answered from the browser. Demo handlers mirror the capability table and script a permission turn for prompting runtimes. Co-authored-by: RainMona --- .../conversation/ConversationRequestCard.tsx | 97 +++++ .../conversation/ConversationTranscript.tsx | 7 + .../components/conversation/conversation.css | 29 +- ui/src/components/conversation/types.ts | 2 + .../ChatWorkspaceSection.auto-quant.spec.tsx | 2 +- .../workspace/ChatWorkspaceSection.spec.tsx | 8 +- .../workspace/ChatWorkspaceSection.tsx | 4 +- .../components/workspace/ResumeCta.spec.tsx | 73 +++- ui/src/components/workspace/ResumeCta.tsx | 31 +- .../components/workspace/WebPiView.spec.tsx | 183 --------- ui/src/components/workspace/WebPiView.tsx | 51 --- .../workspace/WebSessionView.spec.tsx | 301 +++++++++++++++ .../components/workspace/WebSessionView.tsx | 122 ++++++ .../WorkspaceTemplateUpgradePanel.spec.tsx | 2 +- .../WorkspaceTemplateUpgradePanel.tsx | 2 +- .../workspace/WorkspaceView.spec.tsx | 12 +- ui/src/components/workspace/WorkspaceView.tsx | 14 +- ui/src/components/workspace/api.ts | 168 +++++--- .../components/workspace/harness-sessions.ts | 2 +- .../workspace/useWebConversation.ts | 72 ++++ .../workspace/useWebPiConversation.ts | 44 --- ...ation.spec.ts => web-presentation.spec.ts} | 6 +- ...pi-presentation.ts => web-presentation.ts} | 20 +- ...nscript.spec.ts => web-transcript.spec.ts} | 12 +- ...{webpi-transcript.ts => web-transcript.ts} | 53 ++- ui/src/components/workspace/workspaces.css | 2 +- ui/src/contexts/WorkspacesContext.spec.tsx | 16 +- ui/src/contexts/WorkspacesContext.tsx | 10 +- ui/src/contexts/workspaces-context.ts | 2 +- ui/src/demo/README.md | 12 +- ui/src/demo/fixtures/web-session.ts | 360 ++++++++++++++++++ ui/src/demo/fixtures/webpi.ts | 226 ----------- ui/src/demo/fixtures/workspaces.ts | 2 +- .../demo/handlers/workspaces-resume.spec.ts | 4 +- ui/src/demo/handlers/workspaces-web.spec.ts | 186 +++++++++ ui/src/demo/handlers/workspaces-webpi.spec.ts | 111 ------ ui/src/demo/handlers/workspaces.ts | 240 ++++++++---- ui/src/i18n/locales/en.ts | 6 +- ui/src/i18n/locales/ja.ts | 6 +- ui/src/i18n/locales/zh-Hant.ts | 6 +- ui/src/i18n/locales/zh.ts | 6 +- ui/src/pages/AutoPredictionSetupPage.spec.tsx | 2 +- ui/src/pages/AutoQuantSetupPage.spec.tsx | 2 +- ui/src/pages/ChatLandingPage.render.spec.tsx | 2 +- ui/src/pages/ChatSetupPage.spec.tsx | 2 +- ui/src/pages/WorkspaceListPage.spec.tsx | 2 +- ui/src/pages/WorkspaceManagerPage.spec.tsx | 33 +- ui/src/pages/WorkspaceManagerPage.tsx | 19 +- ui/src/pages/WorkspacePage.spec.tsx | 4 +- ui/src/pages/WorkspacePage.tsx | 30 +- 50 files changed, 1729 insertions(+), 879 deletions(-) create mode 100644 ui/src/components/conversation/ConversationRequestCard.tsx delete mode 100644 ui/src/components/workspace/WebPiView.spec.tsx delete mode 100644 ui/src/components/workspace/WebPiView.tsx create mode 100644 ui/src/components/workspace/WebSessionView.spec.tsx create mode 100644 ui/src/components/workspace/WebSessionView.tsx create mode 100644 ui/src/components/workspace/useWebConversation.ts delete mode 100644 ui/src/components/workspace/useWebPiConversation.ts rename ui/src/components/workspace/{webpi-presentation.spec.ts => web-presentation.spec.ts} (79%) rename ui/src/components/workspace/{webpi-presentation.ts => web-presentation.ts} (68%) rename ui/src/components/workspace/{webpi-transcript.spec.ts => web-transcript.spec.ts} (93%) rename ui/src/components/workspace/{webpi-transcript.ts => web-transcript.ts} (82%) create mode 100644 ui/src/demo/fixtures/web-session.ts delete mode 100644 ui/src/demo/fixtures/webpi.ts create mode 100644 ui/src/demo/handlers/workspaces-web.spec.ts delete mode 100644 ui/src/demo/handlers/workspaces-webpi.spec.ts diff --git a/ui/src/components/conversation/ConversationRequestCard.tsx b/ui/src/components/conversation/ConversationRequestCard.tsx new file mode 100644 index 0000000000..cda2fc5fa4 --- /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 c509dd1689..b177d68f55 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 4c841f4ee9..03534d4855 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 082129db7c..74ebcffd31 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 927b2be64b..894a184f0e 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 2f63757fe7..2139b52867 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 43ea22cb88..1d50cf16a2 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 904d4b33e3..81bd882267 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 {