From 9e16d2e52a79d0054f4223c9ed4fc9c65c6a00f8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 05:53:21 +0000 Subject: [PATCH 01/16] chore(deps): @lydell/node-pty root devDependency; @xterm/xterm + addon-fit for the Workbench (#600 PR 3) --- package.json | 1 + packages/workbench/package.json | 2 + pnpm-lock.yaml | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/package.json b/package.json index b469a868c..7b62a5def 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@changesets/cli": "3.0.1", "@effect/language-service": "0.87.2", "@effect/tsgo": "0.39.0", + "@lydell/node-pty": "1.2.0-beta.15", "@modelcontextprotocol/conformance": "0.1.16", "@modelcontextprotocol/server": "2.0.0", "@rsbuild/core": "2.2.3", diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 28fa8511c..4e0731d5a 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -17,6 +17,8 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/sdk": "1.30.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", "effect": "4.0.0-rc.112", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c3c25547..64e7405db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@effect/tsgo': specifier: 0.39.0 version: 0.39.0 + '@lydell/node-pty': + specifier: 1.2.0-beta.15 + version: 1.2.0-beta.15 '@modelcontextprotocol/conformance': specifier: 0.1.16 version: 0.1.16 @@ -441,6 +444,12 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.30.0 version: 1.30.0(supports-color@7.2.0)(zod@4.5.4) + '@xterm/addon-fit': + specifier: 0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: 6.0.0 + version: 6.0.0 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 @@ -788,6 +797,39 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@lydell/node-pty-darwin-arm64@1.2.0-beta.15': + resolution: {integrity: sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==} + cpu: [arm64] + os: [darwin] + + '@lydell/node-pty-darwin-x64@1.2.0-beta.15': + resolution: {integrity: sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==} + cpu: [x64] + os: [darwin] + + '@lydell/node-pty-linux-arm64@1.2.0-beta.15': + resolution: {integrity: sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==} + cpu: [arm64] + os: [linux] + + '@lydell/node-pty-linux-x64@1.2.0-beta.15': + resolution: {integrity: sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==} + cpu: [x64] + os: [linux] + + '@lydell/node-pty-win32-arm64@1.2.0-beta.15': + resolution: {integrity: sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==} + cpu: [arm64] + os: [win32] + + '@lydell/node-pty-win32-x64@1.2.0-beta.15': + resolution: {integrity: sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==} + cpu: [x64] + os: [win32] + + '@lydell/node-pty@1.2.0-beta.15': + resolution: {integrity: sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -1576,6 +1618,12 @@ packages: peerDependencies: react: '>=18.3.1' + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3568,6 +3616,33 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@lydell/node-pty-darwin-arm64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty-darwin-x64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty-linux-arm64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty-linux-x64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty-win32-arm64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty-win32-x64@1.2.0-beta.15': + optional: true + + '@lydell/node-pty@1.2.0-beta.15': + optionalDependencies: + '@lydell/node-pty-darwin-arm64': 1.2.0-beta.15 + '@lydell/node-pty-darwin-x64': 1.2.0-beta.15 + '@lydell/node-pty-linux-arm64': 1.2.0-beta.15 + '@lydell/node-pty-linux-x64': 1.2.0-beta.15 + '@lydell/node-pty-win32-arm64': 1.2.0-beta.15 + '@lydell/node-pty-win32-x64': 1.2.0-beta.15 + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -4368,6 +4443,10 @@ snapshots: react: 19.2.8 unhead: 2.1.17 + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 From 4bcd30f95e5c0206d3f3a52c7069ed5f8dcf73cb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:05:44 +0000 Subject: [PATCH 02/16] feat(dev): join hook receipts and host MCP frames on AGENT_BUNDLE_DEV_SESSION (#600) Wrappers post a top-level receipt `devSession` and leave the host session_id on identity. The proxy sends `x-agent-bundle-dev-session` when the id is valid; receipts reject a bad value with AB8266 and call `attachHostSession`. Host MCP opens with a lazy sessionId resolver so frames pick up the host id once known. --- LANE-NOTES.md | 63 ++++++++++++++++++ docs/diagnostics.md | 1 + .../src/contracts/host-sessions.ts | 6 ++ .../src/dev/hooks/hook-receipt-endpoint.ts | 23 ++++++- .../src/dev/hooks/hook-receipts.ts | 21 +++++- .../agent-bundle/src/dev/host-mcp-proxy.ts | 16 ++++- .../agent-bundle/src/dev/host-mcp-routes.ts | 42 +++++++++++- .../dev/mcp-session/mcp-session-service.ts | 8 ++- .../mcp-session-trace-publisher.ts | 30 ++++++--- .../src/dev/mcp-session/mcp-session-types.ts | 2 + .../agent-bundle/src/dev/trace/trace-entry.ts | 2 + .../agent-bundle/src/events/trace-receipt.ts | 5 ++ .../agent-bundle/tests/hook-receipts.test.ts | 64 +++++++++++++++++++ .../tests/host-mcp-routes.test.ts | 37 +++++++++++ .../tests/mcp-session-trace-publisher.test.ts | 30 +++++++++ packages/workbench/src/trace/trace-model.ts | 4 ++ packages/workbench/tests/trace-model.test.ts | 2 +- website/docs/en/reference/dev-server-http.mdx | 1 + .../docs/en/reference/runtime-environment.mdx | 1 + website/docs/zh/reference/dev-server-http.mdx | 1 + .../docs/zh/reference/runtime-environment.mdx | 1 + 21 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/contracts/host-sessions.ts create mode 100644 packages/agent-bundle/tests/host-mcp-routes.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..626bc8679 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,63 @@ +# S2 — trace correlation through hooks and the dev proxy + +Lane: `lane/wb600-pr3-s2`. No identity override: the host's `session_id` stays +`identity.sessionId`. The Workbench `hs_` id travels as receipt `devSession` +and as `x-agent-bundle-dev-session`. + +## Files + +- `packages/agent-bundle/src/contracts/host-sessions.ts` — **created** with only + `isHostSessionId`. Merge with S1's file (S1 owns the `HostSession` types). +- `packages/agent-bundle/src/dev/trace/trace-entry.ts` — `'session'` appended + to `traceSources`. Dedupe if S1 added the same tuple member. +- `packages/agent-bundle/src/events/trace-receipt.ts` — `EVENT_TRACE_RECEIPT_SESSION_ENV`, + optional `EventTraceReceipt.devSession` posted when the env var is nonempty. + `identity.sessionId` is still the host payload. +- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` — accepts `devSession`, + `isHostSessionId` / `HookReceiptSessionError` (`AB8266`). +- `packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts` — + `attachHostSession?: (devSession, hostSessionId) => void` on + `HookReceiptRoutesOptions` / `AttachHookReceiptsOptions`; called after a + valid decode, before lowering. +- `packages/agent-bundle/src/dev/host-mcp-proxy.ts` — + `hostMcpProxyRequestInit`, `HOST_MCP_DEV_SESSION_HEADER`. Header sent only + when the env var is a valid host-session id. +- `packages/agent-bundle/src/dev/host-mcp-routes.ts` — `hostDevSessionId`, + `HOST_MCP_DEV_SESSION_CODE`, `HostMcpRoutesOptions.traceSessionId?` + (default identity). Initialize request header → `open({ sessionId: () => … })`. +- `packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts` — + `OpenMcpSessionOptions.sessionId?: () => string`. +- `packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts` / + `mcp-session-trace-publisher.ts` — `resolveSessionId` called per frame into + `correlation.sessionId`; frame `_meta.sessionId` still wins. +- Tests: `hook-receipts.test.ts`, `host-mcp-routes.test.ts` (new), + `mcp-session-trace-publisher.test.ts`. Workbench `trace-model.ts` gained + exhaustive `'session'` cases so `pnpm typecheck` passes (glyph `▣`, + headline priority with `hook`). +- Docs: `website/docs/{en,zh}/reference/runtime-environment.mdx`, + `dev-server-http.mdx` source union, `docs/diagnostics.md` `AB8266`. +- Wrapper templates / hash assertions: **unchanged**. + +Did not touch `workbench-server.ts` (S1 wires the callbacks). + +## Exported API + +- `isHostSessionId(value: unknown): value is string` — `/^hs_[0-9a-z]{16}$/` +- `EVENT_TRACE_RECEIPT_SESSION_ENV = 'AGENT_BUNDLE_DEV_SESSION'` +- `EventTraceReceipt.devSession?: string` +- `HOOK_RECEIPT_SESSION_CODE = 'AB8266'`, `HookReceiptSessionError` +- `attachHostSession?: (devSession: string, hostSessionId: string | undefined) => void` +- `hostMcpProxyRequestInit(env?)`, `HOST_MCP_DEV_SESSION_HEADER` +- `hostDevSessionId(headers)`, `HOST_MCP_DEV_SESSION_CODE` +- `HostMcpRoutesOptions.traceSessionId?: (devSession: string) => string` +- `OpenMcpSessionOptions.sessionId?: () => string` +- `McpSessionTracePublisherOptions.resolveSessionId?: () => string` + +## Integrator + +1. Merge `contracts/host-sessions.ts` with S1 (keep one `isHostSessionId`). +2. S1: `attachHookReceipts({ attachHostSession: (id, host) => hostSessions.attach(id, host), … })` + and `new HostMcpRoutes({ …, traceSessionId: (id) => hostSessions.traceSessionId(id) })`. +3. S3: replace the Workbench `'session'` glyph/priority if the Sessions pane + wants different copy; the group-header `hs_` link is still S3. +4. Dedupe `'session'` on `traceSources` if S1 added it too. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4b4e46a90..fc7a5e506 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -51,6 +51,7 @@ even when no error diagnostic was reported. | `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. | | `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, `AB8252` compiled CLI projection or event preflight preparation failed, `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host surface. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`; select a generated host wrapper for `AB8255`. | | `AB8256` | Workbench route invocation cancellation (`POST /api/routes/invocations//cancel`): the invocation is already final (409). Reload the final invocation instead of cancelling it. | +| `AB8266` | Invalid `AGENT_BUNDLE_DEV_SESSION` on a hook receipt (`devSession`) or host MCP proxy request (`x-agent-bundle-dev-session`): the value must match `/^hs_[0-9a-z]{16}$/` (400). | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/contracts/host-sessions.ts b/packages/agent-bundle/src/contracts/host-sessions.ts new file mode 100644 index 000000000..452952d87 --- /dev/null +++ b/packages/agent-bundle/src/contracts/host-sessions.ts @@ -0,0 +1,6 @@ +/** + * Browser-safe host-session identifiers. The Workbench and the generated + * hook wrapper share this predicate; S1 owns the rest of this module. + */ +export const isHostSessionId = (value: unknown): value is string => + typeof value === 'string' && /^hs_[0-9a-z]{16}$/.test(value); diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts index fe9498af7..ce7e88c48 100644 --- a/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts +++ b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts @@ -18,9 +18,11 @@ import type { TracePublisher } from '../trace/trace-hub.ts'; import { decodeHookReceipt, HOOK_RECEIPT_MALFORMED_CODE, + HOOK_RECEIPT_SESSION_CODE, HOOK_RECEIPT_TOO_LARGE_CODE, HOOK_RECEIPT_UNAUTHORIZED_CODE, HookReceiptDecodeError, + HookReceiptSessionError, lowerHookReceipt, } from './hook-receipts.ts'; @@ -50,16 +52,19 @@ const sameToken = (expected: string, actual: string): boolean => { }; export interface HookReceiptRoutesOptions { + readonly attachHostSession?: (devSession: string, hostSessionId: string | undefined) => void; readonly token: string; readonly trace: TracePublisher; } export class HookReceiptRoutes { + readonly #attachHostSession: HookReceiptRoutesOptions['attachHostSession']; readonly #token: string; readonly #trace: TracePublisher; #closed = false; constructor(options: HookReceiptRoutesOptions) { + this.#attachHostSession = options.attachHostSession; this.#token = options.token; this.#trace = options.trace; } @@ -89,15 +94,22 @@ export class HookReceiptRoutes { message: 'Hook receipt exceeds 16 KiB.', }, }); - let entries: readonly TraceEntryInput[]; + let receipt; try { - entries = lowerHookReceipt(decodeHookReceipt(body)); + receipt = decodeHookReceipt(body); } catch (error) { if (error instanceof HookReceiptDecodeError) { throw requestError(diagnostic(HOOK_RECEIPT_MALFORMED_CODE, error.message, 400)); } + if (error instanceof HookReceiptSessionError) { + throw requestError(diagnostic(HOOK_RECEIPT_SESSION_CODE, error.message, 400)); + } throw error; } + if (receipt.devSession !== undefined) { + this.#attachHostSession?.(receipt.devSession, receipt.identity.sessionId); + } + const entries: readonly TraceEntryInput[] = lowerHookReceipt(receipt); for (const entry of entries) this.#trace.publish(entry); response.writeHead(204, { 'cache-control': 'no-store' }); response.end(); @@ -114,6 +126,7 @@ export class HookReceiptRoutes { } export interface AttachHookReceiptsOptions { + readonly attachHostSession?: HookReceiptRoutesOptions['attachHostSession']; /** The project whose dev server this is; the endpoint record lands under its `.agent-bundle/`. */ readonly projectRoot: string; readonly trace: TracePublisher; @@ -136,7 +149,11 @@ export interface HookReceiptAttachment { export const attachHookReceipts = (options: AttachHookReceiptsOptions): HookReceiptAttachment => { const token = randomBytes(32).toString('base64url'); - const routes = new HookReceiptRoutes({ token, trace: options.trace }); + const routes = new HookReceiptRoutes({ + ...(options.attachHostSession === undefined ? {} : { attachHostSession: options.attachHostSession }), + token, + trace: options.trace, + }); const recordPath = eventTraceReceiptEndpointPath(resolve(options.projectRoot)); const endpoint = (url: string): EventTraceReceiptEndpoint => { if (!isLoopbackHttpOrigin(url)) { diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts index 2fa069a8e..82eda654e 100644 --- a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts +++ b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts @@ -1,3 +1,4 @@ +import { isHostSessionId } from '../../contracts/host-sessions.ts'; import type { RequestLineageProvenance, RequestProvenanceAxis } from '../../contracts/request-provenance.ts'; import { hasOnlyOwnKeys, isRecord, type JsonObject, type JsonValue } from '../../core/strict-json.ts'; import { @@ -19,6 +20,7 @@ import type { TraceCorrelation, TraceEntryInput } from '../trace/trace-entry.ts' export const HOOK_RECEIPT_UNAUTHORIZED_CODE = 'AB8247'; export const HOOK_RECEIPT_MALFORMED_CODE = 'AB8248'; export const HOOK_RECEIPT_TOO_LARGE_CODE = 'AB8249'; +export const HOOK_RECEIPT_SESSION_CODE = 'AB8266'; const hookReceiptMaxEvents = 32; const MAX_ID_LENGTH = 256; @@ -31,6 +33,15 @@ export class HookReceiptDecodeError extends TypeError { } } +export class HookReceiptSessionError extends TypeError { + readonly code = HOOK_RECEIPT_SESSION_CODE; + + constructor(readonly path = 'devSession') { + super('AGENT_BUNDLE_DEV_SESSION must be a host-session id (hs_ + 16 lowercase characters).'); + this.name = 'HookReceiptSessionError'; + } +} + const fail: (path: string) => never = (path) => { throw new HookReceiptDecodeError(path); }; @@ -123,6 +134,12 @@ const decodeLineage = (value: unknown): RequestProvenanceAxis { + if (value === undefined) return undefined; + if (!isHostSessionId(value)) throw new HookReceiptSessionError(); + return value; +}; + const decodeIdentity = (value: unknown): EventTraceReceiptIdentity => { const input = record(value, 'identity'); onlyKeys(input, ['conversationId', 'requestId', 'sessionId'], 'identity'); @@ -224,7 +241,7 @@ const decodeEvent = (value: unknown, index: number): EventTraceReceiptEvent => { */ export const decodeHookReceipt = (value: unknown): EventTraceReceipt => { const input = record(value, 'receipt'); - onlyKeys(input, ['events', 'execution', 'identity', 'lineage', 'startedAt', 'version'], 'receipt'); + onlyKeys(input, ['devSession', 'events', 'execution', 'identity', 'lineage', 'startedAt', 'version'], 'receipt'); if (input.version !== EVENT_TRACE_RECEIPT_VERSION) fail('version'); const execution = record(input.execution, 'execution'); onlyKeys(execution, ['event', 'executionId', 'host', 'nativeEvent'], 'execution'); @@ -234,7 +251,9 @@ export const decodeHookReceipt = (value: unknown): EventTraceReceipt => { for (let index = 1; index < events.length; index += 1) { if (events[index]!.sequence <= events[index - 1]!.sequence) fail(`events[${index}].sequence`); } + const devSession = decodeDevSession(input.devSession); return Object.freeze({ + ...(devSession === undefined ? {} : { devSession }), events: Object.freeze(events), execution: Object.freeze({ event: oneOf(execution.event, canonicalAgentEvents, 'execution.event'), diff --git a/packages/agent-bundle/src/dev/host-mcp-proxy.ts b/packages/agent-bundle/src/dev/host-mcp-proxy.ts index 2426591d1..b1feed1ce 100644 --- a/packages/agent-bundle/src/dev/host-mcp-proxy.ts +++ b/packages/agent-bundle/src/dev/host-mcp-proxy.ts @@ -6,9 +6,20 @@ import { import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { resolve } from 'node:path'; +import { isHostSessionId } from '../contracts/host-sessions.ts'; import { isRecord } from '../core/strict-json.ts'; import { discoverDevServerUrl } from './dev-lock.ts'; +export const HOST_MCP_DEV_SESSION_HEADER = 'x-agent-bundle-dev-session'; + +export const hostMcpProxyRequestInit = ( + env: Readonly = process.env, +): { readonly requestInit: { readonly headers: Readonly> } } | undefined => { + const session = env.AGENT_BUNDLE_DEV_SESSION; + if (!isHostSessionId(session)) return undefined; + return { requestInit: { headers: { [HOST_MCP_DEV_SESSION_HEADER]: session } } }; +}; + export const hostMcpUnavailableCode = 'AB8025'; export interface RunHostMcpProxyOptions { @@ -96,7 +107,10 @@ export const runHostMcpProxy = async (options: RunHostMcpProxyOptions): Promise< try { const origin = loopbackOrigin(options.url ?? await discoverDevServerUrl({ projectRoot })); - const transport = new StreamableHTTPClientTransport(hostEndpoint(origin, options.serverName, target)); + const transport = new StreamableHTTPClientTransport( + hostEndpoint(origin, options.serverName, target), + hostMcpProxyRequestInit(), + ); remote = transport; transport.onmessage = (message) => { void stdio.send(message).catch(reportUnavailable); diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index 964dc3090..fe15af6b6 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -4,18 +4,36 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { ProtocolError, Server, type ReadResourceResult } from '@modelcontextprotocol/server'; +import { isHostSessionId } from '../contracts/host-sessions.ts'; import { EpochStoreError, type EpochStore } from './epoch-store.ts'; import { subscribeToEpochAdoption, type EpochAdoptionSource, } from './epoch-adoption-policy.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; +import { diagnostic, requestError, singleHeader } from './http.ts'; import { McpSessionStaleEpochError, type McpSession, type McpSessionService, } from './mcp-session/mcp-session-service.ts'; +export const HOST_MCP_DEV_SESSION_CODE = 'AB8266'; +const hostDevSessionHeader = 'x-agent-bundle-dev-session'; + +export const hostDevSessionId = (headers: IncomingMessage['headers']): string | undefined => { + const value = singleHeader(headers[hostDevSessionHeader]); + if (value === undefined) return undefined; + if (!isHostSessionId(value)) { + throw requestError(diagnostic( + HOST_MCP_DEV_SESSION_CODE, + 'AGENT_BUNDLE_DEV_SESSION must be a host-session id (hs_ + 16 lowercase characters).', + 400, + )); + } + return value; +}; + const hostMcpPathPrefix = '/mcp/host/'; const internalErrorCode = -32_603; @@ -48,11 +66,12 @@ interface HostMcpEpochSession { readonly session: McpSession; } -interface HostMcpRoutesOptions { +export interface HostMcpRoutesOptions { readonly adoption?: EpochAdoptionSource; readonly epochStore: EpochStore; readonly eventHub: ProjectEventHub; readonly mcpSessions: McpSessionService; + readonly traceSessionId?: (devSession: string) => string; } const requestSessionId = (request: IncomingMessage): string | undefined => { @@ -81,9 +100,11 @@ const isEpochDrift = (error: unknown): boolean => class HostMcpConnection { readonly #adoption: EpochAdoptionSource | undefined; readonly #binding: HostMcpBinding; + readonly #devSession: string | undefined; readonly #epochStore: EpochStore; readonly #mcpSessions: McpSessionService; readonly #onSessionInitialized: (sessionId: string, connection: HostMcpConnection) => void; + readonly #traceSessionId: (devSession: string) => string; readonly #epochSessions = new Set(); readonly #server: Server; readonly transport: NodeStreamableHTTPServerTransport; @@ -97,14 +118,18 @@ class HostMcpConnection { constructor( binding: HostMcpBinding, - options: Pick, + options: Pick & { + readonly devSession?: string; + }, onSessionInitialized: (sessionId: string, connection: HostMcpConnection) => void, ) { this.#adoption = options.adoption; this.#binding = binding; + this.#devSession = options.devSession; this.#epochStore = options.epochStore; this.#mcpSessions = options.mcpSessions; this.#onSessionInitialized = onSessionInitialized; + this.#traceSessionId = options.traceSessionId ?? ((id) => id); this.#server = new Server( { name: `agent-bundle-dev:${binding.serverName}`, version: '0.1.0' }, { @@ -232,10 +257,12 @@ class HostMcpConnection { async #openEpochSession(epochId: string): Promise { const target = this.#binding.target ?? await this.#targetFor(epochId); + const devSession = this.#devSession; const session = await this.#mcpSessions.open({ epochId, serverName: this.#binding.serverName, target, + ...(devSession === undefined ? {} : { sessionId: () => this.#traceSessionId(devSession) }), }); const binding: HostMcpEpochSession = { epochId, @@ -355,12 +382,14 @@ export class HostMcpRoutes { readonly #mcpSessions: McpSessionService; readonly #sessions = new Map(); readonly #subscription: ProjectEventSubscription; + readonly #traceSessionId: HostMcpRoutesOptions['traceSessionId']; #closed = false; constructor(options: HostMcpRoutesOptions) { this.#adoption = options.adoption; this.#epochStore = options.epochStore; this.#mcpSessions = options.mcpSessions; + this.#traceSessionId = options.traceSessionId; this.#subscription = subscribeToEpochAdoption(options.adoption, options.eventHub, (epochId) => { for (const connection of this.#connections) connection.refreshCatalog(epochId); }); @@ -389,9 +418,16 @@ export class HostMcpRoutes { return true; } + const devSession = hostDevSessionId(request.headers); const connection = new HostMcpConnection( binding, - { adoption: this.#adoption, epochStore: this.#epochStore, mcpSessions: this.#mcpSessions }, + { + adoption: this.#adoption, + epochStore: this.#epochStore, + mcpSessions: this.#mcpSessions, + ...(this.#traceSessionId === undefined ? {} : { traceSessionId: this.#traceSessionId }), + ...(devSession === undefined ? {} : { devSession }), + }, (id, initialized) => this.#sessions.set(id, initialized), ); this.#connections.add(connection); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index e2a65532e..0287fb5ef 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -397,7 +397,13 @@ export class McpSessionService { this.#traceSink, this.#trace === undefined ? undefined - : createMcpSessionTraceSink({ binding, projectRoot: this.#projectRoot, sessionId, trace: this.#trace }), + : createMcpSessionTraceSink({ + binding, + projectRoot: this.#projectRoot, + sessionId, + trace: this.#trace, + ...(options.sessionId === undefined ? {} : { resolveSessionId: options.sessionId }), + }), ); const session = yield* liftTry(() => new McpSession({ assertEpochAvailable: async () => { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts index 028b77af2..51d194e00 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts @@ -39,6 +39,8 @@ export interface McpSessionTracePublisherOptions { readonly binding: McpSessionBinding; /** Redaction root for stderr and error text (`safeDevWireText`). */ readonly projectRoot: string; + /** Resolved per frame into `correlation.sessionId`; a frame `_meta` value still wins. */ + readonly resolveSessionId?: () => string; readonly sessionId: McpSessionId; readonly trace: TracePublisher; } @@ -137,8 +139,16 @@ const messageOf = (entry: McpSessionFrameTraceEntry): Readonly { - const { binding, projectRoot, sessionId, trace } = options; - const base: TraceCorrelation = Object.freeze({ epochId: binding.epochId, host: binding.target, mcpSessionId: sessionId }); + const { binding, projectRoot, resolveSessionId, sessionId, trace } = options; + const base = (): TraceCorrelation => { + const resolved = resolveSessionId?.(); + return Object.freeze({ + epochId: binding.epochId, + host: binding.target, + mcpSessionId: sessionId, + ...(typeof resolved === 'string' && resolved !== '' ? { sessionId: resolved } : {}), + }); + }; const pending = new Map(); const progressTokens = new Map(); const protocolHref = hrefFor(undefined, sessionId); @@ -177,7 +187,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const routeId = routeIdFor(method, params, binding.serverName); const meta = entry.meta; const correlation: TraceCorrelation = Object.freeze({ - ...base, + ...base(), ...(meta?.correlationId === undefined ? {} : { correlationId: meta.correlationId }), ...(meta?.conversationId === undefined ? {} : { conversationId: meta.conversationId }), mcpRequestId: id, @@ -208,7 +218,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const response = (entry: McpSessionFrameTraceEntry, id: string): void => { const message = messageOf(entry); const matched = forget(id); - const correlation = matched?.correlation ?? Object.freeze({ ...base, mcpRequestId: id }); + const correlation = matched?.correlation ?? Object.freeze({ ...base(), mcpRequestId: id }); const label = matched?.label ?? 'response'; const error = isRecord(message.error) ? message.error : undefined; const result = message.result; @@ -241,7 +251,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const params = message.params; const cancelled = method === 'notifications/cancelled' && isRecord(params) ? jsonRpcId(params.requestId) : undefined; const matched = cancelled === undefined ? undefined : pending.get(cancelled); - const correlation = matched?.correlation ?? Object.freeze({ ...base, ...(cancelled === undefined ? {} : { mcpRequestId: cancelled }) }); + const correlation = matched?.correlation ?? Object.freeze({ ...base(), ...(cancelled === undefined ? {} : { mcpRequestId: cancelled }) }); publish({ correlation, details: { direction: entry.direction, method }, @@ -264,7 +274,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const payload = isRecord(entry.payload) ? entry.payload : undefined; const token = payload === undefined ? undefined : jsonRpcId(payload.progressToken); const matched = token === undefined ? undefined : pending.get(progressTokens.get(token) ?? token); - const correlation = matched?.correlation ?? base; + const correlation = matched?.correlation ?? base(); const current = typeof payload?.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : undefined; const total = typeof payload?.total === 'number' && Number.isFinite(payload.total) ? payload.total : undefined; publish({ @@ -287,7 +297,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const level = wireText(payload?.level); const logger = wireText(payload?.logger); publish({ - correlation: base, + correlation: base(), details: { ...(level === undefined ? {} : { level }), ...(logger === undefined ? {} : { logger }) }, href: protocolHref, kind: 'mcp.logging', @@ -298,7 +308,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const stderr = (entry: McpSessionStderrTraceEntry): void => { publish({ - correlation: base, + correlation: base(), details: { bytes: Buffer.byteLength(entry.text) }, href: protocolHref, kind: 'mcp.stderr', @@ -314,7 +324,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio const restarted = started && entry.operation === 'restart'; started = true; publish({ - correlation: base, + correlation: base(), details: { operation: entry.operation }, href: protocolHref, kind: 'mcp.session.started', @@ -327,7 +337,7 @@ export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptio if (entry.operation === 'close' && entry.phase !== 'started' && !closed) { closed = true; publish({ - correlation: base, + correlation: base(), details: { operation: entry.operation }, href: protocolHref, kind: 'mcp.session.closed', diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index 578c3ac5a..fa606aebf 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -114,6 +114,8 @@ export interface RemoteTransportOptions { } export interface OpenMcpSessionOptions extends McpSessionBinding { + /** Lazy join key for MCP trace frames; resolved per frame. */ + readonly sessionId?: () => string; readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly workspaceRoot?: string; diff --git a/packages/agent-bundle/src/dev/trace/trace-entry.ts b/packages/agent-bundle/src/dev/trace/trace-entry.ts index d18f4af11..dfbbe171c 100644 --- a/packages/agent-bundle/src/dev/trace/trace-entry.ts +++ b/packages/agent-bundle/src/dev/trace/trace-entry.ts @@ -9,6 +9,8 @@ export const traceSources = Object.freeze([ 'mcp', /** A host-invoked hook or event route observed against the dev plugin. */ 'hook', + /** A PTY-backed Claude Code / Codex host session in the Workbench. */ + 'session', /** A dev log record that carries a correlation key. */ 'log', /** Build, contract-gate, and host-attach diagnostics. */ diff --git a/packages/agent-bundle/src/events/trace-receipt.ts b/packages/agent-bundle/src/events/trace-receipt.ts index ee08e4551..09908fdbf 100644 --- a/packages/agent-bundle/src/events/trace-receipt.ts +++ b/packages/agent-bundle/src/events/trace-receipt.ts @@ -19,6 +19,7 @@ export const EVENT_TRACE_RECEIPT_PATH = '/api/trace/receipts'; export const EVENT_TRACE_RECEIPT_MAX_BYTES = 16 * 1024; export const EVENT_TRACE_RECEIPT_URL_ENV = 'AGENT_BUNDLE_DEV_TRACE_URL'; export const EVENT_TRACE_RECEIPT_TOKEN_ENV = 'AGENT_BUNDLE_DEV_TRACE_TOKEN'; +export const EVENT_TRACE_RECEIPT_SESSION_ENV = 'AGENT_BUNDLE_DEV_SESSION'; export const EVENT_TRACE_RECEIPT_ENDPOINT_FILE = 'hook-receipts.json'; /** * The dev host installer's marker at the installed bundle root @@ -45,6 +46,8 @@ type DistributiveOmit = Value extends unknown ? export type EventTraceReceiptEvent = DistributiveOmit; export interface EventTraceReceipt { + /** Workbench host-session id (`hs_…`) when the wrapper ran under `AGENT_BUNDLE_DEV_SESSION`. */ + readonly devSession?: string; readonly events: readonly EventTraceReceiptEvent[]; readonly execution: EventTraceExecution; readonly identity: EventTraceReceiptIdentity; @@ -219,7 +222,9 @@ export const openEventTraceReceipt = async ( send: async () => { if (sent || startedAt === undefined) return; sent = true; + const session = options.env[EVENT_TRACE_RECEIPT_SESSION_ENV]; const receipt: EventTraceReceipt = { + ...(typeof session === 'string' && session !== '' ? { devSession: session } : {}), events, execution: options.execution, identity, diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index e2a6ff911..710c4db8e 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -12,9 +12,11 @@ import { attachHookReceipts, HookReceiptRoutes } from '../src/dev/hooks/hook-rec import { decodeHookReceipt, HOOK_RECEIPT_MALFORMED_CODE, + HOOK_RECEIPT_SESSION_CODE, HOOK_RECEIPT_TOO_LARGE_CODE, HOOK_RECEIPT_UNAUTHORIZED_CODE, HookReceiptDecodeError, + HookReceiptSessionError, lowerHookReceipt, } from '../src/dev/hooks/hook-receipts.ts'; import { diagnostic, isRequestDiagnostic, responseDiagnostic } from '../src/dev/http.ts'; @@ -23,6 +25,7 @@ import { TraceHub } from '../src/dev/trace/trace-hub.ts'; import { DEV_INSTALL_MARKER_FILE, EVENT_TRACE_RECEIPT_PATH, + EVENT_TRACE_RECEIPT_SESSION_ENV, EVENT_TRACE_RECEIPT_TOKEN_ENV, EVENT_TRACE_RECEIPT_URL_ENV, eventTraceReceiptEndpointPath, @@ -113,6 +116,25 @@ it('projects host ids from the native payload without carrying the payload', () expect(eventTraceReceiptIdentity('cursor', { session_id: ' ' })).toEqual({}); }); +const hostSessionId = 'hs_0123456789abcdef'; + +it('accepts a valid top-level devSession and rejects a malformed one with AB8266', () => { + expect(decodeHookReceipt({ ...receipt(), devSession: hostSessionId })).toEqual({ + ...receipt(), + devSession: hostSessionId, + }); + expect(lowerHookReceipt({ ...receipt(), devSession: hostSessionId })[0]?.correlation.sessionId).toBe('session-1'); + let caught: unknown; + try { + decodeHookReceipt({ ...receipt(), devSession: 'hs_nope' }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(HookReceiptSessionError); + expect((caught as HookReceiptSessionError).code).toBe(HOOK_RECEIPT_SESSION_CODE); + expect((caught as HookReceiptSessionError).path).toBe('devSession'); +}); + it('projects the lineage axis without the live tree', () => { expect(eventTraceReceiptLineage({ reason: 'no-subagent-events', state: 'unavailable' })) .toEqual({ reason: 'no-subagent-events', state: 'unavailable' }); @@ -344,6 +366,8 @@ it('refuses receipts without the token, with an Origin header, over the size cap .resolves.toEqual({ code: HOOK_RECEIPT_TOO_LARGE_CODE, status: 413 }); await expect(code(await fetch(`${new URL(EVENT_TRACE_RECEIPT_PATH, url).href}?replay=1`, { body, headers: jsonHeaders('secret-token'), method: 'POST' }))) .resolves.toEqual({ code: HOOK_RECEIPT_MALFORMED_CODE, status: 400 }); + await expect(code(await post(url, JSON.stringify({ ...receipt(), devSession: 'hs_nope' }), jsonHeaders('secret-token')))) + .resolves.toEqual({ code: HOOK_RECEIPT_SESSION_CODE, status: 400 }); expect(hub.latestSequence).toBe(0); routes.close(); @@ -462,6 +486,46 @@ it('records kernel events through the tracer and posts one bounded receipt that expect(silent).toBeUndefined(); }); +it('posts a top-level devSession when AGENT_BUNDLE_DEV_SESSION is set and keeps the host identity', async () => { + const posted: EventTraceReceipt[] = []; + const traced = eventTraceExecution({ event: 'tool/before', host: 'claude', nativeEvent: 'PreToolUse' }); + const recorder = await openEventTraceReceipt({ + anchor: 'file:///nowhere/hooks/x.mjs', + env: { + [EVENT_TRACE_RECEIPT_SESSION_ENV]: hostSessionId, + [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 't', + [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:6000', + }, + execution: traced, + fetch: async (_input, init) => { + posted.push(JSON.parse(init!.body as string) as EventTraceReceipt); + return new Response(null, { status: 204 }); + }, + }); + recorder!.identity({ session_id: 'host-session', tool_use_id: 'u' }); + createEventTracer({ execution: traced, now: () => 1, observer: recorder!.observer }).executeStart('standalone'); + await recorder!.send(); + expect(posted[0]!.devSession).toBe(hostSessionId); + expect(posted[0]!.identity).toEqual({ conversationId: 'host-session', requestId: 'u', sessionId: 'host-session' }); +}); + +it('calls attachHostSession with the validated devSession and the host identity', async () => { + const attached: [string, string | undefined][] = []; + const hub = new TraceHub({ projectRoot: '/work/project' }); + const routes = new HookReceiptRoutes({ + attachHostSession: (devSession, hostSessionId) => { + attached.push([devSession, hostSessionId]); + }, + token: 'secret-token', + trace: hub, + }); + const { url } = await listen((request, response) => routes.handle(request, response)); + const accepted = await post(url, JSON.stringify({ ...receipt(), devSession: hostSessionId }), jsonHeaders('secret-token')); + expect(accepted.status).toBe(204); + expect(attached).toEqual([[hostSessionId, 'session-1']]); + expect(hub.replay().entries[0]?.correlation.sessionId).toBe('session-1'); +}); + it('does not post a receipt when nothing was traced', async () => { let calls = 0; const recorder = await openEventTraceReceipt({ diff --git a/packages/agent-bundle/tests/host-mcp-routes.test.ts b/packages/agent-bundle/tests/host-mcp-routes.test.ts new file mode 100644 index 000000000..8123cb103 --- /dev/null +++ b/packages/agent-bundle/tests/host-mcp-routes.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from '@rstest/core'; + +import { isHostSessionId } from '../src/contracts/host-sessions.ts'; +import { hostMcpProxyRequestInit, HOST_MCP_DEV_SESSION_HEADER } from '../src/dev/host-mcp-proxy.ts'; +import { HOST_MCP_DEV_SESSION_CODE, hostDevSessionId } from '../src/dev/host-mcp-routes.ts'; +import { isRequestDiagnostic } from '../src/dev/http.ts'; + +const hostSessionId = 'hs_0123456789abcdef'; + +it('accepts only hs_ + 16 lowercase characters as a host-session id', () => { + expect(isHostSessionId(hostSessionId)).toBe(true); + expect(isHostSessionId('hs_abcdefghijklmnop')).toBe(true); + expect(isHostSessionId('hs_ABCDEFGHIJKLMNOP')).toBe(false); + expect(isHostSessionId('hs_short')).toBe(false); + expect(isHostSessionId('session-1')).toBe(false); +}); + +it('reads a valid x-agent-bundle-dev-session header and rejects a malformed value with AB8266', () => { + expect(hostDevSessionId({})).toBeUndefined(); + expect(hostDevSessionId({ [HOST_MCP_DEV_SESSION_HEADER]: hostSessionId })).toBe(hostSessionId); + let caught: unknown; + try { + hostDevSessionId({ [HOST_MCP_DEV_SESSION_HEADER]: 'hs_nope' }); + } catch (error) { + caught = error; + } + expect(isRequestDiagnostic(caught)).toBe(true); + expect(caught).toMatchObject({ code: HOST_MCP_DEV_SESSION_CODE, status: 400 }); +}); + +it('sends the proxy header only when AGENT_BUNDLE_DEV_SESSION is a valid host-session id', () => { + expect(hostMcpProxyRequestInit({})).toBeUndefined(); + expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: 'hs_nope' })).toBeUndefined(); + expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: hostSessionId })).toEqual({ + requestInit: { headers: { [HOST_MCP_DEV_SESSION_HEADER]: hostSessionId } }, + }); +}); diff --git a/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts index 0b16358f3..6e43321c3 100644 --- a/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts +++ b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts @@ -190,6 +190,36 @@ it('publishes session started and closed once from the lifecycle operations and expect(trace.published.every((entry) => entry.correlation.mcpSessionId === sessionId && entry.correlation.host === 'claude')).toBe(true); }); +it('resolves a lazy sessionId per frame and lets a frame _meta sessionId win', () => { + const resolved: string[] = []; + let current = 'hs_0123456789abcdef'; + const trace = fakePublisher(); + const sink = createMcpSessionTraceSink({ + binding, + projectRoot, + resolveSessionId: () => { + resolved.push(current); + return current; + }, + sessionId, + trace, + }); + sink(binding, frame('client', { id: 1, jsonrpc: '2.0', method: 'tools/list' }, 1)); + current = 'codex-session'; + sink(binding, frame('client', { + id: 2, + jsonrpc: '2.0', + method: 'tools/call', + params: { _meta: { 'x-codex-turn-metadata': { session_id: 'meta-session' } }, arguments: {}, name: 'search' }, + }, 2)); + sink(binding, Object.freeze({ kind: 'logging', occurredAt: 3, payload: { level: 'info' }, sequence: ++sequence })); + + expect(resolved).toHaveLength(3); + expect(trace.published[0]?.correlation.sessionId).toBe('hs_0123456789abcdef'); + expect(trace.published[1]?.correlation.sessionId).toBe('meta-session'); + expect(trace.published[2]?.correlation.sessionId).toBe('codex-session'); +}); + it('isolates a throwing trace publisher from the session trace log and its sibling sinks', () => { const seen: McpSessionTraceEntry[] = []; const throwing: TracePublisher = { diff --git a/packages/workbench/src/trace/trace-model.ts b/packages/workbench/src/trace/trace-model.ts index 679fb2e45..789e2ab79 100644 --- a/packages/workbench/src/trace/trace-model.ts +++ b/packages/workbench/src/trace/trace-model.ts @@ -95,6 +95,7 @@ const joinToken = (correlation: TraceCorrelation, key: TraceJoinKey): string | u const headlinePriority: Readonly> = Object.freeze({ hook: 0, + session: 0, invocation: 1, mcp: 2, kernel: 3, @@ -106,6 +107,7 @@ const isInvocationLevel = (entry: TraceEntry): boolean => { switch (entry.source) { case 'hook': case 'invocation': + case 'session': return true; case 'kernel': case 'mcp': @@ -258,6 +260,8 @@ export const traceSourceGlyph = (source: TraceSource): string => { return '⇄'; case 'hook': return '⚑'; + case 'session': + return '▣'; case 'log': return '≡'; case 'diagnostic': diff --git a/packages/workbench/tests/trace-model.test.ts b/packages/workbench/tests/trace-model.test.ts index 35f528737..e7f047a87 100644 --- a/packages/workbench/tests/trace-model.test.ts +++ b/packages/workbench/tests/trace-model.test.ts @@ -119,5 +119,5 @@ it('formats times to the millisecond, durations by magnitude, and kinds to short expect(traceKindLabel(sampleTraceEntries[7]!)).toBe('build started'); expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'mcp.tasks.polled', occurredAt: '2026-09-05T07:00:00.000Z', source: 'mcp', summary: 'x' }))).toBe('tasks polled'); expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'session.started', occurredAt: '2026-09-05T07:00:00.000Z', source: 'hook', summary: 'x' }))).toBe('session started'); - expect(new Set(['invocation', 'kernel', 'mcp', 'hook', 'log', 'diagnostic'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(6); + expect(new Set(['invocation', 'kernel', 'mcp', 'hook', 'session', 'log', 'diagnostic'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(7); }); diff --git a/website/docs/en/reference/dev-server-http.mdx b/website/docs/en/reference/dev-server-http.mdx index 4db8da2da..48cf7157f 100644 --- a/website/docs/en/reference/dev-server-http.mdx +++ b/website/docs/en/reference/dev-server-http.mdx @@ -75,6 +75,7 @@ interface TraceEntry { | 'kernel' | 'mcp' | 'hook' + | 'session' | 'log' | 'diagnostic'; readonly kind: string; diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 3acdd1569..4a30d1590 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -47,6 +47,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | | `AGENT_BUNDLE_DEV_TRACE_URL` | Generated hook wrappers in development | Internal loopback origin for posting a payload-free hook trace receipt. The foreground server sets it for simulations; host-invoked development wrappers normally discover the same endpoint from the development install marker. | | `AGENT_BUNDLE_DEV_TRACE_TOKEN` | Generated hook wrappers in development | Internal bearer token paired with `AGENT_BUNDLE_DEV_TRACE_URL`. It authenticates `POST /api/trace/receipts` and must not be logged or persisted outside the private development endpoint record. | +| `AGENT_BUNDLE_DEV_SESSION` | Generated hook wrappers and `agent-bundle dev proxy` in development | Host-session id (`hs_` + 16 lowercase characters) the Workbench sets on a PTY-backed host. When set, the hook wrapper posts it as the receipt `devSession` field; when valid, the proxy sends it as `x-agent-bundle-dev-session`. Must not be logged. | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | Contributor test suites | `1` runs the signed-in Codex native smoke. | diff --git a/website/docs/zh/reference/dev-server-http.mdx b/website/docs/zh/reference/dev-server-http.mdx index 77656dde7..feb6f2c10 100644 --- a/website/docs/zh/reference/dev-server-http.mdx +++ b/website/docs/zh/reference/dev-server-http.mdx @@ -69,6 +69,7 @@ interface TraceEntry { | 'kernel' | 'mcp' | 'hook' + | 'session' | 'log' | 'diagnostic'; readonly kind: string; diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index ab417ed3b..c1264cff0 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -43,6 +43,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的事件路由工作区会设置它。 | | `AGENT_BUNDLE_DEV_TRACE_URL` | 开发环境中的生成式钩子 wrapper | 提交不含载荷的钩子 Trace 收据所使用的内部 loopback origin。前台服务器为模拟调用设置它;由宿主调用的开发包装器通常从开发安装标记发现同一端点。 | | `AGENT_BUNDLE_DEV_TRACE_TOKEN` | 开发环境中的生成式钩子 wrapper | 与 `AGENT_BUNDLE_DEV_TRACE_URL` 配对的内部 bearer token。它认证 `POST /api/trace/receipts`,不得记录日志,也不得持久化到私有开发端点记录之外。 | +| `AGENT_BUNDLE_DEV_SESSION` | 开发环境中的生成式钩子 wrapper 与 `agent-bundle dev proxy` | Workbench 在 PTY 宿主进程上设置的宿主会话 id(`hs_` + 16 个小写字符)。设置后,钩子 wrapper 把它作为收据的 `devSession` 字段提交;值合法时,代理以 `x-agent-bundle-dev-session` 发送。不得记录日志。 | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | 贡献者测试套件 | `1` 用于比对已安装宿主 CLI 的契约。 | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Claude 原生冒烟测试。 | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Codex 原生冒烟测试。 | From c34c0ef7bda04847f9809fcfff0230be133233a0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:08:27 +0000 Subject: [PATCH 03/16] feat(dev): add PTY-backed host sessions --- LANE-NOTES.md | 59 +++ docs/diagnostics.md | 6 + packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 1 + .../src/contracts/host-sessions.ts | 30 ++ .../agent-bundle/src/dev/foreground-server.ts | 16 +- .../src/dev/host-install-manager.ts | 7 + .../src/dev/sessions/host-session-routes.ts | 238 ++++++++++ .../src/dev/sessions/host-session-service.ts | 421 ++++++++++++++++++ packages/agent-bundle/src/dev/sessions/pty.ts | 66 +++ .../agent-bundle/src/dev/trace/trace-entry.ts | 1 + .../agent-bundle/src/dev/workbench-server.ts | 12 + .../tests/host-session-pty.test.ts | 29 ++ .../tests/host-session-routes.test.ts | 171 +++++++ .../tests/host-session-service.test.ts | 214 +++++++++ rstest.integration-tests.ts | 1 + 16 files changed, 1274 insertions(+), 2 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/contracts/host-sessions.ts create mode 100644 packages/agent-bundle/src/dev/sessions/host-session-routes.ts create mode 100644 packages/agent-bundle/src/dev/sessions/host-session-service.ts create mode 100644 packages/agent-bundle/src/dev/sessions/pty.ts create mode 100644 packages/agent-bundle/tests/host-session-pty.test.ts create mode 100644 packages/agent-bundle/tests/host-session-routes.test.ts create mode 100644 packages/agent-bundle/tests/host-session-service.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..d5d81765a --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,59 @@ +# S1 — host-session server + +## Files + +- `packages/agent-bundle/src/contracts/host-sessions.ts` +- `packages/agent-bundle/src/dev/sessions/{pty,host-session-service,host-session-routes}.ts` +- `packages/agent-bundle/src/dev/{foreground-server,workbench-server,host-install-manager}.ts` +- `packages/agent-bundle/src/dev/trace/trace-entry.ts` +- `packages/agent-bundle/{package.json,rslib.config.ts}` +- `packages/agent-bundle/tests/host-session-{service,routes,pty}.test.ts` +- `rstest.integration-tests.ts` +- `docs/diagnostics.md` + +## Exported API + +- `agent-bundle/contracts/host-sessions` exports `HostSessionHost`, `HostSessionState`, + `HostSession`, and `HostAvailability`. +- Internal server API: `HostSessionService` (`availability`, `list`, `read`, `create`, + `input`, `resize`, `terminate`, `restart`, `forget`, `subscribe`, `attach`, + `traceSessionId`, `close`), `HostSessionRoutes`, and the fakeable `PtyAdapter` / + `PtyProcess` boundary. +- `DevHostInstallManager.attached(host)` exposes the current installed + `{ destination, epochId }`. + +## Integrator requests + +1. S2's receipt option is not present on this branch. After S2 lands, pass + `attachHostSession: (devSession, hostSessionId) => + hostSessions.attach(devSession, hostSessionId)` to `attachHookReceipts(...)`. + The current declaration of `hookReceipts` precedes `hostSessions`; move that + declaration below the service construction (the environment callback is lazy) + or use an equivalent deferred forwarding closure. +2. S2's `HostMcpRoutesOptions.traceSessionId` is not present on this branch. + After S2 lands, add + `traceSessionId: (devSession) => hostSessions.traceSessionId(devSession)` to + the `new HostMcpRoutes(...)` options in `workbench-server.ts`. +3. Both S1 and S2 append `'session'` to `traceSources`; keep one tuple entry + when merging. +4. S3 should consume `agent-bundle/contracts/host-sessions` and use + `session.traceSessionId ?? session.id` for Trace links. + +## Verification + +- `pnpm build`: pass. +- `pnpm lint`: pass. +- `pnpm exec tsc --noEmit`: pass (server/root TypeScript graph). +- Host-session unit files: 9 tests pass. +- `host-session-pty.test.ts` + `dev-workbench.test.ts`: 34 tests pass. +- `pnpm typecheck`: blocked only by S3-owned exhaustive switches after the new + trace source: `packages/workbench/src/trace/trace-model.ts` lines 96, 116, + and 266 do not yet handle `'session'`. + +## Unfinished by lane ownership + +- The two S2 wiring lines above await S2's option declarations. +- S2 owns `AB8266` and runtime-environment documentation; S4 owns the + Workbench/HTTP guide and the single PR changeset. +- No package dependency was added. The package build accepted the lazy + `createRequire(... )('@lydell/node-pty')` adapter. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4b4e46a90..c6dfa7117 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -51,6 +51,12 @@ even when no error diagnostic was reported. | `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. | | `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, `AB8252` compiled CLI projection or event preflight preparation failed, `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host surface. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`; select a generated host wrapper for `AB8255`. | | `AB8256` | Workbench route invocation cancellation (`POST /api/routes/invocations//cancel`): the invocation is already final (409). Reload the final invocation instead of cancelling it. | +| `AB8260` | Workbench host sessions: `@lydell/node-pty` could not be resolved from the project or loaded (503). Install the PTY module in the project workspace and restart `agent-bundle dev`. | +| `AB8261` | Workbench host sessions: a request body, path, query, dimension, input, or live-session delete is malformed (400/409). Send only the documented `/api/sessions` fields and forget sessions only after they exit. | +| `AB8262` | Workbench host sessions: the requested host-session id is unknown (404). Refresh the session list and use a retained id. | +| `AB8263` | Workbench host sessions: the host is not launchable because its executable is absent from `PATH`, its dev install is not attached to the adopted epoch, or PTY spawn failed (409). Install the host CLI, attach its current dev install, and retry. | +| `AB8264` | Workbench host sessions: four sessions are already live (409). Terminate a live session before launching another. | +| `AB8265` | Workbench host-session routes are closed or no service was mounted (503). Restart the development server. | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 630c95912..2fb96b246 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -61,6 +61,10 @@ "types": "./dist/config/index.d.ts", "import": "./dist/config.js" }, + "./contracts/host-sessions": { + "types": "./dist/contracts/host-sessions.d.ts", + "import": "./dist/contracts/host-sessions.js" + }, "./eval": { "types": "./dist/eval/index.d.ts", "import": "./dist/eval.js" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 2d0d2ba8e..4d183b6af 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -62,6 +62,7 @@ const publicEntries = { api: './src/api.ts', cli: './src/cli.ts', config: './src/config/index.ts', + 'contracts/host-sessions': './src/contracts/host-sessions.ts', eval: './src/eval/index.ts', index: './src/index.ts', 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', diff --git a/packages/agent-bundle/src/contracts/host-sessions.ts b/packages/agent-bundle/src/contracts/host-sessions.ts new file mode 100644 index 000000000..f6a3db22c --- /dev/null +++ b/packages/agent-bundle/src/contracts/host-sessions.ts @@ -0,0 +1,30 @@ +export type HostSessionHost = 'claude' | 'codex'; +export type HostSessionState = 'running' | 'exited' | 'terminated'; + +export interface HostSession { + readonly id: string; + readonly host: HostSessionHost; + readonly state: HostSessionState; + readonly startedAt: number; + readonly endedAt?: number; + readonly exitCode?: number; + readonly signal?: string; + readonly pid?: number; + readonly cols: number; + readonly rows: number; + readonly prompt?: string; + readonly authority: { + readonly projectRoot: string; + readonly epochId: string; + readonly install: string; + }; + readonly restartOf?: string; + readonly traceSessionId?: string; +} + +export interface HostAvailability { + readonly host: HostSessionHost; + readonly launchable: boolean; + readonly reason?: string; + readonly executable?: string; +} diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 6b79fe36b..0e3baa0c8 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -16,6 +16,7 @@ import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playgro import { HostDiscoveryRoutes, type HostDiscoveryRouteService } from './playground/host-discovery-routes.ts'; import type { HookReceiptRoutes } from './hooks/hook-receipt-endpoint.ts'; import type { HostMcpRoutes } from './host-mcp-routes.ts'; +import { HostSessionRoutes, type HostSessionRouteService } from './sessions/host-session-routes.ts'; import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts'; import { McpProbeRoutes, type McpProbeRouteService } from './playground/mcp-probe-routes.ts'; import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; @@ -89,7 +90,7 @@ export class ForegroundServerError extends Error { export interface ForegroundServerCloseFailure { readonly error: unknown; - readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'logs' | 'mcp-apps' | 'route-invocations' | 'server' | 'trace'; + readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'host-sessions' | 'logs' | 'mcp-apps' | 'route-invocations' | 'server' | 'trace'; } export interface ForegroundServerStartFailure { @@ -180,6 +181,7 @@ export interface ForegroundServerOptions { readonly hostDiscovery?: HostDiscoveryRouteService; /** Stateful MCP surface used only by stable development host proxies. */ readonly hostMcp?: HostMcpRoutes; + readonly hostSessions?: HostSessionRouteService; /** User-initiated read-only initialize and tools/list probing over trusted artifact servers. */ readonly mcpProbe?: McpProbeRouteService; /** Read-only semantic lifecycle replay over the latest valid prepared graph. */ @@ -417,6 +419,7 @@ export class ForegroundServer { readonly #hookReceiptRoutes: HookReceiptRoutes | undefined; readonly #hostDiscoveryRoutes: HostDiscoveryRoutes; readonly #hostMcpRoutes: HostMcpRoutes | undefined; + readonly #hostSessionRoutes: HostSessionRoutes; readonly #host: string; readonly #inspectorRoutes: InspectorRoutes; readonly #lifecycleReplayRoutes: LifecycleReplayRoutes; @@ -483,6 +486,10 @@ export class ForegroundServer { this.#skillDocuments = options.skillDocuments; this.#testing = options.testing; this.sessionToken = options.sessionToken ?? randomUUID(); + this.#hostSessionRoutes = new HostSessionRoutes({ + authorize: (request) => this.#assertMutationSession(request), + ...(options.hostSessions === undefined ? {} : { service: options.hostSessions }), + }); this.#workbenchDevOrigins = Object.freeze(new Set(workbenchDevOrigins)); this.#webHostRoutes = new WebHostRoutes({ authorize: (request) => this.#assertWebHostNavigation(request), @@ -726,6 +733,8 @@ export class ForegroundServer { this.#artifactRoutes.close(); const releaseRouteInvocations = this.#routeInvocationRoutes.close(); void releaseRouteInvocations.catch(() => undefined); + const releaseHostSessions = this.#hostSessionRoutes.close(); + void releaseHostSessions.catch(() => undefined); this.#routeManifestRoutes.close(); this.#lifecycleReplayRoutes.close(); const releaseEvals = this.#evalRoutes.close(); @@ -772,12 +781,13 @@ export class ForegroundServer { return closeServer(this.#server); })() : Promise.resolve(); - const [server, coordinator, evalRoutes, evalService, hookPlayground, logs, routeInvocations, trace] = await Promise.allSettled([ + const [server, coordinator, evalRoutes, evalService, hookPlayground, hostSessions, logs, routeInvocations, trace] = await Promise.allSettled([ releaseServer, releaseCoordinator, releaseEvals, releaseEvalService, releaseHookPlayground, + releaseHostSessions, releaseLogs, releaseRouteInvocations, releaseTrace, @@ -794,6 +804,7 @@ export class ForegroundServer { if (hookPlayground.status === 'rejected') { failures.push(Object.freeze({ error: hookPlayground.reason, resource: 'hook-playground' })); } + if (hostSessions.status === 'rejected') failures.push(Object.freeze({ error: hostSessions.reason, resource: 'host-sessions' })); if (logs.status === 'rejected') failures.push(Object.freeze({ error: logs.reason, resource: 'logs' })); if (routeInvocations.status === 'rejected') { failures.push(Object.freeze({ error: routeInvocations.reason, resource: 'route-invocations' })); @@ -827,6 +838,7 @@ export class ForegroundServer { if (await this.#inspectorRoutes.handle(request, response)) return; if (await this.#artifactRoutes.handle(request, response)) return; if (await this.#routeInvocationRoutes.handle(request, response)) return; + if (await this.#hostSessionRoutes.handle(request, response)) return; if (this.#routeManifestRoutes.handle(request, response)) return; if (await this.#evalRoutes.handle(request, response)) return; if (await this.#devLogRoutes.handle(request, response)) return; diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 88a662469..59323f1f4 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -330,6 +330,13 @@ export class DevHostInstallManager { this.#run = platformRunOf(options.platformRuntime); } + attached(host: InstallHost): Readonly<{ readonly destination: string; readonly epochId: string }> | undefined { + const installed = this.#installed.get(host); + return installed === undefined || installed.epochId.length === 0 + ? undefined + : Object.freeze({ destination: installed.destination, epochId: installed.epochId }); + } + start(): void { if (this.#subscription !== undefined || this.#closed) return; this.#subscription = subscribeToEpochAdoption( diff --git a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts new file mode 100644 index 000000000..f8a8a5376 --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -0,0 +1,238 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { + HostAvailability, + HostSession, + HostSessionHost, +} from '../../contracts/host-sessions.ts'; +import { + createBackpressuredWriter, + writeKeepAliveStreamHead, +} from '../route-streams.ts'; +import { + decodedOpaqueSegment, + diagnostic, + hasOnly, + noQuery, + rawPathname, + readJsonBody, + requestError, + responseDiagnostic, + responseJson, +} from '../http.ts'; +import { + HOST_SESSION_MALFORMED_CODE, + HOST_SESSION_UNAVAILABLE_CODE, + HostSessionError, + type HostSessionStreamMessage, +} from './host-session-service.ts'; + +const invalid = (): never => { + throw requestError(diagnostic(HOST_SESSION_MALFORMED_CODE, 'Host-session request has an invalid shape.', 400)); +}; + +type SessionPath = + | Readonly<{ readonly kind: 'collection' }> + | Readonly<{ readonly id: string; readonly kind: 'item' | 'stream' | 'input' | 'resize' | 'terminate' | 'restart' }>; + +const sessionPath = (target: string | undefined): SessionPath | undefined => { + const pathname = rawPathname(target); + if (pathname !== '/api/sessions' && !pathname.startsWith('/api/sessions/')) return undefined; + if (pathname === '/api/sessions') return { kind: 'collection' }; + const parts = pathname.split('/'); + if ((parts.length !== 4 && parts.length !== 5) || parts[3] === undefined) invalid(); + const id = decodedOpaqueSegment(parts[3], { + code: HOST_SESSION_MALFORMED_CODE, + maxLength: 19, + message: 'Host-session path is not valid.', + rejectBlank: true, + }); + if (!/^hs_[0-9a-z]{16}$/u.test(id)) invalid(); + if (parts.length === 4) return { id, kind: 'item' }; + const action = parts[4]; + switch (action) { + case 'stream': + case 'input': + case 'resize': + case 'terminate': + case 'restart': + return { id, kind: action }; + default: + return invalid(); + } +}; + +const jsonBody = (request: IncomingMessage) => readJsonBody(request, { + invalidShape: invalid, + read: { + code: HOST_SESSION_MALFORMED_CODE, + limit: 20 * 1024, + message: 'Host-session request exceeds 20 KiB.', + }, +}); + +const dimensions = (value: Readonly>): Readonly<{ readonly cols: number; readonly rows: number }> => { + if ( + !hasOnly(value, ['cols', 'rows']) + || !Number.isInteger(value.cols) || (value.cols as number) < 1 || (value.cols as number) > 500 + || !Number.isInteger(value.rows) || (value.rows as number) < 1 || (value.rows as number) > 500 + ) invalid(); + return { cols: value.cols as number, rows: value.rows as number }; +}; + +const creation = (value: Readonly>) => { + if (!hasOnly(value, ['cols', 'host', 'prompt', 'rows'])) invalid(); + if (value.host !== 'claude' && value.host !== 'codex') invalid(); + if (value.prompt !== undefined && typeof value.prompt !== 'string') invalid(); + return { + ...dimensions({ cols: value.cols, rows: value.rows }), + host: value.host as HostSessionHost, + ...(value.prompt === undefined ? {} : { prompt: value.prompt as string }), + }; +}; + +export interface HostSessionRouteService { + availability(): Promise; + close?(): Promise | void; + create(request: Readonly<{ readonly cols: number; readonly host: HostSessionHost; readonly prompt?: string; readonly rows: number }>): Promise; + forget(id: string): boolean; + input(id: string, data: string): void; + list(): readonly HostSession[]; + read(id: string): HostSession | undefined; + resize(id: string, cols: number, rows: number): void; + restart(id: string, size: Readonly<{ readonly cols: number; readonly rows: number }>): Promise; + subscribe(id: string, listener: (message: HostSessionStreamMessage) => void): () => void; + terminate(id: string): Promise; +} + +export interface HostSessionRoutesOptions { + readonly authorize: (request: IncomingMessage) => void; + readonly service?: HostSessionRouteService; +} + +export class HostSessionRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #service: HostSessionRouteService | undefined; + #closed = false; + + constructor(options: HostSessionRoutesOptions) { + this.#authorize = options.authorize; + this.#service = options.service; + } + + close(): Promise { + this.#closed = true; + return Promise.resolve(this.#service?.close?.()); + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const path = sessionPath(request.url); + if (path === undefined) return false; + this.#authorize(request); + if (this.#closed || this.#service === undefined) { + throw requestError(diagnostic(HOST_SESSION_UNAVAILABLE_CODE, 'Host-session routes are not available.', 503)); + } + const service = this.#service; + const method = request.method ?? 'GET'; + noQuery(request.url, invalid); + try { + if (path.kind === 'collection' && method === 'GET') { + responseJson(response, { hosts: await service.availability(), sessions: service.list() }); + return true; + } + if (path.kind === 'collection' && method === 'POST') { + responseJson(response, { session: await service.create(creation(await jsonBody(request))) }, { status: 201 }); + return true; + } + if (path.kind === 'item' && method === 'GET') { + const session = service.read(path.id); + if (session === undefined) throw new HostSessionError('AB8262', `Host session ${JSON.stringify(path.id)} was not found.`, 404); + responseJson(response, { session }); + return true; + } + if (path.kind === 'item' && method === 'DELETE') { + service.forget(path.id); + response.writeHead(204).end(); + return true; + } + if (path.kind === 'stream' && method === 'GET') { + this.#stream(service, path.id, response); + return true; + } + if (path.kind === 'input' && method === 'POST') { + const body = await jsonBody(request); + if (!hasOnly(body, ['data']) || typeof body.data !== 'string') invalid(); + service.input(path.id, body.data as string); + response.writeHead(204).end(); + return true; + } + if (path.kind === 'resize' && method === 'POST') { + const size = dimensions(await jsonBody(request)); + service.resize(path.id, size.cols, size.rows); + response.writeHead(204).end(); + return true; + } + if (path.kind === 'terminate' && method === 'POST') { + const body = await jsonBody(request); + if (!hasOnly(body, [])) invalid(); + responseJson(response, { session: await service.terminate(path.id) }); + return true; + } + if (path.kind === 'restart' && method === 'POST') { + responseJson(response, { session: await service.restart(path.id, dimensions(await jsonBody(request))) }, { status: 201 }); + return true; + } + } catch (error) { + if (error instanceof HostSessionError) { + throw requestError(diagnostic(error.code, error.message, error.status)); + } + throw error; + } + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + + #stream(service: HostSessionRouteService, id: string, response: ServerResponse): void { + let terminal = false; + const stream: { + keepAlive?: ReturnType; + unsubscribe?: () => void; + } = {}; + const finish = (): void => { + if (!terminal || !writer.idle || response.writableEnded || response.destroyed) return; + if (stream.keepAlive !== undefined) clearInterval(stream.keepAlive); + stream.unsubscribe?.(); + response.end(); + }; + const writer = createBackpressuredWriter(response, { + byteLimit: 256 * 1024, + onIdle: finish, + recordLimit: 128, + }); + const deliver = (message: HostSessionStreamMessage): void => { + const type = message.type; + const result = writer.enqueue(`event: ${type}\ndata: ${JSON.stringify( + type === 'output' ? { data: message.data } : { session: message.session }, + )}\n\n`); + if (result === 'overflow') response.destroy(); + if (type === 'end') terminal = true; + finish(); + }; + response.once('close', () => { + writer.markClosed(); + if (stream.keepAlive !== undefined) clearInterval(stream.keepAlive); + stream.unsubscribe?.(); + }); + const replay: HostSessionStreamMessage[] = []; + let replaying = true; + stream.unsubscribe = service.subscribe(id, (message) => replaying ? replay.push(message) : deliver(message)); + writeKeepAliveStreamHead(response, { + cacheControl: 'no-cache', + contentType: 'text/event-stream; charset=utf-8', + }); + replaying = false; + for (const message of replay) deliver(message); + stream.keepAlive = terminal ? undefined : setInterval(() => writer.enqueue(': keep-alive\n\n'), 15_000); + finish(); + } +} diff --git a/packages/agent-bundle/src/dev/sessions/host-session-service.ts b/packages/agent-bundle/src/dev/sessions/host-session-service.ts new file mode 100644 index 000000000..ef5942f57 --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/host-session-service.ts @@ -0,0 +1,421 @@ +import { Buffer } from 'node:buffer'; +import { randomBytes } from 'node:crypto'; +import { access } from 'node:fs/promises'; +import { delimiter, resolve } from 'node:path'; + +import type { + HostAvailability, + HostSession, + HostSessionHost, +} from '../../contracts/host-sessions.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; +import { + loadPtyAdapter, + type PtyAdapter, + type PtyProcess, +} from './pty.ts'; + +export const HOST_SESSION_PTY_UNAVAILABLE_CODE = 'AB8260'; +export const HOST_SESSION_MALFORMED_CODE = 'AB8261'; +export const HOST_SESSION_UNKNOWN_CODE = 'AB8262'; +export const HOST_SESSION_NOT_LAUNCHABLE_CODE = 'AB8263'; +export const HOST_SESSION_LIMIT_CODE = 'AB8264'; +export const HOST_SESSION_UNAVAILABLE_CODE = 'AB8265'; + +const hosts = ['claude', 'codex'] as const; +const defaultScrollbackBytes = 256 * 1024; +const defaultTerminationGraceMs = 2_000; +const inputByteLimit = 16 * 1024; +const signalNames = new Map([[9, 'SIGKILL'], [15, 'SIGTERM']]); + +export class HostSessionError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number, + ) { + super(message); + this.name = 'HostSessionError'; + } +} + +export type HostSessionStreamMessage = + | Readonly<{ readonly session: HostSession; readonly type: 'state' }> + | Readonly<{ readonly data: string; readonly type: 'output' }> + | Readonly<{ readonly session: HostSession; readonly type: 'end' }>; + +interface SessionRecord { + cols: number; + endedAt?: number; + exitCode?: number; + readonly host: HostSessionHost; + readonly id: string; + readonly install: string; + readonly epochId: string; + readonly listeners: Set<(message: HostSessionStreamMessage) => void>; + readonly output: Buffer[]; + outputBytes: number; + readonly process: PtyProcess; + readonly prompt?: string; + readonly restartOf?: string; + rows: number; + readonly startedAt: number; + state: HostSession['state']; + signal?: string; + terminating: boolean; + traceSessionId?: string; + readonly exited: PromiseWithResolvers; +} + +export interface HostSessionServiceOptions { + readonly attached: (host: HostSessionHost) => Readonly<{ readonly destination: string; readonly epochId: string }> | undefined; + readonly currentEpochId: () => string | undefined; + readonly environment?: Readonly | (() => Readonly); + readonly loadPty?: (projectRoot: string) => PtyAdapter; + readonly now?: () => number; + readonly projectRoot: string; + readonly resolveExecutable?: (host: HostSessionHost, environment: Readonly) => Promise; + readonly scrollbackBytes?: number; + readonly terminationGraceMs?: number; + readonly trace?: TracePublisher; +} + +export interface CreateHostSession { + readonly cols: number; + readonly host: HostSessionHost; + readonly prompt?: string; + readonly restartOf?: string; + readonly rows: number; +} + +const executableOnPath = async ( + host: HostSessionHost, + environment: Readonly, +): Promise => { + for (const directory of (environment.PATH ?? '').split(delimiter)) { + if (directory.length === 0) continue; + const candidate = resolve(directory, host); + try { + await access(candidate, 1); + return candidate; + } catch { + continue; + } + } + return undefined; +}; + +const signalName = (signal: number | undefined): string | undefined => + signal === undefined ? undefined : signalNames.get(signal) ?? String(signal); + +export class HostSessionService { + readonly #attached: HostSessionServiceOptions['attached']; + readonly #currentEpochId: HostSessionServiceOptions['currentEpochId']; + readonly #environment: () => Readonly; + readonly #loadPty: NonNullable; + readonly #now: () => number; + readonly #projectRoot: string; + readonly #resolveExecutable: NonNullable; + readonly #scrollbackBytes: number; + readonly #sessions = new Map(); + readonly #terminationGraceMs: number; + readonly #trace: TracePublisher | undefined; + #closed = false; + #pty: PtyAdapter | undefined; + + constructor(options: HostSessionServiceOptions) { + this.#attached = options.attached; + this.#currentEpochId = options.currentEpochId; + const environment = options.environment; + this.#environment = typeof environment === 'function' + ? environment + : () => environment ?? process.env; + this.#loadPty = options.loadPty ?? loadPtyAdapter; + this.#now = options.now ?? Date.now; + this.#projectRoot = options.projectRoot; + this.#resolveExecutable = options.resolveExecutable ?? executableOnPath; + this.#scrollbackBytes = options.scrollbackBytes ?? defaultScrollbackBytes; + this.#terminationGraceMs = options.terminationGraceMs ?? defaultTerminationGraceMs; + this.#trace = options.trace; + } + + async availability(): Promise { + let ptyFailure: HostSessionError | undefined; + try { + this.#adapter(); + } catch (error) { + if (error instanceof HostSessionError) ptyFailure = error; + else throw error; + } + return Promise.all(hosts.map(async (host): Promise => { + if (ptyFailure !== undefined) return { host, launchable: false, reason: `${ptyFailure.code}: ${ptyFailure.message}` }; + const attached = this.#attached(host); + if (attached === undefined || attached.epochId !== this.#currentEpochId()) { + return { host, launchable: false, reason: 'no dev install attached' }; + } + const executable = await this.#resolveExecutable(host, this.#environment()); + return executable === undefined + ? { host, launchable: false, reason: `${host} is not on PATH` } + : { executable, host, launchable: true }; + })); + } + + list(): readonly HostSession[] { + return [...this.#sessions.values()].map((record) => this.#snapshot(record)); + } + + read(id: string): HostSession | undefined { + const record = this.#sessions.get(id); + return record === undefined ? undefined : this.#snapshot(record); + } + + attach(devSession: string, hostSessionId: string | undefined): void { + const record = this.#sessions.get(devSession); + if ( + record === undefined + || record.state !== 'running' + || hostSessionId === undefined + || record.traceSessionId !== undefined + ) return; + record.traceSessionId = hostSessionId; + this.#publish(record, 'session.attached', 'ok', { hostSessionId }); + this.#send(record, { session: this.#snapshot(record), type: 'state' }); + } + + traceSessionId(devSession: string): string { + return this.#sessions.get(devSession)?.traceSessionId ?? devSession; + } + + async create(request: CreateHostSession): Promise { + if (this.#closed) throw new HostSessionError(HOST_SESSION_UNAVAILABLE_CODE, 'Host-session service is not available.', 503); + if ([...this.#sessions.values()].filter((record) => record.state === 'running').length >= 4) { + throw new HostSessionError(HOST_SESSION_LIMIT_CODE, 'Host-session limit reached.', 409); + } + const attached = this.#attached(request.host); + if (attached === undefined || attached.epochId !== this.#currentEpochId()) { + throw new HostSessionError(HOST_SESSION_NOT_LAUNCHABLE_CODE, `${request.host} has no dev install attached to the adopted epoch.`, 409); + } + const environment = this.#environment(); + const executable = await this.#resolveExecutable(request.host, environment); + if (executable === undefined) { + throw new HostSessionError(HOST_SESSION_NOT_LAUNCHABLE_CODE, `${request.host} is not on PATH`, 409); + } + const id = `hs_${randomBytes(8).toString('hex')}`; + let process: PtyProcess; + try { + process = this.#adapter().spawn(executable, request.prompt === undefined ? [] : [request.prompt], { + cols: request.cols, + cwd: this.#projectRoot, + env: { + ...environment, + AGENT_BUNDLE_DEV_SESSION: id, + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + }, + name: 'xterm-256color', + rows: request.rows, + }); + } catch (error) { + if (error instanceof HostSessionError) throw error; + throw new HostSessionError( + HOST_SESSION_NOT_LAUNCHABLE_CODE, + `Failed to launch ${request.host}: ${error instanceof Error ? error.message : String(error)}`, + 409, + ); + } + const record: SessionRecord = { + cols: request.cols, + epochId: attached.epochId, + exited: Promise.withResolvers(), + host: request.host, + id, + install: attached.destination, + listeners: new Set(), + output: [], + outputBytes: 0, + process, + ...(request.prompt === undefined ? {} : { prompt: request.prompt }), + ...(request.restartOf === undefined ? {} : { restartOf: request.restartOf }), + rows: request.rows, + startedAt: this.#now(), + state: 'running', + terminating: false, + }; + this.#sessions.set(id, record); + process.onData((data) => this.#output(record, Buffer.from(data))); + process.onExit((event) => this.#exit(record, event.exitCode, signalName(event.signal))); + this.#publish(record, 'session.started', 'running'); + return this.#snapshot(record); + } + + input(id: string, data: string): void { + const record = this.#running(id); + if (Buffer.byteLength(data, 'utf8') > inputByteLimit) { + throw new HostSessionError(HOST_SESSION_MALFORMED_CODE, 'Host-session input exceeds 16 KiB.', 400); + } + record.process.write(data); + } + + resize(id: string, cols: number, rows: number): void { + const record = this.#running(id); + if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || cols > 500 || rows < 1 || rows > 500) { + throw new HostSessionError(HOST_SESSION_MALFORMED_CODE, 'Host-session dimensions must be integers between 1 and 500.', 400); + } + record.cols = cols; + record.rows = rows; + record.process.resize(cols, rows); + this.#send(record, { session: this.#snapshot(record), type: 'state' }); + } + + async terminate(id: string): Promise { + const record = this.#known(id); + if (record.state !== 'running') return this.#snapshot(record); + if (!record.terminating) { + record.terminating = true; + record.process.kill('SIGTERM'); + const timer = setTimeout(() => { + if (record.state === 'running') record.process.kill('SIGKILL'); + }, this.#terminationGraceMs); + await record.exited.promise; + clearTimeout(timer); + } else { + await record.exited.promise; + } + return this.#snapshot(record); + } + + async restart(id: string, size: Readonly<{ readonly cols: number; readonly rows: number }>): Promise { + const previous = this.#known(id); + if (previous.state === 'running') await this.terminate(id); + return this.create({ + cols: size.cols, + host: previous.host, + ...(previous.prompt === undefined ? {} : { prompt: previous.prompt }), + restartOf: id, + rows: size.rows, + }); + } + + forget(id: string): boolean { + const record = this.#known(id); + if (record.state === 'running') { + throw new HostSessionError(HOST_SESSION_MALFORMED_CODE, 'A live host session cannot be forgotten.', 409); + } + return this.#sessions.delete(id); + } + + subscribe(id: string, listener: (message: HostSessionStreamMessage) => void): () => void { + const record = this.#known(id); + listener({ session: this.#snapshot(record), type: 'state' }); + for (const output of record.output) listener({ data: output.toString('base64'), type: 'output' }); + if (record.state === 'running') record.listeners.add(listener); + else listener({ session: this.#snapshot(record), type: 'end' }); + return () => record.listeners.delete(listener); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await Promise.all([...this.#sessions.values()] + .filter((record) => record.state === 'running') + .map((record) => this.terminate(record.id))); + } + + #adapter(): PtyAdapter { + if (this.#pty !== undefined) return this.#pty; + try { + this.#pty = this.#loadPty(this.#projectRoot); + return this.#pty; + } catch { + throw new HostSessionError(HOST_SESSION_PTY_UNAVAILABLE_CODE, 'PTY module @lydell/node-pty is unavailable.', 503); + } + } + + #known(id: string): SessionRecord { + const record = this.#sessions.get(id); + if (record === undefined) throw new HostSessionError(HOST_SESSION_UNKNOWN_CODE, `Host session ${JSON.stringify(id)} was not found.`, 404); + return record; + } + + #running(id: string): SessionRecord { + const record = this.#known(id); + if (record.state !== 'running') throw new HostSessionError(HOST_SESSION_MALFORMED_CODE, 'Host session is not running.', 409); + return record; + } + + #snapshot(record: SessionRecord): HostSession { + return Object.freeze({ + authority: Object.freeze({ epochId: record.epochId, install: record.install, projectRoot: this.#projectRoot }), + cols: record.cols, + ...(record.endedAt === undefined ? {} : { endedAt: record.endedAt }), + ...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }), + host: record.host, + id: record.id, + ...(record.state === 'running' ? { pid: record.process.pid } : {}), + ...(record.prompt === undefined ? {} : { prompt: record.prompt }), + ...(record.restartOf === undefined ? {} : { restartOf: record.restartOf }), + rows: record.rows, + startedAt: record.startedAt, + state: record.state, + ...(record.signal === undefined ? {} : { signal: record.signal }), + ...(record.traceSessionId === undefined ? {} : { traceSessionId: record.traceSessionId }), + }); + } + + #output(record: SessionRecord, chunk: Buffer): void { + const retained = chunk.byteLength > this.#scrollbackBytes ? chunk.subarray(chunk.byteLength - this.#scrollbackBytes) : chunk; + record.output.push(Buffer.from(retained)); + record.outputBytes += retained.byteLength; + while (record.outputBytes > this.#scrollbackBytes && record.output.length > 1) { + record.outputBytes -= record.output.shift()!.byteLength; + } + const excess = record.outputBytes - this.#scrollbackBytes; + if (excess > 0 && record.output[0] !== undefined) { + record.output[0] = record.output[0].subarray(excess); + record.outputBytes -= excess; + } + this.#send(record, { data: chunk.toString('base64'), type: 'output' }); + } + + #exit(record: SessionRecord, exitCode: number, signal: string | undefined): void { + if (record.state !== 'running') return; + record.state = record.terminating ? 'terminated' : 'exited'; + record.endedAt = this.#now(); + record.exitCode = exitCode; + record.signal = record.terminating ? signal ?? 'SIGTERM' : signal; + const session = this.#snapshot(record); + this.#send(record, { session, type: 'state' }); + this.#send(record, { session, type: 'end' }); + record.listeners.clear(); + record.exited.resolve(); + this.#publish(record, record.terminating ? 'session.terminated' : 'session.exited', 'ok'); + } + + #send(record: SessionRecord, message: HostSessionStreamMessage): void { + for (const listener of record.listeners) listener(message); + } + + #publish( + record: SessionRecord, + kind: string, + status: 'running' | 'ok', + extras: Readonly<{ readonly hostSessionId?: string }> = {}, + ): void { + this.#trace?.publish({ + correlation: { epochId: record.epochId, host: record.host, sessionId: this.traceSessionId(record.id) }, + details: { + ...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }), + host: record.host, + ...(extras.hostSessionId === undefined ? {} : { hostSessionId: extras.hostSessionId }), + ...(record.state === 'running' ? { pid: record.process.pid } : {}), + ...(record.restartOf === undefined ? {} : { restartOf: record.restartOf }), + ...(record.signal === undefined ? {} : { signal: record.signal }), + }, + href: `/sessions?session=${record.id}`, + kind, + source: 'session', + status, + summary: `${record.host} session ${record.state}`, + }); + } +} diff --git a/packages/agent-bundle/src/dev/sessions/pty.ts b/packages/agent-bundle/src/dev/sessions/pty.ts new file mode 100644 index 000000000..b731daebd --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/pty.ts @@ -0,0 +1,66 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +export interface PtySpawnOptions { + readonly name: 'xterm-256color'; + readonly cols: number; + readonly rows: number; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; +} + +export interface PtyProcess { + readonly pid: number; + onData(listener: (data: string) => void): () => void; + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: NodeJS.Signals): void; +} + +export interface PtyAdapter { + spawn(file: string, args: readonly string[], options: PtySpawnOptions): PtyProcess; +} + +interface NativeDisposable { + dispose(): void; +} + +interface NativePty { + readonly pid: number; + onData(listener: (data: string) => void): NativeDisposable; + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): NativeDisposable; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: NodeJS.Signals): void; +} + +interface NativePtyModule { + spawn(file: string, args: string[], options: PtySpawnOptions): NativePty; +} + +const wrap = (process: NativePty): PtyProcess => ({ + pid: process.pid, + kill: (signal) => process.kill(signal), + onData: (listener) => { + const subscription = process.onData(listener); + return () => subscription.dispose(); + }, + onExit: (listener) => { + const subscription = process.onExit(listener); + return () => subscription.dispose(); + }, + resize: (cols, rows) => process.resize(cols, rows), + write: (data) => process.write(data), +}); + +export const loadPtyAdapter = (projectRoot: string): PtyAdapter => { + let native: NativePtyModule; + try { + native = createRequire(join(projectRoot, 'package.json'))('@lydell/node-pty') as NativePtyModule; + } catch { + native = createRequire(import.meta.url)('@lydell/node-pty') as NativePtyModule; + } + if (typeof native.spawn !== 'function') throw new TypeError('@lydell/node-pty does not export spawn.'); + return { spawn: (file, args, options) => wrap(native.spawn(file, [...args], options)) }; +}; diff --git a/packages/agent-bundle/src/dev/trace/trace-entry.ts b/packages/agent-bundle/src/dev/trace/trace-entry.ts index d18f4af11..519df736a 100644 --- a/packages/agent-bundle/src/dev/trace/trace-entry.ts +++ b/packages/agent-bundle/src/dev/trace/trace-entry.ts @@ -13,6 +13,7 @@ export const traceSources = Object.freeze([ 'log', /** Build, contract-gate, and host-attach diagnostics. */ 'diagnostic', + 'session', ] as const); export type TraceSource = (typeof traceSources)[number]; diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 2029210e0..4b3547a19 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -20,6 +20,7 @@ import { attachHookReceipts } from './hooks/hook-receipt-endpoint.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; import { DevHostInstallManager } from './host-install-manager.ts'; +import { HostSessionService } from './sessions/host-session-service.ts'; import { HostDiscoveryService, type HostDiscoveryServiceOptions, @@ -831,6 +832,16 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, platformRuntime, }); + const hostSessions = new HostSessionService({ + attached: (host) => hostInstalls?.attached(host), + currentEpochId: () => epochAdoption.currentEpochId, + environment: () => ({ + ...process.env, + ...(hookReceiptUrl === undefined ? {} : hookReceipts.environment(hookReceiptUrl)), + }), + projectRoot: root, + trace: traceHub, + }); const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions }); const hookPlayground = new HookPlaygroundService({ epochStore, @@ -1046,6 +1057,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun hookReceipts: hookReceipts.routes, hostDiscovery, hostMcp, + hostSessions, inspector, lifecycleReplay, logs, diff --git a/packages/agent-bundle/tests/host-session-pty.test.ts b/packages/agent-bundle/tests/host-session-pty.test.ts new file mode 100644 index 000000000..7a5cd29a3 --- /dev/null +++ b/packages/agent-bundle/tests/host-session-pty.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from '@rstest/core'; + +import { loadPtyAdapter } from '../src/dev/sessions/pty.ts'; + +it('runs input, resize, output, and termination through a real PTY', { timeout: 15_000 }, async () => { + const process = loadPtyAdapter(import.meta.dirname).spawn( + '/bin/sh', + ['-c', 'echo ready; stty size; read line; echo got:$line; sleep 30'], + { + cols: 80, + cwd: import.meta.dirname, + env: { ...globalThis.process.env, TERM: 'xterm-256color' }, + name: 'xterm-256color', + rows: 24, + }, + ); + let output = ''; + process.onData((data) => { output += data; }); + const exited = Promise.withResolvers(); + process.onExit(() => exited.resolve()); + + await expect.poll(() => output).toContain('ready'); + expect(output).toMatch(/24\s+80/u); + process.resize(100, 30); + process.write('hello\n'); + await expect.poll(() => output).toContain('got:hello'); + process.kill('SIGTERM'); + await exited.promise; +}); diff --git a/packages/agent-bundle/tests/host-session-routes.test.ts b/packages/agent-bundle/tests/host-session-routes.test.ts new file mode 100644 index 000000000..8e5fa9ffd --- /dev/null +++ b/packages/agent-bundle/tests/host-session-routes.test.ts @@ -0,0 +1,171 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { expect, it } from '@rstest/core'; + +import { diagnostic, isRequestDiagnostic, requestError, responseDiagnostic } from '../src/dev/http.ts'; +import { HostSessionRoutes } from '../src/dev/sessions/host-session-routes.ts'; +import { HostSessionService } from '../src/dev/sessions/host-session-service.ts'; +import type { PtyAdapter, PtyProcess, PtySpawnOptions } from '../src/dev/sessions/pty.ts'; + +class FakePty implements PtyProcess { + readonly #data = new Set<(data: string) => void>(); + readonly #exit = new Set<(event: { readonly exitCode: number; readonly signal?: number }) => void>(); + constructor(readonly pid: number) {} + emitData(data: string): void { for (const listener of this.#data) listener(data); } + emitExit(exitCode: number, signal?: number): void { + for (const listener of this.#exit) listener({ exitCode, ...(signal === undefined ? {} : { signal }) }); + } + kill(): void {} + onData(listener: (data: string) => void): () => void { + this.#data.add(listener); + return () => this.#data.delete(listener); + } + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void { + this.#exit.add(listener); + return () => this.#exit.delete(listener); + } + resize(): void {} + write(): void {} +} + +class FakeAdapter implements PtyAdapter { + readonly processes: FakePty[] = []; + spawn(_file: string, _args: readonly string[], _options: PtySpawnOptions): PtyProcess { + const process = new FakePty(5_000 + this.processes.length); + this.processes.push(process); + return process; + } +} + +const startRoutes = async (authorized = true, withService = true) => { + const adapter = new FakeAdapter(); + const service = new HostSessionService({ + attached: () => ({ destination: '/host/install', epochId: 'epoch-a' }), + currentEpochId: () => 'epoch-a', + loadPty: () => adapter, + projectRoot: '/work/project', + resolveExecutable: async (host) => `/usr/bin/${host}`, + }); + const routes = new HostSessionRoutes({ + authorize: () => { + if (!authorized) throw requestError(diagnostic('AB8004', 'refused', 403)); + }, + ...(withService ? { service } : {}), + }); + const server = createServer((request, response) => { + void routes.handle(request, response).then((handled) => { + if (!handled) response.writeHead(404).end(); + }).catch((error: unknown) => responseDiagnostic( + response, + isRequestDiagnostic(error) ? error : diagnostic('TEST', String(error), 500), + )); + }); + await new Promise((resolvePromise) => server.listen({ host: '127.0.0.1', port: 0 }, resolvePromise)); + const address = server.address() as AddressInfo; + return { + adapter, + close: async () => { + const closing = routes.close(); + for (const process of adapter.processes) process.emitExit(0, 15); + await closing; + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + }, + service, + url: `http://127.0.0.1:${address.port}`, + }; +}; + +const json = { 'content-type': 'application/json' }; + +it('serves the frozen collection, item, control, restart, and delete contract', async () => { + const started = await startRoutes(); + try { + const created = await fetch(`${started.url}/api/sessions`, { + body: JSON.stringify({ cols: 80, host: 'claude', prompt: 'hello', rows: 24 }), + headers: json, + method: 'POST', + }); + expect(created.status).toBe(201); + const body = await created.json() as { readonly session: { readonly id: string } }; + + await expect(fetch(`${started.url}/api/sessions`).then((response) => response.json())).resolves.toMatchObject({ + hosts: [{ host: 'claude', launchable: true }, { host: 'codex', launchable: true }], + sessions: [{ id: body.session.id }], + }); + await expect(fetch(`${started.url}/api/sessions/${body.session.id}`).then((response) => response.json())) + .resolves.toMatchObject({ session: { id: body.session.id } }); + expect((await fetch(`${started.url}/api/sessions/${body.session.id}/input`, { + body: JSON.stringify({ data: 'typed' }), headers: json, method: 'POST', + })).status).toBe(204); + expect((await fetch(`${started.url}/api/sessions/${body.session.id}/resize`, { + body: JSON.stringify({ cols: 100, rows: 40 }), headers: json, method: 'POST', + })).status).toBe(204); + expect((await fetch(`${started.url}/api/sessions/${body.session.id}`, { method: 'DELETE' })).status).toBe(409); + + const terminating = fetch(`${started.url}/api/sessions/${body.session.id}/terminate`, { + body: '{}', headers: json, method: 'POST', + }); + started.adapter.processes[0]!.emitExit(0, 15); + expect((await terminating).status).toBe(200); + const restarted = await fetch(`${started.url}/api/sessions/${body.session.id}/restart`, { + body: JSON.stringify({ cols: 120, rows: 50 }), headers: json, method: 'POST', + }); + expect(restarted.status).toBe(201); + await expect(restarted.json()).resolves.toMatchObject({ + session: { prompt: 'hello', restartOf: body.session.id }, + }); + } finally { + await started.close(); + } +}); + +it('refuses unauthorized, malformed, unknown, and unavailable requests', async () => { + const unauthorized = await startRoutes(false); + const started = await startRoutes(); + const unavailable = await startRoutes(true, false); + try { + expect((await fetch(`${unauthorized.url}/api/sessions`)).status).toBe(403); + for (const request of [ + fetch(`${started.url}/api/sessions`, { + body: JSON.stringify({ cols: 80, host: 'claude', rows: 24, surprise: true }), + headers: json, + method: 'POST', + }), + fetch(`${started.url}/api/sessions/bad%2Fid`), + fetch(`${started.url}/api/sessions?extra=true`), + ]) { + const response = await request; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ diagnostic: { code: 'AB8261' } }); + } + const unknown = await fetch(`${started.url}/api/sessions/hs_0000000000000000`); + expect(unknown.status).toBe(404); + await expect(unknown.json()).resolves.toMatchObject({ diagnostic: { code: 'AB8262' } }); + const closed = await fetch(`${unavailable.url}/api/sessions`); + expect(closed.status).toBe(503); + await expect(closed.json()).resolves.toMatchObject({ diagnostic: { code: 'AB8265' } }); + } finally { + await Promise.all([unauthorized.close(), started.close(), unavailable.close()]); + } +}); + +it('replays scrollback before live SSE output and closes after end', async () => { + const started = await startRoutes(); + try { + const session = await started.service.create({ cols: 80, host: 'codex', rows: 24 }); + started.adapter.processes[0]!.emitData('old'); + const response = await fetch(`${started.url}/api/sessions/${session.id}/stream`); + const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); + started.adapter.processes[0]!.emitData('live'); + started.adapter.processes[0]!.emitExit(0); + let text = ''; + while (!text.includes('event: end')) text += (await reader.read()).value ?? ''; + expect(text.indexOf(Buffer.from('old').toString('base64'))) + .toBeLessThan(text.indexOf(Buffer.from('live').toString('base64'))); + expect(text).toContain('event: state'); + expect(text).toContain('event: end'); + } finally { + await started.close(); + } +}); diff --git a/packages/agent-bundle/tests/host-session-service.test.ts b/packages/agent-bundle/tests/host-session-service.test.ts new file mode 100644 index 000000000..3624f0511 --- /dev/null +++ b/packages/agent-bundle/tests/host-session-service.test.ts @@ -0,0 +1,214 @@ +import { expect, it } from '@rstest/core'; + +import type { HostSessionStreamMessage } from '../src/dev/sessions/host-session-service.ts'; +import { + HostSessionError, + HostSessionService, +} from '../src/dev/sessions/host-session-service.ts'; +import type { + PtyAdapter, + PtyProcess, + PtySpawnOptions, +} from '../src/dev/sessions/pty.ts'; + +class FakePty implements PtyProcess { + readonly kills: NodeJS.Signals[] = []; + readonly resizes: Array = []; + readonly writes: string[] = []; + readonly #data = new Set<(data: string) => void>(); + readonly #exit = new Set<(event: { readonly exitCode: number; readonly signal?: number }) => void>(); + + constructor(readonly pid: number) {} + + emitData(data: string): void { + for (const listener of this.#data) listener(data); + } + + emitExit(exitCode: number, signal?: number): void { + for (const listener of this.#exit) listener({ exitCode, ...(signal === undefined ? {} : { signal }) }); + } + + kill(signal: NodeJS.Signals = 'SIGTERM'): void { + this.kills.push(signal); + } + + onData(listener: (data: string) => void): () => void { + this.#data.add(listener); + return () => this.#data.delete(listener); + } + + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void { + this.#exit.add(listener); + return () => this.#exit.delete(listener); + } + + resize(cols: number, rows: number): void { + this.resizes.push([cols, rows]); + } + + write(data: string): void { + this.writes.push(data); + } +} + +class FakeAdapter implements PtyAdapter { + readonly spawns: Array<{ + readonly args: readonly string[]; + readonly file: string; + readonly options: PtySpawnOptions; + readonly pty: FakePty; + }> = []; + + spawn(file: string, args: readonly string[], options: PtySpawnOptions): PtyProcess { + const pty = new FakePty(4_000 + this.spawns.length); + this.spawns.push({ args, file, options, pty }); + return pty; + } +} + +const serviceFor = (adapter: FakeAdapter, overrides: Partial[0]> = {}) => + new HostSessionService({ + attached: () => ({ destination: '/host/install', epochId: 'epoch-a' }), + currentEpochId: () => 'epoch-a', + environment: { + AGENT_BUNDLE_DEV_TRACE_TOKEN: 'trace-token', + AGENT_BUNDLE_DEV_TRACE_URL: 'http://127.0.0.1/trace', + PATH: '/bin', + }, + loadPty: () => adapter, + now: () => 1_780_000_000_000, + projectRoot: '/work/project', + resolveExecutable: async (host) => `/usr/bin/${host}`, + ...overrides, + }); + +it('launches fixed host argv and exposes authority and environment', async () => { + const adapter = new FakeAdapter(); + const traces: unknown[] = []; + const service = serviceFor(adapter, { + trace: { publish: (entry) => traces.push(entry) as never }, + }); + + const session = await service.create({ cols: 120, host: 'claude', prompt: 'inspect it', rows: 32 }); + + expect(session).toMatchObject({ + authority: { epochId: 'epoch-a', install: '/host/install', projectRoot: '/work/project' }, + cols: 120, + host: 'claude', + prompt: 'inspect it', + rows: 32, + state: 'running', + }); + expect(session.id).toMatch(/^hs_[0-9a-z]{16}$/u); + expect(adapter.spawns[0]).toMatchObject({ + args: ['inspect it'], + file: '/usr/bin/claude', + options: { cols: 120, cwd: '/work/project', name: 'xterm-256color', rows: 32 }, + }); + expect(adapter.spawns[0]?.options.env).toMatchObject({ + AGENT_BUNDLE_DEV_SESSION: session.id, + AGENT_BUNDLE_DEV_TRACE_TOKEN: 'trace-token', + AGENT_BUNDLE_DEV_TRACE_URL: 'http://127.0.0.1/trace', + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + }); + expect(JSON.stringify(traces)).not.toContain('inspect it'); + expect(traces).toMatchObject([{ + correlation: { epochId: 'epoch-a', host: 'claude', sessionId: session.id }, + details: { host: 'claude', pid: 4_000 }, + href: `/sessions?session=${session.id}`, + kind: 'session.started', + source: 'session', + }]); +}); + +it('reports unavailable hosts and enforces four live sessions', async () => { + const missing = serviceFor(new FakeAdapter(), { resolveExecutable: async () => undefined }); + await expect(missing.availability()).resolves.toMatchObject([ + { host: 'claude', launchable: false, reason: 'claude is not on PATH' }, + { host: 'codex', launchable: false, reason: 'codex is not on PATH' }, + ]); + await expect(missing.create({ cols: 80, host: 'claude', rows: 24 })).rejects.toMatchObject({ code: 'AB8263' }); + + const service = serviceFor(new FakeAdapter()); + for (let index = 0; index < 4; index += 1) { + await service.create({ cols: 80, host: index % 2 === 0 ? 'claude' : 'codex', rows: 24 }); + } + await expect(service.create({ cols: 80, host: 'claude', rows: 24 })).rejects.toMatchObject({ code: 'AB8264' }); +}); + +it('replays bounded scrollback before live output', async () => { + const adapter = new FakeAdapter(); + const service = serviceFor(adapter, { scrollbackBytes: 8 }); + const session = await service.create({ cols: 80, host: 'codex', rows: 24 }); + adapter.spawns[0]!.pty.emitData('discard'); + adapter.spawns[0]!.pty.emitData('retained'); + const messages: HostSessionStreamMessage[] = []; + + const unsubscribe = service.subscribe(session.id, (message) => messages.push(message)); + adapter.spawns[0]!.pty.emitData('live'); + + expect(messages.map((message) => message.type)).toEqual(['state', 'output', 'output']); + expect(messages.slice(1)).toEqual([ + { data: Buffer.from('retained').toString('base64'), type: 'output' }, + { data: Buffer.from('live').toString('base64'), type: 'output' }, + ]); + unsubscribe(); +}); + +it('attaches the host trace id and uses it for later lifecycle entries', async () => { + const adapter = new FakeAdapter(); + const traces: Array<{ readonly correlation?: { readonly sessionId?: string }; readonly kind?: string }> = []; + const service = serviceFor(adapter, { + trace: { publish: (entry) => traces.push(entry) as never }, + }); + const session = await service.create({ cols: 80, host: 'claude', rows: 24 }); + const messages: HostSessionStreamMessage[] = []; + service.subscribe(session.id, (message) => messages.push(message)); + + service.attach(session.id, 'host-session-42'); + service.attach('hs_0000000000000000', 'ignored'); + service.attach(session.id, undefined); + + expect(service.traceSessionId(session.id)).toBe('host-session-42'); + expect(service.traceSessionId('hs_0000000000000000')).toBe('hs_0000000000000000'); + expect(service.read(session.id)).toMatchObject({ traceSessionId: 'host-session-42' }); + expect(messages.at(-1)).toMatchObject({ session: { traceSessionId: 'host-session-42' }, type: 'state' }); + expect(traces.at(-1)).toMatchObject({ + correlation: { sessionId: 'host-session-42' }, + details: { host: 'claude', hostSessionId: 'host-session-42' }, + kind: 'session.attached', + }); + + adapter.spawns[0]!.pty.emitExit(0); + expect(traces.at(-1)).toMatchObject({ + correlation: { sessionId: 'host-session-42' }, + kind: 'session.exited', + }); +}); + +it('terminates with SIGKILL fallback and restarts with the prompt', async () => { + const adapter = new FakeAdapter(); + const service = serviceFor(adapter, { terminationGraceMs: 5 }); + const original = await service.create({ cols: 90, host: 'claude', prompt: 'seed', rows: 30 }); + const terminating = service.terminate(original.id); + await expect.poll(() => adapter.spawns[0]?.pty.kills).toEqual(['SIGTERM', 'SIGKILL']); + adapter.spawns[0]!.pty.emitExit(137, 9); + await expect(terminating).resolves.toMatchObject({ signal: 'SIGKILL', state: 'terminated' }); + + const restarted = await service.restart(original.id, { cols: 100, rows: 40 }); + expect(restarted).toMatchObject({ prompt: 'seed', restartOf: original.id, state: 'running' }); + expect(adapter.spawns[1]).toMatchObject({ args: ['seed'], options: { cols: 100, rows: 40 } }); +}); + +it('validates controls and forgets only exited sessions', async () => { + const adapter = new FakeAdapter(); + const service = serviceFor(adapter); + const session = await service.create({ cols: 80, host: 'codex', rows: 24 }); + expect(() => service.input(session.id, 'x'.repeat(16 * 1024 + 1))).toThrow(HostSessionError); + expect(() => service.resize(session.id, 0, 24)).toThrow(HostSessionError); + expect(() => service.forget(session.id)).toThrow(expect.objectContaining({ code: 'AB8261', status: 409 })); + adapter.spawns[0]!.pty.emitExit(0); + expect(service.forget(session.id)).toBe(true); + expect(service.read(session.id)).toBeUndefined(); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 53dc20a30..bfea76bb7 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -60,6 +60,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-mcp-proxy.test.ts', 'packages/agent-bundle/tests/host-install-proof.test.ts', 'packages/agent-bundle/tests/host-install-session.test.ts', + 'packages/agent-bundle/tests/host-session-pty.test.ts', 'packages/agent-bundle/tests/integration-matrix.test.ts', 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', From 974df95b0867b4c973c1fa6f61f1fe0a90c6bfd1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:17:16 +0000 Subject: [PATCH 04/16] workbench(trace): add the session trace source and link hs_ groups to the Sessions pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit traceSources gains 'session'; the Trace page headlines a host-session group by its lifecycle entry (glyph ▣) and offers a Session link back to /sessions?session= whenever a group is joined on an hs_ session id. --- .../agent-bundle/src/dev/trace/trace-entry.ts | 2 ++ packages/workbench/src/trace/trace-model.ts | 32 +++++++++++++++---- packages/workbench/src/trace/trace-page.css | 1 + packages/workbench/src/trace/trace-page.tsx | 16 +++++----- packages/workbench/tests/trace-model.test.ts | 22 ++++++++++++- packages/workbench/tests/trace-page.test.ts | 14 +++++++- 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/packages/agent-bundle/src/dev/trace/trace-entry.ts b/packages/agent-bundle/src/dev/trace/trace-entry.ts index d18f4af11..7e72b0316 100644 --- a/packages/agent-bundle/src/dev/trace/trace-entry.ts +++ b/packages/agent-bundle/src/dev/trace/trace-entry.ts @@ -13,6 +13,8 @@ export const traceSources = Object.freeze([ 'log', /** Build, contract-gate, and host-attach diagnostics. */ 'diagnostic', + /** Lifecycle of a Workbench-launched host session (`dev/sessions`). */ + 'session', ] as const); export type TraceSource = (typeof traceSources)[number]; diff --git a/packages/workbench/src/trace/trace-model.ts b/packages/workbench/src/trace/trace-model.ts index 679fb2e45..98fbfd3b4 100644 --- a/packages/workbench/src/trace/trace-model.ts +++ b/packages/workbench/src/trace/trace-model.ts @@ -4,6 +4,7 @@ import type { TraceSource, TraceStatus, } from '../../../agent-bundle/src/contracts/trace.ts'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; /** Matches `TraceHub`'s default retention so the page never holds more than the server does. */ export const maximumTraceEntries = 4_096; @@ -94,18 +95,20 @@ const joinToken = (correlation: TraceCorrelation, key: TraceJoinKey): string | u }; const headlinePriority: Readonly> = Object.freeze({ - hook: 0, - invocation: 1, - mcp: 2, - kernel: 3, - diagnostic: 4, - log: 5, + session: 0, + hook: 1, + invocation: 2, + mcp: 3, + kernel: 4, + diagnostic: 5, + log: 6, }); const isInvocationLevel = (entry: TraceEntry): boolean => { switch (entry.source) { case 'hook': case 'invocation': + case 'session': return true; case 'kernel': case 'mcp': @@ -262,6 +265,8 @@ export const traceSourceGlyph = (source: TraceSource): string => { return '≡'; case 'diagnostic': return '⚠'; + case 'session': + return '▣'; default: { const exhaustive: never = source; return exhaustive; @@ -269,6 +274,18 @@ export const traceSourceGlyph = (source: TraceSource): string => { } }; +/** The join value behind a group key (`sessionId:hs_1` → `hs_1`). */ +export const traceGroupKeyValue = (group: TraceGroup): string => { + const separator = group.key.indexOf(':'); + return separator === -1 ? group.key : group.key.slice(separator + 1); +}; + +/** A group joined on a Workbench host-session id (`hs_…`) links back to that session in the Sessions pane. */ +export const traceGroupSessionLocation = (group: TraceGroup): WorkbenchLocation | undefined => { + const value = traceGroupKeyValue(group); + return group.keyKind === 'sessionId' && value.startsWith('hs_') ? Object.freeze({ area: 'sessions', session: value }) : undefined; +}; + const kindLabels: ReadonlyMap = new Map([ ['invocation.started', 'invocation started'], ['invocation.completed', 'invocation completed'], @@ -294,6 +311,9 @@ const kindLabels: ReadonlyMap = new Map([ ['hook.failed', 'hook failed'], ['session.started', 'session started'], ['session.ended', 'session ended'], + ['session.attached', 'session attached'], + ['session.exited', 'session exited'], + ['session.terminated', 'session terminated'], ['diagnostic.build.failed', 'build failed'], ['diagnostic.contract.failed', 'contract failed'], ['diagnostic.host.sync', 'host sync'], diff --git a/packages/workbench/src/trace/trace-page.css b/packages/workbench/src/trace/trace-page.css index 0cd629192..ccc20449a 100644 --- a/packages/workbench/src/trace/trace-page.css +++ b/packages/workbench/src/trace/trace-page.css @@ -39,6 +39,7 @@ .trace-glyph--hook { color: #147b36; } .trace-glyph--log { color: #596372; } .trace-glyph--diagnostic { color: #b31b23; } +.trace-glyph--session { color: #8a5700; } .trace-link { color: #0759c7; font-weight: 700; text-decoration: none; } .trace-link:hover { text-decoration: underline; } .trace-status { font-size: 12px; font-weight: 750; text-transform: capitalize; } diff --git a/packages/workbench/src/trace/trace-page.tsx b/packages/workbench/src/trace/trace-page.tsx index cac96470f..e6b51b6a3 100644 --- a/packages/workbench/src/trace/trace-page.tsx +++ b/packages/workbench/src/trace/trace-page.tsx @@ -10,6 +10,8 @@ import { groupTraceEntries, selectTraceEntry, selectTraceGroup, + traceGroupKeyValue, + traceGroupSessionLocation, traceKindLabel, traceSourceGlyph, type TraceGroup, @@ -51,11 +53,6 @@ const groupKeyLabel = (kind: TraceGroupKeyKind): string => { } }; -const groupKeyValue = (group: TraceGroup): string => { - const separator = group.key.indexOf(':'); - return separator === -1 ? group.key : group.key.slice(separator + 1); -}; - const splitHref = (href: string): readonly [string, string] => { const index = href.indexOf('?'); return index === -1 ? [href, ''] : [href.slice(0, index), href.slice(index)]; @@ -85,8 +82,9 @@ const GroupView = ({ correlation, group, onNavigate, selected, selectedEntryId, readonly selected: boolean; readonly selectedEntryId: string | undefined; readonly timeZone: string | undefined; -}) => -
{ + const session = traceGroupSessionLocation(group); + return ; +}; const DetailDrawer = ({ correlation, entry, onNavigate, timeZone }: { readonly correlation: string | undefined; diff --git a/packages/workbench/tests/trace-model.test.ts b/packages/workbench/tests/trace-model.test.ts index 35f528737..238e277ae 100644 --- a/packages/workbench/tests/trace-model.test.ts +++ b/packages/workbench/tests/trace-model.test.ts @@ -8,6 +8,8 @@ import { mergeTraceEntries, selectTraceEntry, selectTraceGroup, + traceGroupKeyValue, + traceGroupSessionLocation, traceKindLabel, traceSourceGlyph, } from '../src/trace/trace-model.ts'; @@ -119,5 +121,23 @@ it('formats times to the millisecond, durations by magnitude, and kinds to short expect(traceKindLabel(sampleTraceEntries[7]!)).toBe('build started'); expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'mcp.tasks.polled', occurredAt: '2026-09-05T07:00:00.000Z', source: 'mcp', summary: 'x' }))).toBe('tasks polled'); expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'session.started', occurredAt: '2026-09-05T07:00:00.000Z', source: 'hook', summary: 'x' }))).toBe('session started'); - expect(new Set(['invocation', 'kernel', 'mcp', 'hook', 'log', 'diagnostic'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(6); + expect(new Set(['invocation', 'kernel', 'mcp', 'hook', 'log', 'diagnostic', 'session'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(7); + expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'session.exited', occurredAt: '2026-09-05T07:00:00.000Z', source: 'session', summary: 'x' }))).toBe('session exited'); +}); + +it('headlines a host-session group by its lifecycle entry and links an hs_ session group to the Sessions pane', () => { + const at = '2026-09-05T07:00:00.000Z'; + const groups = groupTraceEntries([ + traceEntry(1, { correlation: { host: 'claude', sessionId: 'hs_0123456789abcdef' }, href: '/sessions?session=hs_0123456789abcdef', kind: 'session.started', occurredAt: at, source: 'session', summary: 'Claude session started' }), + traceEntry(2, { correlation: { sessionId: 'hs_0123456789abcdef' }, kind: 'hook.received', occurredAt: at, source: 'hook', summary: 'SessionStart' }), + traceEntry(3, { correlation: { mcpSessionId: 'm1', sessionId: 'hs_0123456789abcdef' }, kind: 'mcp.request', occurredAt: at, source: 'mcp', summary: 'tools/call' }), + traceEntry(4, { correlation: { sessionId: 'claude-own-id' }, kind: 'hook.received', occurredAt: at, source: 'hook', summary: 'other' }), + ]); + expect(groups.map((group) => group.key)).toEqual(['sessionId:hs_0123456789abcdef', 'sessionId:claude-own-id']); + expect(groups[0]?.headline.source).toBe('session'); + expect(groups[0]?.rows.map((row) => row.depth)).toEqual([0, 0, 1]); + expect(traceGroupKeyValue(groups[0]!)).toBe('hs_0123456789abcdef'); + expect(traceGroupSessionLocation(groups[0]!)).toEqual({ area: 'sessions', session: 'hs_0123456789abcdef' }); + expect(traceGroupSessionLocation(groups[1]!)).toBeUndefined(); + expect(traceGroupSessionLocation(groupTraceEntries([traceEntry(1, { correlation: { conversationId: 'hs_x', sessionId: 'hs_y' }, kind: 'hook.received', occurredAt: at, source: 'hook', summary: 'x' })])[0]!)).toBeUndefined(); }); diff --git a/packages/workbench/tests/trace-page.test.ts b/packages/workbench/tests/trace-page.test.ts index 120314cbb..6a5b8b934 100644 --- a/packages/workbench/tests/trace-page.test.ts +++ b/packages/workbench/tests/trace-page.test.ts @@ -5,7 +5,7 @@ import { expect, it } from '@rstest/core'; import type { TraceClient } from '../src/trace/trace-client.ts'; import { TracePage, type TracePageProps } from '../src/trace/trace-page.tsx'; -import { sampleTraceEntries } from './support/trace-fixtures.ts'; +import { sampleTraceEntries, traceEntry } from './support/trace-fixtures.ts'; /** The page never opens the feed when a snapshot is supplied; this client fails loudly if it does. */ const untouched: TraceClient = { @@ -47,6 +47,18 @@ it('renders the correlated timeline oldest first with one line per entry, nested expect(markup).toContain('data-group-key="entry:trc_8"'); }); +it('links a group joined on a Workbench host-session id to the Sessions pane', () => { + const occurredAt = '2026-09-05T07:00:00.000Z'; + const markup = render({ entries: [ + traceEntry(1, { correlation: { host: 'codex', sessionId: 'hs_0123456789abcdef' }, kind: 'session.started', occurredAt, source: 'session', summary: 'Codex session started' }), + traceEntry(2, { correlation: { sessionId: 'codex-own-id' }, kind: 'hook.received', occurredAt, source: 'hook', summary: 'SessionStart' }), + ] }); + expect(count(markup, 'data-testid="trace-group-session"')).toBe(1); + expect(markup).toContain('href="/sessions?session=hs_0123456789abcdef"'); + expect(markup).toContain('trace-glyph trace-glyph--session'); + expect(render()).not.toContain('data-testid="trace-group-session"'); +}); + it('shows the empty state that explains what produces entries, and a connecting state before the first replay', () => { const empty = render({ entries: [] }); expect(empty).toContain('data-testid="trace-empty"'); From 31710cdb27509b97cf957e5db9173561d3bc3a8b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:17:16 +0000 Subject: [PATCH 05/16] workbench(sessions): host-session client, xterm terminal, Sessions pane, Open in Claude/Codex Consume the frozen /api/sessions HTTP+SSE contract: strict zod decoders, base64 output frames written to one xterm per session, input/resize posts, Host sessions nav item with /sessions?session= deep links, Trace link per session (traceSessionId ?? id), and Open in Claude / Open in Codex beside Run with the seeded prompts, disabled with the availability reason. Adds the sessions-* / route-open-in-* test ids and the xterm.js MIT notice. --- packages/workbench/THIRD_PARTY_NOTICES | 29 +++ .../src/application/event-route-workspace.tsx | 4 +- .../executable-route-workspace.tsx | 4 + .../src/application/route-input-editor.tsx | 5 +- .../src/application/route-workspace.tsx | 6 +- .../src/application/workspace-contracts.ts | 2 + .../workbench/src/application/workspace.css | 2 + packages/workbench/src/main.tsx | 6 +- .../src/sessions/host-session-client.ts | 222 ++++++++++++++++++ .../src/sessions/host-session-contracts.ts | 56 +++++ .../src/sessions/host-session-model.ts | 111 +++++++++ .../workbench/src/sessions/open-in-host.tsx | 77 ++++++ .../workbench/src/sessions/sessions-page.css | 31 +++ .../workbench/src/sessions/sessions-page.tsx | 154 ++++++++++++ packages/workbench/src/sessions/terminal.css | 4 + packages/workbench/src/sessions/terminal.tsx | 92 ++++++++ .../workbench/src/shell/workbench-location.ts | 10 +- .../workbench/src/shell/workbench-shell.tsx | 3 +- .../tests/host-session-client.test.ts | 167 +++++++++++++ .../tests/host-session-model.test.ts | 73 ++++++ .../workbench/tests/sessions-page.test.ts | 34 +++ .../tests/support/workbench-acceptance.ts | 18 +- .../tests/support/workspace-fixtures.ts | 2 + .../tests/workbench-location.test.ts | 6 +- .../workbench/tests/workbench-shell.test.ts | 6 +- 25 files changed, 1108 insertions(+), 16 deletions(-) create mode 100644 packages/workbench/src/sessions/host-session-client.ts create mode 100644 packages/workbench/src/sessions/host-session-contracts.ts create mode 100644 packages/workbench/src/sessions/host-session-model.ts create mode 100644 packages/workbench/src/sessions/open-in-host.tsx create mode 100644 packages/workbench/src/sessions/sessions-page.css create mode 100644 packages/workbench/src/sessions/sessions-page.tsx create mode 100644 packages/workbench/src/sessions/terminal.css create mode 100644 packages/workbench/src/sessions/terminal.tsx create mode 100644 packages/workbench/tests/host-session-client.test.ts create mode 100644 packages/workbench/tests/host-session-model.test.ts create mode 100644 packages/workbench/tests/sessions-page.test.ts diff --git a/packages/workbench/THIRD_PARTY_NOTICES b/packages/workbench/THIRD_PARTY_NOTICES index 403fe1d8b..fc7985dbe 100644 --- a/packages/workbench/THIRD_PARTY_NOTICES +++ b/packages/workbench/THIRD_PARTY_NOTICES @@ -8,3 +8,32 @@ Inspector project's AppRenderer component: The derived code is src/mcp/app-renderer.tsx. The MIT license text is in src/mcp/APP-RENDERER-LICENSE. + +Agent Bundle workbench bundles the xterm.js terminal emulator and its fit +addon for the Host sessions pane (src/sessions/terminal.tsx): + + @xterm/xterm 6.0.0 and @xterm/addon-fit 0.11.0 + https://github.com/xtermjs/xterm.js + MIT License + +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index 006f97cbe..209ca161f 100644 --- a/packages/workbench/src/application/event-route-workspace.tsx +++ b/packages/workbench/src/application/event-route-workspace.tsx @@ -212,6 +212,7 @@ const ReplayTab = ({ controller, defaultHost, lifecycle }: { }; export interface EventRouteWorkspaceProps { + readonly actions?: React.ReactNode; readonly clients: Pick; readonly controller: RouteInvocationController; readonly invocationId?: string; @@ -236,7 +237,7 @@ const useLifecycle = (client: LifecycleClient, leaf: ApplicationLeaf): Lifecycle }; /** Host selector → executable body with the event codec tabs. */ -export const EventRouteWorkspace = ({ clients, controller, invocationId, leaf, onNavigate, tab, trace }: EventRouteWorkspaceProps): React.ReactNode => { +export const EventRouteWorkspace = ({ actions, clients, controller, invocationId, leaf, onNavigate, tab, trace }: EventRouteWorkspaceProps): React.ReactNode => { const lifecycleState = useLifecycle(clients.lifecycleClient, leaf); const lifecycle = lifecycleState.state === 'ready' ? lifecycleState.lifecycle : undefined; const fixtures = useMemo(() => eventFixturesFor(lifecycle), [lifecycle]); @@ -299,6 +300,7 @@ export const EventRouteWorkspace = ({ clients, controller, invocationId, leaf, o ]; return ; export interface ExecutableRouteWorkspaceProps { + /** Rendered beside Run in the input editor. */ + readonly actions?: React.ReactNode; readonly controller: RouteInvocationController; /** Result tabs appended after the core set (the event route's codec panes). */ readonly extraTabs?: readonly ResultTabDefinition[]; @@ -284,6 +286,7 @@ export const WorkspaceHeader = ({ leaf, surface }: { readonly leaf: ApplicationL /** Input → Run → result tabs + inspector for one invocable leaf. */ export const ExecutableRouteWorkspace = ({ + actions, controller, extraTabs, fixtures, @@ -393,6 +396,7 @@ export const ExecutableRouteWorkspace = ({ : undefined} {toolbar} ` launchers). */ + readonly actions?: React.ReactNode; readonly cliSurface?: boolean; readonly disabled?: boolean; readonly fixtures?: readonly RouteInputFixture[]; @@ -259,7 +261,7 @@ const isRunShortcut = (event: React.KeyboardEvent): boolean => event.key === 'Enter' && (event.metaKey || event.ctrlKey); /** The workspace's input panel: form or raw JSON, fixtures, argv preview, and Run. */ -export const RouteInputEditor = ({ cliSurface, disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { +export const RouteInputEditor = ({ actions, cliSurface, disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { const schema = leaf.inputSchema; const submission = routeInputSubmission(leaf, value, cliSurface); const fieldErrors = value.attempted && submission.fieldErrors !== undefined ? submission.fieldErrors : {}; @@ -398,6 +400,7 @@ export const RouteInputEditor = ({ cliSurface, disabled = false, fixtures = [], type="button" >{running ? 'Running…' : 'Run'} Ctrl/⌘ + Enter + {actions} {value.attempted && submission.error !== undefined && value.mode !== 'raw' ? {submission.error} : undefined} diff --git a/packages/workbench/src/application/route-workspace.tsx b/packages/workbench/src/application/route-workspace.tsx index 8c87adfed..716f1046b 100644 --- a/packages/workbench/src/application/route-workspace.tsx +++ b/packages/workbench/src/application/route-workspace.tsx @@ -13,6 +13,7 @@ */ import React from 'react'; +import { OpenInHost } from '../sessions/open-in-host.tsx'; import { AppRouteWorkspace } from './app-route-workspace.tsx'; import type { ApplicationLeaf } from './application-tree-model.ts'; import { EventRouteWorkspace } from './event-route-workspace.tsx'; @@ -59,9 +60,10 @@ export const DocumentWorkspace = ({ leaf }: { readonly leaf: ApplicationLeaf }): const InvokeWorkspace = ({ backends, clients, invocationId, leaf, onNavigate, tab, trace }: RouteWorkspaceProps): React.ReactNode => { const controller = useRouteInvocation({ backends, ...(invocationId === undefined ? {} : { invocationId }), leaf }); + const actions = ; return leaf.ref.kind === 'event' - ? - : ; + ? + : ; }; /** Mounts the workspace body the selected leaf's execution kind calls for. */ diff --git a/packages/workbench/src/application/workspace-contracts.ts b/packages/workbench/src/application/workspace-contracts.ts index a3294204f..c475d75d1 100644 --- a/packages/workbench/src/application/workspace-contracts.ts +++ b/packages/workbench/src/application/workspace-contracts.ts @@ -15,6 +15,7 @@ import type { JsonValue } from '../../../agent-bundle/src/contracts/strict-json. import type { EvalClient } from '../evals/eval-client.ts'; import type { HookClient } from '../hooks/hook-client.ts'; import type { LifecycleClient } from '../lifecycles/lifecycle-client.ts'; +import type { HostSessionClient } from '../sessions/host-session-client.ts'; import type { McpAppClient } from '../mcp/mcp-app-client.ts'; import type { ForegroundRouteClient, McpRouteClient } from '../mcp/mcp-route-client.ts'; import type { SkillClient } from '../skill-client.ts'; @@ -49,6 +50,7 @@ export interface WorkspaceClients { readonly evalClient: EvalClient; readonly foreground: ForegroundRouteClient; readonly hookClient: HookClient; + readonly hostSessionClient: HostSessionClient; readonly lifecycleClient: LifecycleClient; readonly mcpRoutes: McpRouteClient; readonly skillClient: SkillClient; diff --git a/packages/workbench/src/application/workspace.css b/packages/workbench/src/application/workspace.css index 4130e73a8..d90a57edb 100644 --- a/packages/workbench/src/application/workspace.css +++ b/packages/workbench/src/application/workspace.css @@ -43,6 +43,8 @@ .route-cancel { background: #fff; border: 1px solid #b31b23; border-radius: 4px; color: #b31b23; cursor: pointer; justify-self: start; padding: 6px 14px; } .route-cancel:disabled { cursor: not-allowed; opacity: .55; } .route-input-shortcut { color: #7a8492; font-size: 11px; } +.route-open-in-host { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; cursor: pointer; font-size: 13px; font-weight: 600; padding: 7px 14px; } +.route-open-in-host:disabled { cursor: not-allowed; opacity: .55; } .route-input-error { color: #aa1f2a; display: block; font-size: 11px; margin-top: 4px; } /* Status line and diagnostics */ diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index be7fc1859..fe8e44466 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -37,6 +37,8 @@ import { ProblemsPage } from './problems/problems-page.tsx'; import { projectFailureText, ProjectClient, type ProjectConnectionState } from './project-client.ts'; import { RouteManifestClient } from './routes/route-manifest-client.ts'; import { RuntimeClient, type RuntimeBootstrap } from './runtime-client.ts'; +import { HostSessionClient } from './sessions/host-session-client.ts'; +import { SessionsPage } from './sessions/sessions-page.tsx'; import type { RuntimeProfileOption } from './runtime-model.ts'; import { createRuntimeEventBuffer, @@ -112,6 +114,7 @@ const createClients = () => { evalClient: new EvalClient({ foreground }), foreground, hookClient: new HookClient({ foreground }), + hostSessionClient: new HostSessionClient({ foreground }), inspectorLaunch: createMcpInspectorLaunchController({ routes: mcpRoutes }), invocationClient: new InvocationClient({ foreground }), lifecycleClient: new LifecycleClient({ foreground }), @@ -411,6 +414,7 @@ const Workbench = () => { evalClient: clients.evalClient, foreground: clients.foreground, hookClient: clients.hookClient, + hostSessionClient: clients.hostSessionClient, lifecycleClient: clients.lifecycleClient, mcpRoutes: clients.mcpRoutes, skillClient: clients.skillClient, @@ -452,7 +456,7 @@ const Workbench = () => { case 'problems': return ; case 'sessions': - return

Sessions

Embedded host sessions arrive in a later release.

; + return ; case 'advanced': return + | Readonly<{ readonly bytes: Uint8Array; readonly type: 'output' }> + | Readonly<{ readonly session: HostSession; readonly type: 'end' }>; + +const textSchema = z.string().min(1); +const sizeSchema = z.number().int().min(1).max(500); +const hostSchema = z.enum(['claude', 'codex']); +const sessionSchema: z.ZodType = z.strictObject({ + authority: z.strictObject({ + epochId: textSchema, + install: textSchema, + projectRoot: textSchema, + }), + cols: sizeSchema, + endedAt: z.number().finite().nonnegative().optional(), + exitCode: z.number().int().optional(), + host: hostSchema, + id: textSchema, + pid: z.number().int().nonnegative().optional(), + prompt: z.string().optional(), + restartOf: textSchema.optional(), + rows: sizeSchema, + signal: textSchema.optional(), + startedAt: z.number().finite().nonnegative(), + state: z.enum(['exited', 'running', 'terminated']), + traceSessionId: textSchema.optional(), +}); +const availabilitySchema: z.ZodType = z.strictObject({ + executable: textSchema.optional(), + host: hostSchema, + launchable: z.boolean(), + reason: z.string().optional(), +}); +const listSchema: z.ZodType = z.strictObject({ + hosts: z.array(availabilitySchema), + sessions: z.array(sessionSchema), +}); +const sessionResponseSchema = z.strictObject({ session: sessionSchema }); +const outputFrameSchema = z.strictObject({ + data: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u), +}); + +const invalid = (message: string): HostSessionClientError => new HostSessionClientError('AB8261', message); + +const responseError = (value: unknown, status: number): HostSessionClientError => { + const decoded = diagnosticErrorEnvelopeSchema.safeParse(value); + return decoded.success + ? new HostSessionClientError(decoded.data.diagnostic.code, decoded.data.diagnostic.message, status) + : new HostSessionClientError('AB8261', `Host session request failed with HTTP ${String(status)}.`, status); +}; + +const opaqueSessionId = (value: string): string => { + if ( + value.length === 0 || value === '.' || value === '..' || + value.includes('/') || value.includes('\\') || value.includes('\0') + ) { + throw invalid('Host session id is not a valid opaque segment.'); + } + return encodeURIComponent(value); +}; + +const bodyFor = async (response: Response): Promise => + response.status === 204 ? undefined : response.json().catch(() => undefined); + +const sessionBody = (value: unknown): HostSession => { + const decoded = sessionResponseSchema.safeParse(value); + if (!decoded.success) throw invalid('Host session route returned an invalid response.'); + return Object.freeze(decoded.data.session); +}; + +/** `atob` + `Uint8Array.from`: `Uint8Array.fromBase64` is not in the Workbench's Chrome target. */ +export const decodeBase64 = (data: string): Uint8Array => + Uint8Array.from(atob(data), (character) => character.charCodeAt(0)); + +const invalidFrame = (): HostSessionClientError => invalid('Host session stream returned an invalid frame.'); + +const decodeFrame = (event: string | undefined, data: string | undefined): HostSessionStreamMessage => { + if (event === undefined || data === undefined) throw invalidFrame(); + let parsed: unknown; + try { + parsed = parseJsonWithoutDuplicateKeys(data); + } catch { + throw invalidFrame(); + } + switch (event) { + case 'state': + case 'end': { + const decoded = sessionResponseSchema.safeParse(parsed); + if (!decoded.success) throw invalidFrame(); + return Object.freeze({ session: Object.freeze(decoded.data.session), type: event }); + } + case 'output': { + const decoded = outputFrameSchema.safeParse(parsed); + if (!decoded.success) throw invalidFrame(); + return Object.freeze({ bytes: decodeBase64(decoded.data.data), type: 'output' }); + } + default: + throw invalidFrame(); + } +}; + +const json = (body: unknown, signal?: AbortSignal): RequestInit => ({ + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + method: 'POST', + ...(signal === undefined ? {} : { signal }), +}); + +export class HostSessionClient { + readonly #foreground: ForegroundRequestAuthority; + + constructor({ foreground }: HostSessionClientOptions) { + this.#foreground = foreground; + } + + async #request(path: string, init: RequestInit): Promise { + const response = await this.#foreground.protectedRequest(path, init); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + return body; + } + + async list(signal?: AbortSignal): Promise { + const body = await this.#request('/api/sessions', signal === undefined ? {} : { signal }); + const decoded = listSchema.safeParse(body); + if (!decoded.success) throw invalid('Host session list route returned an invalid response.'); + return Object.freeze({ hosts: Object.freeze(decoded.data.hosts), sessions: Object.freeze(decoded.data.sessions) }); + } + + async launch(request: HostSessionLaunchRequest, signal?: AbortSignal): Promise { + return sessionBody(await this.#request('/api/sessions', json(request, signal))); + } + + async read(id: string, signal?: AbortSignal): Promise { + return sessionBody(await this.#request(`/api/sessions/${opaqueSessionId(id)}`, signal === undefined ? {} : { signal })); + } + + async stream( + id: string, + listener: (message: HostSessionStreamMessage) => void, + signal?: AbortSignal, + ): Promise { + const response = await this.#foreground.protectedRequest( + `/api/sessions/${opaqueSessionId(id)}/stream`, + signal === undefined ? {} : { signal }, + ); + if (!response.ok) throw responseError(await bodyFor(response), response.status); + if (response.body === null) throw invalid('Host session stream returned no body.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let buffered = ''; + let final: HostSession | undefined; + for (;;) { + const next = await reader.read(); + buffered += decoder.decode(next.value, { stream: !next.done }); + let boundary = buffered.indexOf('\n\n'); + while (boundary !== -1) { + const lines = buffered.slice(0, boundary).split('\n'); + buffered = buffered.slice(boundary + 2); + boundary = buffered.indexOf('\n\n'); + if (lines.every((line) => line.startsWith(':') || line.length === 0)) continue; + const message = decodeFrame( + lines.find((line) => line.startsWith('event: '))?.slice(7), + lines.find((line) => line.startsWith('data: '))?.slice(6), + ); + listener(message); + if (message.type === 'end') final = message.session; + } + if (next.done) break; + } + if (final === undefined) throw invalid('Host session stream ended without an end frame.'); + return final; + } + + async input(id: string, data: string, signal?: AbortSignal): Promise { + await this.#request(`/api/sessions/${opaqueSessionId(id)}/input`, json({ data }, signal)); + } + + async resize(id: string, size: HostSessionSize, signal?: AbortSignal): Promise { + await this.#request(`/api/sessions/${opaqueSessionId(id)}/resize`, json(size, signal)); + } + + async terminate(id: string, signal?: AbortSignal): Promise { + return sessionBody(await this.#request(`/api/sessions/${opaqueSessionId(id)}/terminate`, json({}, signal))); + } + + async restart(id: string, size: HostSessionSize, signal?: AbortSignal): Promise { + return sessionBody(await this.#request(`/api/sessions/${opaqueSessionId(id)}/restart`, json(size, signal))); + } + + async forget(id: string, signal?: AbortSignal): Promise { + await this.#request(`/api/sessions/${opaqueSessionId(id)}`, { method: 'DELETE', ...(signal === undefined ? {} : { signal }) }); + } +} diff --git a/packages/workbench/src/sessions/host-session-contracts.ts b/packages/workbench/src/sessions/host-session-contracts.ts new file mode 100644 index 000000000..0c223c6fa --- /dev/null +++ b/packages/workbench/src/sessions/host-session-contracts.ts @@ -0,0 +1,56 @@ +/** + * Browser-safe host-session wire types (#600 PR 3, frozen HTTP contract). + * Declared here until `agent-bundle/src/contracts/host-sessions.ts` lands; + * the integrator switches this module to a re-export of that file. + */ +export type HostSessionHost = 'claude' | 'codex'; + +export type HostSessionState = 'exited' | 'running' | 'terminated'; + +export interface HostSessionAuthority { + readonly epochId: string; + readonly install: string; + readonly projectRoot: string; +} + +export interface HostSession { + readonly authority: HostSessionAuthority; + readonly cols: number; + readonly endedAt?: number; + readonly exitCode?: number; + readonly host: HostSessionHost; + readonly id: string; + readonly pid?: number; + readonly prompt?: string; + readonly restartOf?: string; + readonly rows: number; + readonly signal?: string; + readonly startedAt: number; + readonly state: HostSessionState; + /** The host's own session id once a hook receipt revealed it; the trace join key. */ + readonly traceSessionId?: string; +} + +export interface HostAvailability { + readonly executable?: string; + readonly host: HostSessionHost; + readonly launchable: boolean; + readonly reason?: string; +} + +export interface HostSessionLaunchRequest { + readonly cols: number; + readonly host: HostSessionHost; + readonly prompt?: string; + readonly rows: number; +} + +export interface HostSessionSize { + readonly cols: number; + readonly rows: number; +} + +export interface HostSessionList { + readonly hosts: readonly HostAvailability[]; + readonly sessions: readonly HostSession[]; +} diff --git a/packages/workbench/src/sessions/host-session-model.ts b/packages/workbench/src/sessions/host-session-model.ts new file mode 100644 index 000000000..71c9266ba --- /dev/null +++ b/packages/workbench/src/sessions/host-session-model.ts @@ -0,0 +1,111 @@ +import type { ApplicationLeaf } from '../application/application-tree-model.ts'; +import type { + HostAvailability, + HostSession, + HostSessionHost, + HostSessionSize, + HostSessionState, +} from './host-session-contracts.ts'; + +/** The Sessions pane's view of the list; scrollback belongs to xterm, not here. */ +export interface HostSessionsState { + readonly error?: string; + readonly hosts: readonly HostAvailability[]; + readonly loaded: boolean; + readonly sessions: readonly HostSession[]; +} + +export type HostSessionsAction = + | Readonly<{ readonly hosts: readonly HostAvailability[]; readonly sessions: readonly HostSession[]; readonly type: 'list' }> + /** A launch, terminate, restart response or a `state`/`end` frame: the record replaces its predecessor. */ + | Readonly<{ readonly session: HostSession; readonly type: 'session' }> + | Readonly<{ readonly id: string; readonly type: 'forget' }> + | Readonly<{ readonly message: string; readonly type: 'error' }>; + +export const hosts: readonly HostSessionHost[] = Object.freeze(['claude', 'codex']); + +/** A launch before any terminal has fitted its pane starts here; the first fit resizes it. */ +export const defaultHostSessionSize: HostSessionSize = Object.freeze({ cols: 120, rows: 32 }); + +export const initialHostSessionsState: HostSessionsState = Object.freeze({ hosts: Object.freeze([]), loaded: false, sessions: Object.freeze([]) }); + +const newestFirst = (left: HostSession, right: HostSession): number => + right.startedAt - left.startedAt || left.id.localeCompare(right.id); + +const sorted = (sessions: readonly HostSession[]): readonly HostSession[] => Object.freeze([...sessions].sort(newestFirst)); + +export const reduceHostSessions = (state: HostSessionsState, action: HostSessionsAction): HostSessionsState => { + switch (action.type) { + case 'list': + return Object.freeze({ hosts: Object.freeze([...action.hosts]), loaded: true, sessions: sorted(action.sessions) }); + case 'session': + return Object.freeze({ + ...state, + error: undefined, + sessions: sorted([...state.sessions.filter((session) => session.id !== action.session.id), action.session]), + }); + case 'forget': + return Object.freeze({ ...state, sessions: Object.freeze(state.sessions.filter((session) => session.id !== action.id)) }); + case 'error': + return Object.freeze({ ...state, error: action.message, loaded: true }); + default: { + const exhaustive: never = action; + return exhaustive; + } + } +}; + +export const hostLabel = (host: HostSessionHost): string => { + switch (host) { + case 'claude': + return 'Claude'; + case 'codex': + return 'Codex'; + default: { + const exhaustive: never = host; + return exhaustive; + } + } +}; + +export const sessionStateLabel = (state: HostSessionState): string => { + switch (state) { + case 'running': + return 'Running'; + case 'exited': + return 'Exited'; + case 'terminated': + return 'Terminated'; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +}; + +export const availabilityFor = (available: readonly HostAvailability[], host: HostSessionHost): HostAvailability | undefined => + available.find((entry) => entry.host === host); + +/** The prompt `Open in ` seeds for a route leaf; leaves without one offer no launch. */ +export const hostSessionPromptFor = (leaf: ApplicationLeaf): string | undefined => { + switch (leaf.ref.kind) { + case 'tool': + return leaf.routeId === undefined ? undefined : `Call the ${leaf.routeId} tool of this plugin and explain the result.`; + case 'event': + return `Trigger the ${leaf.ref.event} hook of this plugin and explain what it did.`; + case 'cli': + return `Run the ${leaf.ref.path.join(' ')} command of this plugin and explain the result.`; + case 'resource': + case 'prompt': + case 'app': + case 'script': + case 'skill': + case 'command': + case 'rule': + return undefined; + default: { + const exhaustive: never = leaf.ref; + return exhaustive; + } + } +}; diff --git a/packages/workbench/src/sessions/open-in-host.tsx b/packages/workbench/src/sessions/open-in-host.tsx new file mode 100644 index 000000000..37e53ba2b --- /dev/null +++ b/packages/workbench/src/sessions/open-in-host.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; + +import type { ApplicationLeaf } from '../application/application-tree-model.ts'; +import { errorMessage, isAbortError } from '../client-helpers.ts'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { HostAvailability, HostSessionHost } from './host-session-contracts.ts'; +import type { HostSessionClient } from './host-session-client.ts'; +import { availabilityFor, defaultHostSessionSize, hostLabel, hosts, hostSessionPromptFor } from './host-session-model.ts'; + +export interface OpenInHostProps { + readonly client: HostSessionClient; + readonly leaf: ApplicationLeaf; + readonly onNavigate: (location: WorkbenchLocation) => void; +} + +type Availability = + | Readonly<{ readonly state: 'loading' }> + | Readonly<{ readonly hosts: readonly HostAvailability[]; readonly state: 'ready' }> + | Readonly<{ readonly message: string; readonly state: 'unavailable' }>; + +/** `Open in Claude` / `Open in Codex` beside Run: launches the host with the leaf's seeded prompt and lands on the session. */ +export const OpenInHost = ({ client, leaf, onNavigate }: OpenInHostProps): React.ReactNode => { + const prompt = hostSessionPromptFor(leaf); + const [availability, setAvailability] = useState({ state: 'loading' }); + const [error, setError] = useState(); + + useEffect(() => { + if (prompt === undefined) return; + const controller = new AbortController(); + client.list(controller.signal).then( + (list) => { if (!controller.signal.aborted) setAvailability({ hosts: list.hosts, state: 'ready' }); }, + (reason: unknown) => { if (!controller.signal.aborted && !isAbortError(reason)) setAvailability({ message: errorMessage(reason, 'Host availability could not be loaded.'), state: 'unavailable' }); }, + ); + return () => controller.abort(); + }, [client, prompt]); + + if (prompt === undefined) return undefined; + + const reasonFor = (host: HostSessionHost): string | undefined => { + switch (availability.state) { + case 'loading': + return 'Checking host availability…'; + case 'unavailable': + return availability.message; + case 'ready': { + const entry = availabilityFor(availability.hosts, host); + return entry?.launchable === true ? undefined : entry?.reason ?? `${hostLabel(host)} is not available in this project.`; + } + default: { + const exhaustive: never = availability; + return exhaustive; + } + } + }; + const open = (host: HostSessionHost): void => { + client.launch({ host, prompt, ...defaultHostSessionSize }).then( + (session) => onNavigate({ area: 'sessions', session: session.id }), + (reason: unknown) => setError(errorMessage(reason, 'The host could not be launched.')), + ); + }; + + return <> + {hosts.map((host) => { + const reason = reasonFor(host); + return ; + })} + {error === undefined ? undefined : {error}} + ; +}; diff --git a/packages/workbench/src/sessions/sessions-page.css b/packages/workbench/src/sessions/sessions-page.css new file mode 100644 index 000000000..37266f851 --- /dev/null +++ b/packages/workbench/src/sessions/sessions-page.css @@ -0,0 +1,31 @@ +.sessions-page { display: grid; grid-template-columns: var(--tree-width) minmax(0, 1fr); height: calc(100vh - var(--header-height)); max-width: none; padding: 0; } +.sessions-side { border-right: 1px solid #d9dee7; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; padding: 28px 22px 24px; } +.sessions-heading { margin-bottom: 18px; } +.sessions-heading h1 { font-size: 24px; } +.sessions-heading p { font-size: 13px; } +.sessions-launch { display: grid; gap: 10px; margin-bottom: 18px; } +.sessions-launch-host { display: grid; gap: 4px; } +.sessions-launch-host .route-run { width: 100%; } +.sessions-launch-reason { color: #7a8492; font-size: 12px; } +.sessions-list { display: grid; gap: 6px; list-style: none; margin: 0; padding: 0; } +.sessions-item { border: 1px solid #d9dee7; border-radius: 6px; color: inherit; display: grid; gap: 3px; grid-template-columns: 1fr auto; padding: 9px 11px; text-decoration: none; } +.sessions-item:hover { border-color: #9fb3d1; } +.sessions-item[aria-current="page"] { border-color: #1465df; box-shadow: inset 3px 0 0 #1465df; } +.sessions-item-host { font-size: 14px; font-weight: 700; } +.sessions-item-meta, .sessions-item-prompt { color: #596372; font-size: 12px; grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sessions-state { border-radius: 999px; font-size: 11px; font-weight: 750; padding: 2px 8px; text-transform: uppercase; } +.sessions-state--running { background: #e6f4ea; color: #1e6b2c; } +.sessions-state--exited { background: #eef1f5; color: #4f5866; } +.sessions-state--terminated { background: #fdf0f0; color: #b31b23; } +.sessions-main { display: flex; flex-direction: column; min-height: 0; min-width: 0; } +.sessions-placeholder { color: #5c6676; display: grid; flex: 1; font-size: 15px; place-items: center; } +.sessions-toolbar { align-items: center; border-bottom: 1px solid #d9dee7; display: flex; gap: 16px; padding: 12px 24px; } +.sessions-toolbar h2 { color: #141821; font-size: 15px; margin: 0; text-transform: none; } +.sessions-toolbar-actions { align-items: center; display: flex; gap: 8px; margin-left: auto; } +.sessions-trace-link { color: #0b5bd3; font-size: 13px; font-weight: 700; margin-right: 8px; } +.sessions-action { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; cursor: pointer; padding: 6px 14px; } +.sessions-action:disabled { cursor: not-allowed; opacity: .55; } +.sessions-authority { border-top: 1px solid #d9dee7; display: flex; flex-wrap: wrap; gap: 0 28px; margin: 0; padding: 10px 24px; } +.sessions-authority div { display: flex; gap: 8px; min-width: 0; } +.sessions-authority dt { color: #596372; font-size: 12px; font-weight: 750; } +.sessions-authority dd { font-size: 12px; margin: 0; overflow-wrap: anywhere; } diff --git a/packages/workbench/src/sessions/sessions-page.tsx b/packages/workbench/src/sessions/sessions-page.tsx new file mode 100644 index 000000000..baf7b9013 --- /dev/null +++ b/packages/workbench/src/sessions/sessions-page.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { errorMessage, isAbortError } from '../client-helpers.ts'; +import { ShellLink } from '../shell/shell-link.tsx'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { HostSession, HostSessionHost, HostSessionSize } from './host-session-contracts.ts'; +import type { HostSessionClient } from './host-session-client.ts'; +import { + availabilityFor, + defaultHostSessionSize, + hostLabel, + hosts, + initialHostSessionsState, + reduceHostSessions, + sessionStateLabel, +} from './host-session-model.ts'; +import { SessionTerminal } from './terminal.tsx'; +import './sessions-page.css'; + +export interface SessionsPageProps { + readonly client: HostSessionClient; + readonly onNavigate: (location: WorkbenchLocation) => void; + /** `?session=`: the session whose terminal fills the pane. */ + readonly session?: string; +} + +const time = (millis: number): string => new Date(millis).toLocaleTimeString('en-GB', { hour12: false }); + +const stateSummary = (session: HostSession): string => { + const detail = session.state === 'running' + ? session.pid === undefined ? undefined : `pid ${String(session.pid)}` + : session.signal ?? (session.exitCode === undefined ? undefined : `exit ${String(session.exitCode)}`); + return detail === undefined ? sessionStateLabel(session.state) : `${sessionStateLabel(session.state)} · ${detail}`; +}; + +export const SessionsPage = ({ client, onNavigate, session: selectedId }: SessionsPageProps): React.ReactNode => { + const [state, dispatch] = useReducer(reduceHostSessions, initialHostSessionsState); + const size = useRef(defaultHostSessionSize); + const fail = useCallback((reason: unknown): void => { + if (!isAbortError(reason)) dispatch({ message: errorMessage(reason, 'The host session request failed.'), type: 'error' }); + }, []); + + useEffect(() => { + const controller = new AbortController(); + client.list(controller.signal).then( + (list) => { if (!controller.signal.aborted) dispatch({ ...list, type: 'list' }); }, + (reason: unknown) => { if (!controller.signal.aborted) fail(reason); }, + ); + return () => controller.abort(); + }, [client, fail]); + + const select = useCallback((session: HostSession): void => { + dispatch({ session, type: 'session' }); + onNavigate({ area: 'sessions', session: session.id }); + }, [onNavigate]); + const launch = (host: HostSessionHost): void => { client.launch({ host, ...size.current }).then(select, fail); }; + const terminate = (id: string): void => { client.terminate(id).then((session) => dispatch({ session, type: 'session' }), fail); }; + const restart = (id: string): void => { client.restart(id, size.current).then(select, fail); }; + const forget = (id: string): void => { + client.forget(id).then(() => { + dispatch({ id, type: 'forget' }); + onNavigate({ area: 'sessions' }); + }, fail); + }; + const onSession = useCallback((session: HostSession): void => { dispatch({ session, type: 'session' }); }, []); + const onSize = useCallback((next: HostSessionSize): void => { size.current = next; }, []); + const onError = useCallback((message: string): void => { dispatch({ message, type: 'error' }); }, []); + + const selected = selectedId === undefined ? undefined : state.sessions.find((session) => session.id === selectedId); + + return
+ +
+ {selected === undefined + ?
+ {selectedId === undefined + ?

Select a session, or launch a host.

+ :

{state.loaded ? `No host session is ${selectedId} in this dev server.` : 'Loading host sessions…'}

} +
+ : <> +
+

{hostLabel(selected.host)} {selected.id}

+ + Trace + + + + +
+ +
+
Runs in
{selected.authority.projectRoot}
+
Epoch
{selected.authority.epochId}
+
Install
{selected.authority.install}
+
State
{stateSummary(selected)}
+ {selected.restartOf === undefined ? undefined :
Restart of
{selected.restartOf}
} + {selected.traceSessionId === undefined ? undefined :
Host session
{selected.traceSessionId}
} +
+ } +
+
; +}; diff --git a/packages/workbench/src/sessions/terminal.css b/packages/workbench/src/sessions/terminal.css new file mode 100644 index 000000000..d580c7c4d --- /dev/null +++ b/packages/workbench/src/sessions/terminal.css @@ -0,0 +1,4 @@ +@import '@xterm/xterm/css/xterm.css'; + +.session-terminal { background: #101822; flex: 1; min-height: 0; padding: 8px; } +.session-terminal .xterm { height: 100%; } diff --git a/packages/workbench/src/sessions/terminal.tsx b/packages/workbench/src/sessions/terminal.tsx new file mode 100644 index 000000000..8df662e0e --- /dev/null +++ b/packages/workbench/src/sessions/terminal.tsx @@ -0,0 +1,92 @@ +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; +import React, { useEffect, useRef } from 'react'; + +import { errorMessage, isAbortError } from '../client-helpers.ts'; +import type { HostSession, HostSessionSize } from './host-session-contracts.ts'; +import type { HostSessionClient } from './host-session-client.ts'; +import './terminal.css'; + +export interface SessionTerminalProps { + readonly client: HostSessionClient; + /** Only a running session receives `input` and `resize`; an ended one just replays. */ + readonly live: boolean; + readonly onError: (message: string) => void; + /** Every `state`/`end` frame of the stream. */ + readonly onSession: (session: HostSession) => void; + /** The fitted size, for the next launch to start at the pane's real dimensions. */ + readonly onSize: (size: HostSessionSize) => void; + readonly sessionId: string; +} + +const resizeDebounceMs = 100; + +/** + * One xterm per mounted session id: opens the SSE stream, writes `output` + * bytes straight into the terminal, forwards keystrokes as `input`, and fits + * to its pane (debounced) before reporting the size as `resize`. The stream's + * scrollback replay refills a remounted terminal. + */ +export const SessionTerminal = ({ client, live, onError, onSession, onSize, sessionId }: SessionTerminalProps): React.ReactNode => { + const host = useRef(null); + const callbacks = useRef({ live, onError, onSession, onSize }); + callbacks.current = { live, onError, onSession, onSize }; + + useEffect(() => { + const element = host.current!; + const controller = new AbortController(); + const fail = (reason: unknown): void => { + if (!controller.signal.aborted && !isAbortError(reason)) callbacks.current.onError(errorMessage(reason, 'The host session request failed.')); + }; + const term = new Terminal({ + cursorBlink: true, + fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace', + fontSize: 13, + scrollback: 5_000, + theme: { background: '#101822' }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(element); + const input = term.onData((data) => { + if (callbacks.current.live) client.input(sessionId, data).catch(fail); + }); + const resized = term.onResize((size) => { + callbacks.current.onSize(size); + if (callbacks.current.live) client.resize(sessionId, size).catch(fail); + }); + let pending: ReturnType | undefined; + const observer = new ResizeObserver(() => { + clearTimeout(pending); + pending = setTimeout(() => fit.fit(), resizeDebounceMs); + }); + observer.observe(element); + fit.fit(); + term.focus(); + client.stream(sessionId, (message) => { + switch (message.type) { + case 'output': + term.write(message.bytes); + break; + case 'state': + case 'end': + callbacks.current.onSession(message.session); + break; + default: { + const exhaustive: never = message; + return exhaustive; + } + } + }, controller.signal).catch(fail); + return () => { + controller.abort(); + clearTimeout(pending); + observer.disconnect(); + input.dispose(); + resized.dispose(); + term.dispose(); + }; + }, [client, sessionId]); + + return
; +}; diff --git a/packages/workbench/src/shell/workbench-location.ts b/packages/workbench/src/shell/workbench-location.ts index f5d1f8084..1404cf091 100644 --- a/packages/workbench/src/shell/workbench-location.ts +++ b/packages/workbench/src/shell/workbench-location.ts @@ -8,7 +8,7 @@ * /routes/… One application leaf (see application-node.ts) * /trace · /trace/ Live trace, one selected entry * /problems Diagnostics - * /sessions · /sessions/ Embedded host sessions (PR 3) + * /sessions · /sessions?session= Host sessions, one selected session * /advanced/
evals | artifact | protocol | hosts | logs * * `?invocation=` on a route path opens that route with the named @@ -48,7 +48,7 @@ export type WorkbenchLocation = /** `invocationId` is the selected trace entry id (`/trace/`); the name predates the unified trace and still accepts an `inv_…` id. */ | Readonly<{ readonly area: 'trace'; readonly correlation?: string; readonly invocationId?: string }> | Readonly<{ readonly area: 'problems' }> - | Readonly<{ readonly area: 'sessions'; readonly host?: string }> + | Readonly<{ readonly area: 'sessions'; readonly session?: string }> | Readonly<{ readonly area: 'advanced'; readonly section: AdvancedSection }>; const segment = (value: string): string => encodeURIComponent(value); @@ -105,8 +105,8 @@ export const parseWorkbenchLocation = (pathname: string, search = ''): Workbench case 'problems': return Object.freeze({ area: 'problems' }); case 'sessions': { - const host = rest.length === 1 ? decode(rest[0]!) : undefined; - return Object.freeze({ area: 'sessions', ...(host === undefined ? {} : { host }) }); + const session = nonempty(query.get('session')); + return Object.freeze({ area: 'sessions', ...(session === undefined ? {} : { session }) }); } case 'advanced': { const section = rest[0]; @@ -135,7 +135,7 @@ export const formatWorkbenchLocation = (location: WorkbenchLocation): string => case 'problems': return '/problems'; case 'sessions': - return location.host === undefined ? '/sessions' : `/sessions/${segment(location.host)}`; + return location.session === undefined ? '/sessions' : `/sessions?session=${segment(location.session)}`; case 'advanced': return `/advanced/${location.section}`; default: { diff --git a/packages/workbench/src/shell/workbench-shell.tsx b/packages/workbench/src/shell/workbench-shell.tsx index bb578ced2..b5db09664 100644 --- a/packages/workbench/src/shell/workbench-shell.tsx +++ b/packages/workbench/src/shell/workbench-shell.tsx @@ -10,7 +10,7 @@ import { formatWorkbenchLocation, type WorkbenchArea, type WorkbenchLocation } f import './shell.css'; export interface WorkbenchNavItem { - readonly area: Exclude; + readonly area: WorkbenchArea; readonly glyph: string; readonly label: string; readonly location: WorkbenchLocation; @@ -20,6 +20,7 @@ export interface WorkbenchNavItem { export const workbenchNavItems: readonly WorkbenchNavItem[] = Object.freeze([ Object.freeze({ area: 'application' as const, glyph: '⌸', label: 'Application', location: Object.freeze({ area: 'application' as const }) }), Object.freeze({ area: 'trace' as const, glyph: '≡', label: 'Trace', location: Object.freeze({ area: 'trace' as const }) }), + Object.freeze({ area: 'sessions' as const, glyph: '▣', label: 'Host sessions', location: Object.freeze({ area: 'sessions' as const }) }), Object.freeze({ area: 'problems' as const, glyph: '!', label: 'Problems', location: Object.freeze({ area: 'problems' as const }) }), Object.freeze({ area: 'advanced' as const, glyph: '⚙', label: 'Advanced', location: Object.freeze({ area: 'advanced' as const, section: 'evals' as const }) }), ]); diff --git a/packages/workbench/tests/host-session-client.test.ts b/packages/workbench/tests/host-session-client.test.ts new file mode 100644 index 000000000..fcf88dbd2 --- /dev/null +++ b/packages/workbench/tests/host-session-client.test.ts @@ -0,0 +1,167 @@ +import { expect, it } from '@rstest/core'; + +import type { ForegroundRequestAuthority } from '../src/mcp/mcp-route-client.ts'; +import { + decodeBase64, + HostSessionClient, + HostSessionClientError, + type HostSessionStreamMessage, +} from '../src/sessions/host-session-client.ts'; +import type { HostAvailability, HostSession } from '../src/sessions/host-session-contracts.ts'; + +const session: HostSession = Object.freeze({ + authority: Object.freeze({ epochId: 'epoch-1', install: '/home/dev/.claude/plugins/cache/curator', projectRoot: '/work/curator' }), + cols: 120, + host: 'claude', + id: 'hs_0123456789abcdef', + pid: 4242, + prompt: 'Call the tool:curator/search_audible tool of this plugin and explain the result.', + rows: 32, + startedAt: 1_757_000_000_000, + state: 'running', +}); + +const ended: HostSession = Object.freeze({ + authority: session.authority, + cols: 120, + endedAt: 1_757_000_005_000, + exitCode: 0, + host: 'claude', + id: session.id, + prompt: session.prompt, + rows: 32, + startedAt: session.startedAt, + state: 'exited', + traceSessionId: 'claude-own-session', +}); + +const hosts: readonly HostAvailability[] = Object.freeze([ + Object.freeze({ executable: '/usr/local/bin/claude', host: 'claude', launchable: true }), + Object.freeze({ host: 'codex', launchable: false, reason: 'codex is not on PATH' }), +]); + +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + status, +}); + +interface Seen { + readonly body: unknown; + readonly method: string; + readonly path: string; +} + +const clientFor = ( + handler: (path: string, init: RequestInit) => Response | Promise, +): { readonly client: HostSessionClient; readonly seen: Seen[] } => { + const seen: Seen[] = []; + const foreground: ForegroundRequestAuthority = { + protectedRequest: async (path, init = {}) => { + seen.push({ body: typeof init.body === 'string' ? JSON.parse(init.body) : undefined, method: init.method ?? 'GET', path }); + return handler(path, init); + }, + }; + return { client: new HostSessionClient({ foreground }), seen }; +}; + +const sse = (frames: readonly string[]): Response => new Response(frames.join(''), { + headers: { 'content-type': 'text/event-stream' }, +}); + +const frame = (event: string, data: unknown): string => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + + +it('drives every route of the contract with the mutation-session authority and decodes strictly', async () => { + const { client, seen } = clientFor((path, init) => { + if (path === '/api/sessions' && init.method === undefined) return json({ hosts, sessions: [ended, session] }); + if (path === '/api/sessions') return json({ session }, 201); + if (path === `/api/sessions/${session.id}`) return init.method === 'DELETE' ? new Response(null, { status: 204 }) : json({ session }); + if (path.endsWith('/input') || path.endsWith('/resize')) return new Response(null, { status: 204 }); + if (path.endsWith('/terminate')) return json({ session: ended }); + if (path.endsWith('/restart')) return json({ session: { ...session, id: 'hs_fedcba9876543210', restartOf: session.id } }, 201); + throw new Error(`unexpected ${path}`); + }); + + const list = await client.list(); + expect(list.hosts).toEqual(hosts); + expect(list.sessions.map((entry) => entry.state)).toEqual(['exited', 'running']); + expect(Object.isFrozen(list.sessions)).toBe(true); + expect(await client.launch({ cols: 120, host: 'claude', prompt: session.prompt, rows: 32 })).toEqual(session); + expect(await client.read(session.id)).toEqual(session); + await client.input(session.id, 'ls\r'); + await client.resize(session.id, { cols: 100, rows: 40 }); + expect((await client.terminate(session.id)).state).toBe('exited'); + expect((await client.restart(session.id, { cols: 100, rows: 40 })).restartOf).toBe(session.id); + await client.forget(session.id); + + expect(seen.map((request) => [request.method, request.path, request.body])).toEqual([ + ['GET', '/api/sessions', undefined], + ['POST', '/api/sessions', { cols: 120, host: 'claude', prompt: session.prompt, rows: 32 }], + ['GET', `/api/sessions/${session.id}`, undefined], + ['POST', `/api/sessions/${session.id}/input`, { data: 'ls\r' }], + ['POST', `/api/sessions/${session.id}/resize`, { cols: 100, rows: 40 }], + ['POST', `/api/sessions/${session.id}/terminate`, {}], + ['POST', `/api/sessions/${session.id}/restart`, { cols: 100, rows: 40 }], + ['DELETE', `/api/sessions/${session.id}`, undefined], + ]); +}); + +it('rejects unknown keys, bad sizes, and malformed ids before or after the wire', async () => { + const { client } = clientFor((path) => { + if (path === '/api/sessions') return json({ hosts, sessions: [{ ...session, cwd: '/elsewhere' }] }); + return json({ session: { ...session, cols: 0 } }); + }); + await expect(client.list()).rejects.toMatchObject({ code: 'AB8261', name: 'HostSessionClientError' }); + await expect(client.read(session.id)).rejects.toMatchObject({ code: 'AB8261' }); + await expect(client.read('../etc')).rejects.toMatchObject({ code: 'AB8261' }); + await expect(client.input('a/b', 'x')).rejects.toMatchObject({ code: 'AB8261' }); +}); + +it('surfaces the server diagnostic code and HTTP status of a refused request', async () => { + const { client } = clientFor((path) => path.endsWith('/terminate') + ? json({ diagnostic: { code: 'AB8262', message: 'Unknown host session.' } }, 404) + : new Response('nope', { status: 503 })); + const refused = await client.terminate('hs_missing').catch((reason: unknown) => reason); + expect(refused).toBeInstanceOf(HostSessionClientError); + expect(refused).toMatchObject({ code: 'AB8262', message: 'Unknown host session.', status: 404 }); + await expect(client.list()).rejects.toMatchObject({ code: 'AB8261', status: 503 }); +}); + +it('decodes the SSE stream: state, base64 output bytes, keep-alive comments, and the final end frame', async () => { + const output = new TextEncoder().encode('$ echo ready\r\nready\r\n'); + const { client } = clientFor(() => sse([ + frame('state', { session }), + ': keep-alive\n\n', + frame('output', { data: btoa(String.fromCharCode(...output)) }), + frame('output', { data: '' }), + frame('state', { session: { ...session, traceSessionId: 'claude-own-session' } }), + frame('end', { session: ended }), + ])); + const messages: HostSessionStreamMessage[] = []; + const final = await client.stream(session.id, (message) => messages.push(message)); + expect(final).toEqual(ended); + expect(messages.map((message) => message.type)).toEqual(['state', 'output', 'output', 'state', 'end']); + const first = messages[1]; + expect(first?.type === 'output' ? new TextDecoder().decode(first.bytes) : undefined).toBe('$ echo ready\r\nready\r\n'); + const empty = messages[2]; + expect(empty?.type === 'output' ? empty.bytes.length : undefined).toBe(0); + const attached = messages[3]; + expect(attached?.type === 'state' ? attached.session.traceSessionId : undefined).toBe('claude-own-session'); + expect(decodeBase64('AAEC/w==')).toEqual(Uint8Array.from([0, 1, 2, 255])); +}); + +it('refuses a stream frame that is not on the contract and a stream that ends without an end frame', async () => { + const cases: readonly (readonly string[])[] = [ + [frame('output', { data: 'not base64!' })], + [frame('state', { session: { ...session, state: 'starting' } })], + [frame('progress', { percent: 1 })], + ['event: state\ndata: {"session": nope}\n\n'], + [frame('state', { session })], + ]; + for (const frames of cases) { + const { client } = clientFor(() => sse(frames)); + await expect(client.stream(session.id, () => undefined)).rejects.toMatchObject({ code: 'AB8261' }); + } + const { client } = clientFor(() => json({ diagnostic: { code: 'AB8262', message: 'Unknown host session.' } }, 404)); + await expect(client.stream(session.id, () => undefined)).rejects.toMatchObject({ code: 'AB8262', status: 404 }); +}); diff --git a/packages/workbench/tests/host-session-model.test.ts b/packages/workbench/tests/host-session-model.test.ts new file mode 100644 index 000000000..3c9f66122 --- /dev/null +++ b/packages/workbench/tests/host-session-model.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from '@rstest/core'; + +import type { HostAvailability, HostSession } from '../src/sessions/host-session-contracts.ts'; +import { + availabilityFor, + defaultHostSessionSize, + hostLabel, + hostSessionPromptFor, + initialHostSessionsState, + reduceHostSessions, + sessionStateLabel, +} from '../src/sessions/host-session-model.ts'; +import { cliLeaf, eventLeaf, skillLeaf, toolLeaf } from './support/workspace-fixtures.ts'; + +const session = (id: string, startedAt: number, extras: Partial = {}): HostSession => Object.freeze({ + authority: Object.freeze({ epochId: 'epoch-1', install: '/home/dev/.codex/plugins/curator', projectRoot: '/work/curator' }), + cols: 120, + host: 'codex', + id, + rows: 32, + startedAt, + state: 'running', + ...extras, +}); + +const hosts: readonly HostAvailability[] = Object.freeze([ + Object.freeze({ host: 'claude', launchable: true }), + Object.freeze({ host: 'codex', launchable: false, reason: 'no dev install attached' }), +]); + +it('lists newest first, replaces a record by id on every state frame, forgets, and keeps errors until the next list', () => { + const older = session('hs_a', 1_000); + const newer = session('hs_b', 2_000); + const listed = reduceHostSessions(initialHostSessionsState, { hosts, sessions: [older, newer], type: 'list' }); + expect(listed.loaded).toBe(true); + expect(listed.sessions.map((entry) => entry.id)).toEqual(['hs_b', 'hs_a']); + expect(Object.isFrozen(listed) && Object.isFrozen(listed.sessions)).toBe(true); + + const attached = reduceHostSessions(listed, { session: session('hs_a', 1_000, { traceSessionId: 'codex-thread-1' }), type: 'session' }); + expect(attached.sessions.map((entry) => [entry.id, entry.traceSessionId])).toEqual([['hs_b', undefined], ['hs_a', 'codex-thread-1']]); + + const exited = reduceHostSessions(attached, { session: session('hs_b', 2_000, { endedAt: 2_500, exitCode: 1, state: 'exited' }), type: 'session' }); + expect(exited.sessions[0]).toMatchObject({ exitCode: 1, id: 'hs_b', state: 'exited' }); + + const launched = reduceHostSessions(exited, { session: session('hs_c', 3_000, { restartOf: 'hs_b' }), type: 'session' }); + expect(launched.sessions.map((entry) => entry.id)).toEqual(['hs_c', 'hs_b', 'hs_a']); + + const forgotten = reduceHostSessions(launched, { id: 'hs_b', type: 'forget' }); + expect(forgotten.sessions.map((entry) => entry.id)).toEqual(['hs_c', 'hs_a']); + + const failed = reduceHostSessions(initialHostSessionsState, { message: 'AB8265 host sessions unavailable', type: 'error' }); + expect(failed).toMatchObject({ error: 'AB8265 host sessions unavailable', loaded: true, sessions: [] }); + expect(reduceHostSessions(failed, { session: older, type: 'session' }).error).toBeUndefined(); + expect(reduceHostSessions(failed, { hosts, sessions: [], type: 'list' }).error).toBeUndefined(); +}); + +it('names hosts and states, resolves availability, and starts at the default size', () => { + expect(hostLabel('claude')).toBe('Claude'); + expect(hostLabel('codex')).toBe('Codex'); + expect(sessionStateLabel('running')).toBe('Running'); + expect(sessionStateLabel('exited')).toBe('Exited'); + expect(sessionStateLabel('terminated')).toBe('Terminated'); + expect(availabilityFor(hosts, 'codex')?.reason).toBe('no dev install attached'); + expect(availabilityFor([], 'claude')).toBeUndefined(); + expect(defaultHostSessionSize).toEqual({ cols: 120, rows: 32 }); +}); + +it('seeds the contract prompt for tool, event, and CLI leaves and none for the rest', () => { + expect(hostSessionPromptFor(toolLeaf)).toBe(`Call the ${toolLeaf.routeId!} tool of this plugin and explain the result.`); + expect(hostSessionPromptFor(eventLeaf)).toBe(`Trigger the ${eventLeaf.event!} hook of this plugin and explain what it did.`); + expect(hostSessionPromptFor(cliLeaf)).toBe(`Run the ${cliLeaf.command!.path.join(' ')} command of this plugin and explain the result.`); + expect(hostSessionPromptFor(skillLeaf)).toBeUndefined(); +}); diff --git a/packages/workbench/tests/sessions-page.test.ts b/packages/workbench/tests/sessions-page.test.ts new file mode 100644 index 000000000..b1340b57c --- /dev/null +++ b/packages/workbench/tests/sessions-page.test.ts @@ -0,0 +1,34 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { expect, it } from '@rstest/core'; + +import { ForegroundRouteClient } from '../src/mcp/mcp-route-client.ts'; +import { HostSessionClient } from '../src/sessions/host-session-client.ts'; +import { OpenInHost } from '../src/sessions/open-in-host.tsx'; +import { SessionsPage } from '../src/sessions/sessions-page.tsx'; +import { neverFetch, skillLeaf, toolLeaf } from './support/workspace-fixtures.ts'; + +const client = new HostSessionClient({ foreground: new ForegroundRouteClient({ fetch: neverFetch }) }); + +it('renders the launch buttons disabled until availability loads, the empty list, and the placeholder', () => { + const markup = renderToStaticMarkup(createElement(SessionsPage, { client, onNavigate: () => undefined })); + expect(markup).toContain('

Host sessions

'); + expect(markup).toMatch(/]*data-testid="sessions-launch-claude"[^>]*disabled=""/u); + expect(markup).toMatch(/]*data-testid="sessions-launch-codex"[^>]*disabled=""/u); + expect(markup).toContain('Checking host availability…'); + expect(markup).toContain('data-testid="sessions-empty"'); + expect(markup).toContain('data-testid="sessions-placeholder"'); + expect(markup).not.toContain('data-testid="sessions-terminal"'); + + const deepLink = renderToStaticMarkup(createElement(SessionsPage, { client, onNavigate: () => undefined, session: 'hs_0123456789abcdef' })); + expect(deepLink).toContain('Loading host sessions…'); +}); + +it('offers Open in Claude / Codex only for leaves with a seeded prompt, disabled until availability loads', () => { + const markup = renderToStaticMarkup(createElement(OpenInHost, { client, leaf: toolLeaf, onNavigate: () => undefined })); + expect(markup).toMatch(/]*data-testid="route-open-in-claude"[^>]*disabled=""[^>]*title="Checking host availability…"/u); + expect(markup).toContain('data-testid="route-open-in-codex"'); + expect(markup).toContain('>Open in Codex'); + expect(renderToStaticMarkup(createElement(OpenInHost, { client, leaf: skillLeaf, onNavigate: () => undefined }))).toBe(''); +}); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index c200ac5e2..cb28fd636 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -33,23 +33,39 @@ export const workbenchTestIds = Object.freeze({ resultTabTrace: 'result-tab-trace', routeCancel: 'route-cancel', routeInputEditor: 'route-input-editor', + routeOpenInClaude: 'route-open-in-claude', + routeOpenInCodex: 'route-open-in-codex', routeOutcome: 'route-outcome', routeRun: 'route-run', routeRunningStatus: 'route-running-status', routeStatus: 'route-status', routeWorkspace: 'route-workspace', + sessionsAuthority: 'sessions-authority', + sessionsEmpty: 'sessions-empty', + sessionsForget: 'sessions-forget', + sessionsItem: 'sessions-item', + sessionsLaunchClaude: 'sessions-launch-claude', + sessionsLaunchCodex: 'sessions-launch-codex', + sessionsList: 'sessions-list', + sessionsPlaceholder: 'sessions-placeholder', + sessionsRestart: 'sessions-restart', + sessionsState: 'sessions-state', + sessionsTerminal: 'sessions-terminal', + sessionsTerminate: 'sessions-terminate', + sessionsTrace: 'sessions-trace', shellBuildStatus: 'shell-build-status', staticAuthoredDocument: 'static-authored-document', traceDetail: 'trace-detail', traceEntry: 'trace-entry', traceGroup: 'trace-group', + traceGroupSession: 'trace-group-session', unknownRoute: 'unknown-route', workbenchLoading: 'workbench-loading', workbenchNav: 'workbench-nav', workspaceEmpty: 'workspace-empty', } as const); -export const primaryNavLabels = Object.freeze(['Application', 'Trace', 'Problems', 'Advanced'] as const); +export const primaryNavLabels = Object.freeze(['Application', 'Trace', 'Host sessions', 'Problems', 'Advanced'] as const); export const applicationGroupOrder = Object.freeze([ 'MCP', diff --git a/packages/workbench/tests/support/workspace-fixtures.ts b/packages/workbench/tests/support/workspace-fixtures.ts index 60bbc0d0b..9c6da5229 100644 --- a/packages/workbench/tests/support/workspace-fixtures.ts +++ b/packages/workbench/tests/support/workspace-fixtures.ts @@ -15,6 +15,7 @@ import { LifecycleClient } from '../../src/lifecycles/lifecycle-client.ts'; import { McpAppClient } from '../../src/mcp/mcp-app-client.ts'; import { ForegroundRouteClient, McpRouteClient } from '../../src/mcp/mcp-route-client.ts'; import { ProjectClient } from '../../src/project-client.ts'; +import { HostSessionClient } from '../../src/sessions/host-session-client.ts'; import { SkillClient } from '../../src/skill-client.ts'; export const neverFetch: typeof fetch = async () => { throw new Error('Effects do not run during server rendering.'); }; @@ -237,6 +238,7 @@ export const clients = (): WorkspaceClients => { evalClient: new EvalClient({ foreground }), foreground, hookClient: new HookClient({ foreground }), + hostSessionClient: new HostSessionClient({ foreground }), lifecycleClient: new LifecycleClient({ foreground }), mcpRoutes: new McpRouteClient({ fetch: neverFetch, foreground }), skillClient: new SkillClient({ fetch: neverFetch }), diff --git a/packages/workbench/tests/workbench-location.test.ts b/packages/workbench/tests/workbench-location.test.ts index dfb3098ca..4cb5fd9c8 100644 --- a/packages/workbench/tests/workbench-location.test.ts +++ b/packages/workbench/tests/workbench-location.test.ts @@ -32,7 +32,8 @@ const roundTrips: readonly Readonly<{ readonly location: WorkbenchLocation; read { location: { area: 'trace', correlation: 'tool:a/b c', invocationId: 'trc_12' }, url: '/trace/trc_12?correlation=tool%3Aa%2Fb%20c' }, { location: { area: 'problems' }, url: '/problems' }, { location: { area: 'sessions' }, url: '/sessions' }, - { location: { area: 'sessions', host: 'claude' }, url: '/sessions/claude' }, + { location: { area: 'sessions', session: 'hs_0123456789abcdef' }, url: '/sessions?session=hs_0123456789abcdef' }, + { location: { area: 'sessions', session: 'a b/c' }, url: '/sessions?session=a%20b%2Fc' }, ...advancedSections.map((section) => ({ location: { area: 'advanced' as const, section }, url: `/advanced/${section}` })), ]; @@ -96,6 +97,9 @@ it('normalizes trace, sessions, and advanced tails', () => { expect(parseWorkbenchLocation('/trace/a/b')).toEqual({ area: 'trace' }); expect(parseWorkbenchLocation('/trace/%ZZ')).toEqual({ area: 'trace' }); expect(parseWorkbenchLocation('/sessions/a/b')).toEqual({ area: 'sessions' }); + expect(parseWorkbenchLocation('/sessions', '?session=')).toEqual({ area: 'sessions' }); + expect(parseWorkbenchLocation('/sessions', '?session=a%00b')).toEqual({ area: 'sessions' }); + expect(parseWorkbenchLocation('/sessions', '?invocation=inv-1&session=hs_1')).toEqual({ area: 'sessions', session: 'hs_1' }); expect(parseWorkbenchLocation('/advanced')).toEqual({ area: 'advanced', section: 'evals' }); expect(parseWorkbenchLocation('/advanced/nope')).toEqual({ area: 'advanced', section: 'evals' }); expect(parseWorkbenchLocation('/advanced/logs/extra')).toEqual({ area: 'advanced', section: 'evals' }); diff --git a/packages/workbench/tests/workbench-shell.test.ts b/packages/workbench/tests/workbench-shell.test.ts index 625a6dd64..5b65c7f9a 100644 --- a/packages/workbench/tests/workbench-shell.test.ts +++ b/packages/workbench/tests/workbench-shell.test.ts @@ -49,15 +49,15 @@ const shell = (location: ShellProps['location'], problems: readonly Problem[] = ...extra, })); -it('renders the primary destinations in order, without Sessions, and marks the active one', () => { - expect(workbenchNavItems.map((item) => item.label)).toEqual(['Application', 'Trace', 'Problems', 'Advanced']); +it('renders the primary destinations in order and marks the active one', () => { + expect(workbenchNavItems.map((item) => item.label)).toEqual(['Application', 'Trace', 'Host sessions', 'Problems', 'Advanced']); const markup = shell({ area: 'trace' }); expect(markup).toContain('data-testid="workbench-nav"'); - expect(markup).not.toContain('Sessions'); expect(markup).toMatch(/]*aria-current="page"[^>]*data-area="trace"/u); expect(markup).not.toMatch(/aria-current="page"[^>]*data-area="application"/u); expect(markup).toContain('href="/"'); expect(markup).toContain('href="/trace"'); + expect(markup).toContain('href="/sessions"'); expect(markup).toContain('href="/problems"'); expect(markup).toContain('href="/advanced/evals"'); expect(markup).toContain('data-testid="workbench-area-trace"'); From 2a473d366b372cb52251d55c5dbc6e69094ee18d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:17:16 +0000 Subject: [PATCH 06/16] lane S3: LANE-NOTES --- LANE-NOTES.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..3fb06ec86 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,92 @@ +# Lane S3 — Workbench host sessions (#600 PR 3) + +Branch `lane/wb600-pr3-s3`. Consumes the frozen `/api/sessions` HTTP/SSE contract in +`/tmp/wb600/PR3-CONTRACT.md` (incl. the 06:20 `traceSessionId` amendment). No server code. + +## Files + +New (`packages/workbench/src/sessions/`): + +- `host-session-contracts.ts` — browser-safe wire types: `HostSessionHost`, `HostSessionState`, + `HostSessionAuthority`, `HostSession` (with `traceSessionId?`), `HostAvailability`, + `HostSessionLaunchRequest`, `HostSessionSize`, `HostSessionList`. **Temporary** — see request 1. +- `host-session-client.ts` — `HostSessionClient` (`list`, `launch`, `read`, `stream`, `input`, + `resize`, `terminate`, `restart`, `forget`), `HostSessionClientError` (`code`, `status`), + `HostSessionStreamMessage` (`state` / `output` with decoded `bytes` / `end`), `decodeBase64`. + Zod strict decoders; every path goes through `ForegroundRequestAuthority.protectedRequest` + (mutation session, same as `/api/invocations`). Keep-alive comment frames are skipped. +- `host-session-model.ts` — `reduceHostSessions` (`list` / `session` / `forget` / `error`; + `session` replaces the record by id, so a repeated `state` frame carrying `traceSessionId` + just lands), `initialHostSessionsState`, `hosts`, `defaultHostSessionSize` (120×32), + `hostLabel`, `sessionStateLabel`, `availabilityFor`, `hostSessionPromptFor(leaf)`. +- `terminal.tsx` + `terminal.css` — `SessionTerminal`: one xterm per mounted session id; SSE + `output` → `term.write(bytes)`, `onData` → `POST …/input`, `ResizeObserver` → debounced 100 ms + `fit.fit()` → `onResize` → `POST …/resize`; `state`/`end` frames → `onSession`. `live=false` + (exited/terminated) suppresses input/resize posts. xterm's stylesheet is pulled with + `@import '@xterm/xterm/css/xterm.css'` from `terminal.css` (a bare `import '…/xterm.css'` in + TSX is externalized by the Rstest pool and fails under Node). +- `sessions-page.tsx` + `sessions-page.css` — `SessionsPage({ client, onNavigate, session? })`: + left column launch buttons (disabled with the availability `reason`), session list; right + column toolbar (`Trace` link → `{ area: 'trace', correlation: traceSessionId ?? id }`, + `Terminate`, `Restart`, `Forget`), terminal, authority strip (project root, epoch, install, + state + pid/exit/signal, restartOf, host session id). +- `open-in-host.tsx` — `OpenInHost({ client, leaf, onNavigate })`: `Open in Claude` / + `Open in Codex` beside Run; `GET /api/sessions` on mount for availability; `POST /api/sessions` + with the contract prompt, then navigates to `{ area: 'sessions', session: id }`. Renders + nothing for leaves without a prompt (resource, prompt, script, skill, command, rule). + +Modified: + +- `packages/agent-bundle/src/dev/trace/trace-entry.ts` — `traceSources` += `'session'` + (identical to S1/S2's edit; dedupe on merge). +- `shell/workbench-location.ts` — `sessions` location is `{ area: 'sessions', session?: string }`, + URL `/sessions?session=`; the unused `/sessions/` form is gone. +- `shell/workbench-shell.tsx` — nav item `Host sessions` (`▣`) between Trace and Problems; + `Exclude` removed. +- `main.tsx` — `HostSessionClient` in the shared client set and in `WorkspaceClients`; + `case 'sessions'` renders `SessionsPage`. +- `application/workspace-contracts.ts` — `WorkspaceClients.hostSessionClient`. +- `application/route-input-editor.tsx`, `executable-route-workspace.tsx`, + `event-route-workspace.tsx`, `route-workspace.tsx` — optional `actions` node rendered beside + Run; `InvokeWorkspace` supplies ``. +- `trace/trace-model.ts` — `'session'` in headline priority (top), invocation-level rows, glyph + `▣`; `traceGroupKeyValue` (moved from the page); `traceGroupSessionLocation(group)` → Sessions + pane link when the group is keyed by `sessionId` and the value starts with `hs_`. Kind labels + for `session.attached/exited/terminated`. +- `trace/trace-page.tsx` — group header renders a `Session` link (`data-testid="trace-group-session"`). +- `THIRD_PARTY_NOTICES` — xterm.js + addon-fit (MIT, full text inlined; no extra copied file). +- `tests/support/workbench-acceptance.ts` — `primaryNavLabels` gains `Host sessions`; + `workbenchTestIds` gains `routeOpenInClaude`, `routeOpenInCodex`, `sessionsAuthority`, + `sessionsEmpty`, `sessionsForget`, `sessionsItem`, `sessionsLaunchClaude`, `sessionsLaunchCodex`, + `sessionsList`, `sessionsPlaceholder`, `sessionsRestart`, `sessionsState`, `sessionsTerminal`, + `sessionsTerminate`, `sessionsTrace`, `traceGroupSession`. + +Tests: `host-session-client.test.ts`, `host-session-model.test.ts`, `sessions-page.test.ts` +(static render of the page and `OpenInHost`), plus extended `workbench-location.test.ts`, +`trace-model.test.ts`, `trace-page.test.ts`, `workbench-shell.test.ts`; `workspace-fixtures.ts` +constructs the new client. + +## Requests for the integrator + +1. Replace the body of `src/sessions/host-session-contracts.ts` with + `export type { … } from '../../../agent-bundle/src/contracts/host-sessions.ts'` (or point the + four importers — client, model, terminal, page — at it directly and delete the file) once S1 + lands. Field names/optionality here match the contract exactly; `HostSessionLaunchRequest`, + `HostSessionSize`, and `HostSessionList` are convenience shapes S1 may or may not export. +2. `traceSources` `'session'`: three lanes append the same literal — keep one. +3. The browser client reports its own failures (malformed id, undecodable response/frame) as + `AB8261`; the server's diagnostic `code` wins whenever a response carries one. If you prefer a + dedicated browser-decoder code (the AB8233–AB8235 block), it is one constant in + `host-session-client.ts`. +4. `Open in ` seeds the prompt with `leaf.routeId` verbatim for tools + (`Call the tool:curator/search_audible tool …`), as the contract text says. If the intended + spelling is the bare tool name, change one line in `hostSessionPromptFor`. +5. `primaryNavLabels` now has five entries; every e2e that asserts the nav (`expectPrimaryNav`) + picks it up automatically. +6. Not done here (S4/S2 own them): docs pages, changeset, browser e2e, `dev-server-http.mdx` + `TraceEntry.source` union. + +## Gate + +`pnpm build && pnpm typecheck && pnpm lint` green; Workbench unit pool +(`pnpm rstest --config rstest.unit.config.ts packages/workbench/tests`) green — see the report. From 359e4e208618ec188b5ac598dbbb452c493ca6d8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:26:49 +0000 Subject: [PATCH 07/16] wire host-session attach/traceSessionId into receipts and host MCP routes; share isHostSessionId --- .../src/dev/sessions/host-session-routes.ts | 11 ++++++----- packages/agent-bundle/src/dev/workbench-server.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts index f8a8a5376..a0fc7b7ca 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -1,9 +1,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { - HostAvailability, - HostSession, - HostSessionHost, +import { + type HostAvailability, + type HostSession, + type HostSessionHost, + isHostSessionId, } from '../../contracts/host-sessions.ts'; import { createBackpressuredWriter, @@ -47,7 +48,7 @@ const sessionPath = (target: string | undefined): SessionPath | undefined => { message: 'Host-session path is not valid.', rejectBlank: true, }); - if (!/^hs_[0-9a-z]{16}$/u.test(id)) invalid(); + if (!isHostSessionId(id)) invalid(); if (parts.length === 4) return { id, kind: 'item' }; const action = parts[4]; switch (action) { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 4b3547a19..903469905 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -622,7 +622,11 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const eventHub = new ProjectEventHub(); const epochStore = new EpochStore({ projectRoot: root }); const traceHub = new TraceHub({ projectRoot: root }); - const hookReceipts = attachHookReceipts({ projectRoot: root, trace: traceHub }); + const hookReceipts = attachHookReceipts({ + attachHostSession: (devSession, hostSessionId) => hostSessions.attach(devSession, hostSessionId), + projectRoot: root, + trace: traceHub, + }); let hookReceiptUrl: string | undefined; const logs = new DevLogService({ projectRoot: root, trace: traceHub }); const detachProjectLogs = attachProjectEventLogs(logs, eventHub); @@ -842,7 +846,13 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, trace: traceHub, }); - const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions }); + const hostMcp = new HostMcpRoutes({ + adoption: epochAdoption, + epochStore, + eventHub, + mcpSessions, + traceSessionId: (devSession) => hostSessions.traceSessionId(devSession), + }); const hookPlayground = new HookPlaygroundService({ epochStore, hookService: new HookService({ From e00a03e83b819905ce3790fb964a6b3d59d86c4e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:28:33 +0000 Subject: [PATCH 08/16] share host-session wire shapes through agent-bundle/contracts; drop the Workbench shim --- .../src/contracts/host-sessions.ts | 15 +++++ .../src/dev/sessions/host-session-routes.ts | 8 ++- .../src/sessions/host-session-client.ts | 2 +- .../src/sessions/host-session-contracts.ts | 56 ------------------- .../src/sessions/host-session-model.ts | 2 +- .../workbench/src/sessions/open-in-host.tsx | 2 +- .../workbench/src/sessions/sessions-page.tsx | 2 +- packages/workbench/src/sessions/terminal.tsx | 2 +- .../tests/host-session-client.test.ts | 2 +- .../tests/host-session-model.test.ts | 2 +- 10 files changed, 27 insertions(+), 66 deletions(-) delete mode 100644 packages/workbench/src/sessions/host-session-contracts.ts diff --git a/packages/agent-bundle/src/contracts/host-sessions.ts b/packages/agent-bundle/src/contracts/host-sessions.ts index 8397282b3..ed087323b 100644 --- a/packages/agent-bundle/src/contracts/host-sessions.ts +++ b/packages/agent-bundle/src/contracts/host-sessions.ts @@ -29,5 +29,20 @@ export interface HostAvailability { readonly executable?: string; } +export interface HostSessionSize { + readonly cols: number; + readonly rows: number; +} + +export interface HostSessionLaunchRequest extends HostSessionSize { + readonly host: HostSessionHost; + readonly prompt?: string; +} + +export interface HostSessionList { + readonly hosts: readonly HostAvailability[]; + readonly sessions: readonly HostSession[]; +} + export const isHostSessionId = (value: unknown): value is string => typeof value === 'string' && /^hs_[0-9a-z]{16}$/.test(value); diff --git a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts index a0fc7b7ca..b8dfa6bc9 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -4,6 +4,8 @@ import { type HostAvailability, type HostSession, type HostSessionHost, + type HostSessionLaunchRequest, + type HostSessionSize, isHostSessionId, } from '../../contracts/host-sessions.ts'; import { @@ -72,7 +74,7 @@ const jsonBody = (request: IncomingMessage) => readJsonBody(request, { }, }); -const dimensions = (value: Readonly>): Readonly<{ readonly cols: number; readonly rows: number }> => { +const dimensions = (value: Readonly>): HostSessionSize => { if ( !hasOnly(value, ['cols', 'rows']) || !Number.isInteger(value.cols) || (value.cols as number) < 1 || (value.cols as number) > 500 @@ -95,13 +97,13 @@ const creation = (value: Readonly>) => { export interface HostSessionRouteService { availability(): Promise; close?(): Promise | void; - create(request: Readonly<{ readonly cols: number; readonly host: HostSessionHost; readonly prompt?: string; readonly rows: number }>): Promise; + create(request: HostSessionLaunchRequest): Promise; forget(id: string): boolean; input(id: string, data: string): void; list(): readonly HostSession[]; read(id: string): HostSession | undefined; resize(id: string, cols: number, rows: number): void; - restart(id: string, size: Readonly<{ readonly cols: number; readonly rows: number }>): Promise; + restart(id: string, size: HostSessionSize): Promise; subscribe(id: string, listener: (message: HostSessionStreamMessage) => void): () => void; terminate(id: string): Promise; } diff --git a/packages/workbench/src/sessions/host-session-client.ts b/packages/workbench/src/sessions/host-session-client.ts index 6824e1a44..3fc46d4c4 100644 --- a/packages/workbench/src/sessions/host-session-client.ts +++ b/packages/workbench/src/sessions/host-session-client.ts @@ -9,7 +9,7 @@ import type { HostSessionLaunchRequest, HostSessionList, HostSessionSize, -} from './host-session-contracts.ts'; +} from '../../../agent-bundle/src/contracts/host-sessions.ts'; export interface HostSessionClientOptions { readonly foreground: ForegroundRequestAuthority; diff --git a/packages/workbench/src/sessions/host-session-contracts.ts b/packages/workbench/src/sessions/host-session-contracts.ts deleted file mode 100644 index 0c223c6fa..000000000 --- a/packages/workbench/src/sessions/host-session-contracts.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Browser-safe host-session wire types (#600 PR 3, frozen HTTP contract). - * Declared here until `agent-bundle/src/contracts/host-sessions.ts` lands; - * the integrator switches this module to a re-export of that file. - */ -export type HostSessionHost = 'claude' | 'codex'; - -export type HostSessionState = 'exited' | 'running' | 'terminated'; - -export interface HostSessionAuthority { - readonly epochId: string; - readonly install: string; - readonly projectRoot: string; -} - -export interface HostSession { - readonly authority: HostSessionAuthority; - readonly cols: number; - readonly endedAt?: number; - readonly exitCode?: number; - readonly host: HostSessionHost; - readonly id: string; - readonly pid?: number; - readonly prompt?: string; - readonly restartOf?: string; - readonly rows: number; - readonly signal?: string; - readonly startedAt: number; - readonly state: HostSessionState; - /** The host's own session id once a hook receipt revealed it; the trace join key. */ - readonly traceSessionId?: string; -} - -export interface HostAvailability { - readonly executable?: string; - readonly host: HostSessionHost; - readonly launchable: boolean; - readonly reason?: string; -} - -export interface HostSessionLaunchRequest { - readonly cols: number; - readonly host: HostSessionHost; - readonly prompt?: string; - readonly rows: number; -} - -export interface HostSessionSize { - readonly cols: number; - readonly rows: number; -} - -export interface HostSessionList { - readonly hosts: readonly HostAvailability[]; - readonly sessions: readonly HostSession[]; -} diff --git a/packages/workbench/src/sessions/host-session-model.ts b/packages/workbench/src/sessions/host-session-model.ts index 71c9266ba..b62ceb375 100644 --- a/packages/workbench/src/sessions/host-session-model.ts +++ b/packages/workbench/src/sessions/host-session-model.ts @@ -5,7 +5,7 @@ import type { HostSessionHost, HostSessionSize, HostSessionState, -} from './host-session-contracts.ts'; +} from '../../../agent-bundle/src/contracts/host-sessions.ts'; /** The Sessions pane's view of the list; scrollback belongs to xterm, not here. */ export interface HostSessionsState { diff --git a/packages/workbench/src/sessions/open-in-host.tsx b/packages/workbench/src/sessions/open-in-host.tsx index 37e53ba2b..53a78e4a1 100644 --- a/packages/workbench/src/sessions/open-in-host.tsx +++ b/packages/workbench/src/sessions/open-in-host.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import type { ApplicationLeaf } from '../application/application-tree-model.ts'; import { errorMessage, isAbortError } from '../client-helpers.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; -import type { HostAvailability, HostSessionHost } from './host-session-contracts.ts'; +import type { HostAvailability, HostSessionHost } from '../../../agent-bundle/src/contracts/host-sessions.ts'; import type { HostSessionClient } from './host-session-client.ts'; import { availabilityFor, defaultHostSessionSize, hostLabel, hosts, hostSessionPromptFor } from './host-session-model.ts'; diff --git a/packages/workbench/src/sessions/sessions-page.tsx b/packages/workbench/src/sessions/sessions-page.tsx index baf7b9013..cee37e8a1 100644 --- a/packages/workbench/src/sessions/sessions-page.tsx +++ b/packages/workbench/src/sessions/sessions-page.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useReducer, useRef } from 'react'; import { errorMessage, isAbortError } from '../client-helpers.ts'; import { ShellLink } from '../shell/shell-link.tsx'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; -import type { HostSession, HostSessionHost, HostSessionSize } from './host-session-contracts.ts'; +import type { HostSession, HostSessionHost, HostSessionSize } from '../../../agent-bundle/src/contracts/host-sessions.ts'; import type { HostSessionClient } from './host-session-client.ts'; import { availabilityFor, diff --git a/packages/workbench/src/sessions/terminal.tsx b/packages/workbench/src/sessions/terminal.tsx index 8df662e0e..1e11c7b98 100644 --- a/packages/workbench/src/sessions/terminal.tsx +++ b/packages/workbench/src/sessions/terminal.tsx @@ -3,7 +3,7 @@ import { Terminal } from '@xterm/xterm'; import React, { useEffect, useRef } from 'react'; import { errorMessage, isAbortError } from '../client-helpers.ts'; -import type { HostSession, HostSessionSize } from './host-session-contracts.ts'; +import type { HostSession, HostSessionSize } from '../../../agent-bundle/src/contracts/host-sessions.ts'; import type { HostSessionClient } from './host-session-client.ts'; import './terminal.css'; diff --git a/packages/workbench/tests/host-session-client.test.ts b/packages/workbench/tests/host-session-client.test.ts index fcf88dbd2..3a3fdd7f4 100644 --- a/packages/workbench/tests/host-session-client.test.ts +++ b/packages/workbench/tests/host-session-client.test.ts @@ -7,7 +7,7 @@ import { HostSessionClientError, type HostSessionStreamMessage, } from '../src/sessions/host-session-client.ts'; -import type { HostAvailability, HostSession } from '../src/sessions/host-session-contracts.ts'; +import type { HostAvailability, HostSession } from '../../agent-bundle/src/contracts/host-sessions.ts'; const session: HostSession = Object.freeze({ authority: Object.freeze({ epochId: 'epoch-1', install: '/home/dev/.claude/plugins/cache/curator', projectRoot: '/work/curator' }), diff --git a/packages/workbench/tests/host-session-model.test.ts b/packages/workbench/tests/host-session-model.test.ts index 3c9f66122..0bfeb5c1b 100644 --- a/packages/workbench/tests/host-session-model.test.ts +++ b/packages/workbench/tests/host-session-model.test.ts @@ -1,6 +1,6 @@ import { expect, it } from '@rstest/core'; -import type { HostAvailability, HostSession } from '../src/sessions/host-session-contracts.ts'; +import type { HostAvailability, HostSession } from '../../agent-bundle/src/contracts/host-sessions.ts'; import { availabilityFor, defaultHostSessionSize, From 60328d188155c08425935063dd4140c8a9cbf91b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:37:11 +0000 Subject: [PATCH 09/16] =?UTF-8?q?feat(dev):=20resolve=20a=20host=20MCP=20p?= =?UTF-8?q?roxy's=20Workbench=20session=20by=20process=20ancestry=20?= =?UTF-8?q?=E2=80=94=20Codex=20forwards=20only=20a=20whitelisted=20env=20t?= =?UTF-8?q?o=20stdio=20MCP=20servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-bundle/src/dev/host-mcp-proxy.ts | 15 ++++++-- .../agent-bundle/src/dev/host-mcp-routes.ts | 18 +++++++++- .../src/dev/sessions/host-session-service.ts | 36 ++++++++++++++++++- .../agent-bundle/src/dev/workbench-server.ts | 1 + .../tests/host-mcp-routes.test.ts | 30 ++++++++++++---- .../tests/host-session-service.test.ts | 20 ++++++++++- 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/agent-bundle/src/dev/host-mcp-proxy.ts b/packages/agent-bundle/src/dev/host-mcp-proxy.ts index b1feed1ce..8e1f51132 100644 --- a/packages/agent-bundle/src/dev/host-mcp-proxy.ts +++ b/packages/agent-bundle/src/dev/host-mcp-proxy.ts @@ -11,13 +11,22 @@ import { isRecord } from '../core/strict-json.ts'; import { discoverDevServerUrl } from './dev-lock.ts'; export const HOST_MCP_DEV_SESSION_HEADER = 'x-agent-bundle-dev-session'; +export const HOST_MCP_DEV_PID_HEADER = 'x-agent-bundle-dev-pid'; +/** The proxy always names its pid; the session id rides along only when the host forwarded the env. */ export const hostMcpProxyRequestInit = ( env: Readonly = process.env, -): { readonly requestInit: { readonly headers: Readonly> } } | undefined => { + pid = process.pid, +): { readonly requestInit: { readonly headers: Readonly> } } => { const session = env.AGENT_BUNDLE_DEV_SESSION; - if (!isHostSessionId(session)) return undefined; - return { requestInit: { headers: { [HOST_MCP_DEV_SESSION_HEADER]: session } } }; + return { + requestInit: { + headers: { + [HOST_MCP_DEV_PID_HEADER]: String(pid), + ...(isHostSessionId(session) ? { [HOST_MCP_DEV_SESSION_HEADER]: session } : {}), + }, + }, + }; }; export const hostMcpUnavailableCode = 'AB8025'; diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index fe15af6b6..66d38ba6d 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -20,6 +20,7 @@ import { export const HOST_MCP_DEV_SESSION_CODE = 'AB8266'; const hostDevSessionHeader = 'x-agent-bundle-dev-session'; +const hostDevPidHeader = 'x-agent-bundle-dev-pid'; export const hostDevSessionId = (headers: IncomingMessage['headers']): string | undefined => { const value = singleHeader(headers[hostDevSessionHeader]); @@ -34,6 +35,15 @@ export const hostDevSessionId = (headers: IncomingMessage['headers']): string | return value; }; +export const hostDevProcessId = (headers: IncomingMessage['headers']): number | undefined => { + const value = singleHeader(headers[hostDevPidHeader]); + if (value === undefined) return undefined; + if (!/^[1-9]\d{0,9}$/u.test(value)) { + throw requestError(diagnostic(HOST_MCP_DEV_SESSION_CODE, 'x-agent-bundle-dev-pid must be a process id.', 400)); + } + return Number(value); +}; + const hostMcpPathPrefix = '/mcp/host/'; const internalErrorCode = -32_603; @@ -72,6 +82,8 @@ export interface HostMcpRoutesOptions { readonly eventHub: ProjectEventHub; readonly mcpSessions: McpSessionService; readonly traceSessionId?: (devSession: string) => string; + /** Resolves the Workbench host session owning a proxy process when the host did not forward `AGENT_BUNDLE_DEV_SESSION`. */ + readonly sessionForProcess?: (pid: number) => Promise; } const requestSessionId = (request: IncomingMessage): string | undefined => { @@ -382,6 +394,7 @@ export class HostMcpRoutes { readonly #mcpSessions: McpSessionService; readonly #sessions = new Map(); readonly #subscription: ProjectEventSubscription; + readonly #sessionForProcess: HostMcpRoutesOptions['sessionForProcess']; readonly #traceSessionId: HostMcpRoutesOptions['traceSessionId']; #closed = false; @@ -389,6 +402,7 @@ export class HostMcpRoutes { this.#adoption = options.adoption; this.#epochStore = options.epochStore; this.#mcpSessions = options.mcpSessions; + this.#sessionForProcess = options.sessionForProcess; this.#traceSessionId = options.traceSessionId; this.#subscription = subscribeToEpochAdoption(options.adoption, options.eventHub, (epochId) => { for (const connection of this.#connections) connection.refreshCatalog(epochId); @@ -418,7 +432,9 @@ export class HostMcpRoutes { return true; } - const devSession = hostDevSessionId(request.headers); + const pid = hostDevProcessId(request.headers); + const devSession = hostDevSessionId(request.headers) + ?? (pid === undefined ? undefined : await this.#sessionForProcess?.(pid)); const connection = new HostMcpConnection( binding, { diff --git a/packages/agent-bundle/src/dev/sessions/host-session-service.ts b/packages/agent-bundle/src/dev/sessions/host-session-service.ts index ef5942f57..3fdb7d035 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-service.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-service.ts @@ -1,7 +1,9 @@ import { Buffer } from 'node:buffer'; +import { execFile } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { access } from 'node:fs/promises'; +import { access, readFile } from 'node:fs/promises'; import { delimiter, resolve } from 'node:path'; +import { promisify } from 'node:util'; import type { HostAvailability, @@ -105,6 +107,24 @@ const executableOnPath = async ( return undefined; }; +const maxAncestryHops = 32; + +/** + * Codex hands its stdio MCP servers eight whitelisted variables, so the dev + * proxy cannot carry `AGENT_BUNDLE_DEV_SESSION`; it reports its pid instead + * and the session is found by walking up to the PTY child. + */ +const parentPid = async (pid: number): Promise => { + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${pid}/stat`, 'utf8').catch(() => undefined); + const fields = stat?.slice(stat.lastIndexOf(')') + 2).split(' '); + return fields === undefined ? undefined : Number(fields[1]); + } + const { stdout } = await promisify(execFile)('ps', ['-o', 'ppid=', '-p', String(pid)]).catch(() => ({ stdout: '' })); + const parsed = Number(stdout.trim()); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +}; + const signalName = (signal: number | undefined): string | undefined => signal === undefined ? undefined : signalNames.get(signal) ?? String(signal); @@ -186,6 +206,20 @@ export class HostSessionService { return this.#sessions.get(devSession)?.traceSessionId ?? devSession; } + /** The live session whose PTY child is `pid` or one of its ancestors. */ + async sessionForProcess(pid: number): Promise { + const byPid = new Map( + [...this.#sessions.values()].filter((record) => record.state === 'running').map((record) => [record.process.pid, record.id]), + ); + let current: number | undefined = pid; + for (let hop = 0; current !== undefined && current > 1 && hop < maxAncestryHops; hop += 1) { + const id = byPid.get(current); + if (id !== undefined) return id; + current = await parentPid(current); + } + return undefined; + } + async create(request: CreateHostSession): Promise { if (this.#closed) throw new HostSessionError(HOST_SESSION_UNAVAILABLE_CODE, 'Host-session service is not available.', 503); if ([...this.#sessions.values()].filter((record) => record.state === 'running').length >= 4) { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 903469905..11c0eefaf 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -851,6 +851,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun epochStore, eventHub, mcpSessions, + sessionForProcess: (pid) => hostSessions.sessionForProcess(pid), traceSessionId: (devSession) => hostSessions.traceSessionId(devSession), }); const hookPlayground = new HookPlaygroundService({ diff --git a/packages/agent-bundle/tests/host-mcp-routes.test.ts b/packages/agent-bundle/tests/host-mcp-routes.test.ts index 8123cb103..3b59daed3 100644 --- a/packages/agent-bundle/tests/host-mcp-routes.test.ts +++ b/packages/agent-bundle/tests/host-mcp-routes.test.ts @@ -1,8 +1,8 @@ import { expect, it } from '@rstest/core'; import { isHostSessionId } from '../src/contracts/host-sessions.ts'; -import { hostMcpProxyRequestInit, HOST_MCP_DEV_SESSION_HEADER } from '../src/dev/host-mcp-proxy.ts'; -import { HOST_MCP_DEV_SESSION_CODE, hostDevSessionId } from '../src/dev/host-mcp-routes.ts'; +import { hostMcpProxyRequestInit, HOST_MCP_DEV_PID_HEADER, HOST_MCP_DEV_SESSION_HEADER } from '../src/dev/host-mcp-proxy.ts'; +import { HOST_MCP_DEV_SESSION_CODE, hostDevProcessId, hostDevSessionId } from '../src/dev/host-mcp-routes.ts'; import { isRequestDiagnostic } from '../src/dev/http.ts'; const hostSessionId = 'hs_0123456789abcdef'; @@ -28,10 +28,26 @@ it('reads a valid x-agent-bundle-dev-session header and rejects a malformed valu expect(caught).toMatchObject({ code: HOST_MCP_DEV_SESSION_CODE, status: 400 }); }); -it('sends the proxy header only when AGENT_BUNDLE_DEV_SESSION is a valid host-session id', () => { - expect(hostMcpProxyRequestInit({})).toBeUndefined(); - expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: 'hs_nope' })).toBeUndefined(); - expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: hostSessionId })).toEqual({ - requestInit: { headers: { [HOST_MCP_DEV_SESSION_HEADER]: hostSessionId } }, +it('always names the proxy pid and adds the session header only for a valid AGENT_BUNDLE_DEV_SESSION', () => { + expect(hostMcpProxyRequestInit({}, 4242)).toEqual({ requestInit: { headers: { [HOST_MCP_DEV_PID_HEADER]: '4242' } } }); + expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: 'hs_nope' }, 4242)).toEqual({ + requestInit: { headers: { [HOST_MCP_DEV_PID_HEADER]: '4242' } }, }); + expect(hostMcpProxyRequestInit({ AGENT_BUNDLE_DEV_SESSION: hostSessionId }, 4242)).toEqual({ + requestInit: { headers: { [HOST_MCP_DEV_PID_HEADER]: '4242', [HOST_MCP_DEV_SESSION_HEADER]: hostSessionId } }, + }); +}); + +it('reads x-agent-bundle-dev-pid as a process id and rejects anything else with AB8266', () => { + expect(hostDevProcessId({})).toBeUndefined(); + expect(hostDevProcessId({ [HOST_MCP_DEV_PID_HEADER]: '4242' })).toBe(4242); + for (const value of ['0', '-1', '12a', '01']) { + let caught: unknown; + try { + hostDevProcessId({ [HOST_MCP_DEV_PID_HEADER]: value }); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ code: HOST_MCP_DEV_SESSION_CODE, status: 400 }); + } }); diff --git a/packages/agent-bundle/tests/host-session-service.test.ts b/packages/agent-bundle/tests/host-session-service.test.ts index 3624f0511..af6ea42a3 100644 --- a/packages/agent-bundle/tests/host-session-service.test.ts +++ b/packages/agent-bundle/tests/host-session-service.test.ts @@ -1,3 +1,5 @@ +import { spawn } from 'node:child_process'; + import { expect, it } from '@rstest/core'; import type { HostSessionStreamMessage } from '../src/dev/sessions/host-session-service.ts'; @@ -59,8 +61,10 @@ class FakeAdapter implements PtyAdapter { readonly pty: FakePty; }> = []; + constructor(readonly pids: readonly number[] = []) {} + spawn(file: string, args: readonly string[], options: PtySpawnOptions): PtyProcess { - const pty = new FakePty(4_000 + this.spawns.length); + const pty = new FakePty(this.pids[this.spawns.length] ?? 4_000 + this.spawns.length); this.spawns.push({ args, file, options, pty }); return pty; } @@ -187,6 +191,20 @@ it('attaches the host trace id and uses it for later lifecycle entries', async ( }); }); +it('finds the session owning a descendant process by walking its ancestry', async () => { + const service = serviceFor(new FakeAdapter([process.pid])); + const session = await service.create({ cols: 80, host: 'codex', rows: 24 }); + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + try { + await new Promise((resolvePromise) => child.once('spawn', resolvePromise)); + expect(await service.sessionForProcess(child.pid!)).toBe(session.id); + expect(await service.sessionForProcess(process.pid)).toBe(session.id); + expect(await service.sessionForProcess(2 ** 22 - 1)).toBeUndefined(); + } finally { + child.kill('SIGKILL'); + } +}); + it('terminates with SIGKILL fallback and restarts with the prompt', async () => { const adapter = new FakeAdapter(); const service = serviceFor(adapter, { terminationGraceMs: 5 }); From 1673acc7f1a816c224e2144686e7fcde09992dc0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:38:45 +0000 Subject: [PATCH 10/16] host sessions: record only a measured exit signal --- .../agent-bundle/src/dev/sessions/host-session-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/dev/sessions/host-session-service.ts b/packages/agent-bundle/src/dev/sessions/host-session-service.ts index 3fdb7d035..9510558f8 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-service.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-service.ts @@ -125,8 +125,9 @@ const parentPid = async (pid: number): Promise => { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; }; +/** node-pty reports `signal: 0` for a normal exit; only a real signal is a signal. */ const signalName = (signal: number | undefined): string | undefined => - signal === undefined ? undefined : signalNames.get(signal) ?? String(signal); + signal === undefined || signal === 0 ? undefined : signalNames.get(signal) ?? String(signal); export class HostSessionService { readonly #attached: HostSessionServiceOptions['attached']; @@ -416,7 +417,7 @@ export class HostSessionService { record.state = record.terminating ? 'terminated' : 'exited'; record.endedAt = this.#now(); record.exitCode = exitCode; - record.signal = record.terminating ? signal ?? 'SIGTERM' : signal; + record.signal = signal; const session = this.#snapshot(record); this.#send(record, { session, type: 'state' }); this.#send(record, { session, type: 'end' }); From 651c0bf61e51443ad8c8c522fbafe997c0a8b640 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 06:41:40 +0000 Subject: [PATCH 11/16] workbench(sessions): send only cols/rows on resize (xterm's onResize event carries change flags) --- packages/workbench/src/sessions/terminal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/workbench/src/sessions/terminal.tsx b/packages/workbench/src/sessions/terminal.tsx index 1e11c7b98..f256fb1ac 100644 --- a/packages/workbench/src/sessions/terminal.tsx +++ b/packages/workbench/src/sessions/terminal.tsx @@ -51,7 +51,8 @@ export const SessionTerminal = ({ client, live, onError, onSession, onSize, sess const input = term.onData((data) => { if (callbacks.current.live) client.input(sessionId, data).catch(fail); }); - const resized = term.onResize((size) => { + const resized = term.onResize(({ cols, rows }) => { + const size = { cols, rows }; callbacks.current.onSize(size); if (callbacks.current.live) client.resize(sessionId, size).catch(fail); }); From 6eba57cbdcaf79ef1148cb05aa522c09ba286208 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 07:41:05 +0000 Subject: [PATCH 12/16] test: accept workbench host sessions --- .changeset/wb600-host-sessions.md | 5 + LANE-NOTES.md | 17 ++ .../tests/native-host-sessions.test.ts | 77 ++++++ .../tests/support/fake-host-cli/claude | 4 + .../tests/support/fake-host-cli/codex | 4 + .../tests/support/fake-host-cli/fake-host.mjs | 253 ++++++++++++++++++ .../workbench/src/sessions/sessions-page.tsx | 6 +- packages/workbench/src/sessions/terminal.tsx | 1 + packages/workbench/src/trace/trace-model.ts | 11 +- packages/workbench/tests/sessions.e2e.test.ts | 174 ++++++++++++ .../workbench/tests/support/workbench-e2e.ts | 2 +- packages/workbench/tests/trace-model.test.ts | 4 + rstest.integration-tests.ts | 2 + .../docs/en/guide/development/workbench.mdx | 49 +++- website/docs/en/reference/dev-server-http.mdx | 52 ++++ .../docs/zh/guide/development/workbench.mdx | 39 ++- website/docs/zh/reference/dev-server-http.mdx | 50 ++++ 17 files changed, 742 insertions(+), 8 deletions(-) create mode 100644 .changeset/wb600-host-sessions.md create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/tests/native-host-sessions.test.ts create mode 100755 packages/agent-bundle/tests/support/fake-host-cli/claude create mode 100755 packages/agent-bundle/tests/support/fake-host-cli/codex create mode 100644 packages/agent-bundle/tests/support/fake-host-cli/fake-host.mjs create mode 100644 packages/workbench/tests/sessions.e2e.test.ts diff --git a/.changeset/wb600-host-sessions.md b/.changeset/wb600-host-sessions.md new file mode 100644 index 000000000..4c2f33e91 --- /dev/null +++ b/.changeset/wb600-host-sessions.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Add PTY-backed Claude Code and Codex sessions to `agent-bundle dev`, expose the Workbench `Host sessions` destination and `/api/sessions`, correlate activity through `AGENT_BUNDLE_DEV_SESSION`, and report `AB8260`–`AB8266` for session failures. (#PR) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..2c673d584 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,17 @@ +# S4 lane notes + +- Merged `wb600-pr3-sessions` at `60328d1881` before final acceptance work. +- Added dependency-free fake Claude and Codex CLIs, browser acceptance, skip-safe native host contracts, bilingual documentation, and the release changeset. +- Added a temporary `sessionStart` hook only to the copied browser fixture so the acceptance test can prove hook-receipt correlation without changing the public example. +- Fixed integrated S3 seams found by acceptance: removed two unused fixture-helper imports, retained the Sessions deep link after a trace group adopts the host-native conversation id, ignored invalid zero-sized xterm resize events, and restarted with the server-validated session dimensions. +- Codex acceptance keeps the real host's restricted MCP environment; correlation is proven through proxy PID ancestry. Claude correlation uses `AGENT_BUNDLE_DEV_SESSION`, and hook receipts carry `devSession`. + +## Gates + +- `pnpm build`: pass +- `pnpm typecheck`: pass +- `pnpm lint`: pass +- `pnpm rstest --config rstest.native-host.config.ts packages/agent-bundle/tests/native-host-sessions.test.ts`: pass (2 opt-in tests skipped) +- `pnpm docs:site:build`: pass +- `pnpm rstest --config rstest.config.ts packages/workbench/tests/sessions.e2e.test.ts`: pass +- `pnpm rstest --config rstest.config.ts packages/workbench/tests/trace-model.test.ts`: pass (7 tests) diff --git a/packages/agent-bundle/tests/native-host-sessions.test.ts b/packages/agent-bundle/tests/native-host-sessions.test.ts new file mode 100644 index 000000000..fc5bb8e0b --- /dev/null +++ b/packages/agent-bundle/tests/native-host-sessions.test.ts @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; + +import { expect, it } from '@rstest/core'; + +import type { HostSession, HostSessionHost } from '../src/contracts/host-sessions.ts'; +import type { TraceEntry, TraceReplay } from '../src/contracts/trace.ts'; +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { copyExample } from '../../workbench/tests/support/example-acceptance.ts'; + +const prompt = 'Call the search_audible tool with query "dune" and explain the result.'; +const hosts = ['claude', 'codex'] as const; + +const enabled = (host: HostSessionHost): boolean => + process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' + && spawnSync(host, ['--version'], { stdio: 'ignore', timeout: 5_000, windowsHide: true }).status === 0; + +const request = async ( + origin: string, + token: string, + path: string, + init: RequestInit = {}, +): Promise => { + const response = await fetch(`${origin}${path}`, { + ...init, + headers: { + ...init.headers, + 'x-agent-bundle-session': token, + }, + }); + const body = await response.json() as Body; + if (!response.ok) throw new Error(`${init.method ?? 'GET'} ${path} returned ${String(response.status)}: ${JSON.stringify(body)}`); + return body; +}; + +for (const host of hosts) { + const hostIt = enabled(host) ? it : it.skip; + hostIt(`correlates a real ${host} host session with its tools/call trace`, async () => { + const project = await copyExample('audiobook-curator'); + const server = await startDevServer({ installHosts: [host], open: false, port: 0, root: project.root }); + let created: HostSession | undefined; + let token: string | undefined; + try { + const authenticatedToken = await fetch(`${server.url}/api/project/session`) + .then((response) => response.json()) + .then((body: { readonly token: string }) => body.token); + token = authenticatedToken; + const launched = (await request<{ readonly session: HostSession }>(server.url, authenticatedToken, '/api/sessions', { + body: JSON.stringify({ cols: 100, host, prompt, rows: 30 }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + })).session; + created = launched; + + await expect.poll(async () => { + const [sessionBody, trace] = await Promise.all([ + request<{ readonly session: HostSession }>(server.url, authenticatedToken, `/api/sessions/${launched.id}`), + request(server.url, authenticatedToken, '/api/trace?after=0'), + ]); + const traceSessionId = sessionBody.session.traceSessionId ?? launched.id; + return trace.entries.some((entry: TraceEntry) => + entry.kind === 'mcp.request' + && entry.summary.includes('tools/call') + && entry.correlation.sessionId === traceSessionId); + }, { interval: 500, timeout: 120_000 }).toBe(true); + } finally { + if (created !== undefined && token !== undefined) { + await request(server.url, token, `/api/sessions/${created.id}/terminate`, { + body: '{}', + headers: { 'content-type': 'application/json' }, + method: 'POST', + }).catch(() => undefined); + } + await server.close(); + await project.release(); + } + }, 120_000); +} diff --git a/packages/agent-bundle/tests/support/fake-host-cli/claude b/packages/agent-bundle/tests/support/fake-host-cli/claude new file mode 100755 index 000000000..681b87c95 --- /dev/null +++ b/packages/agent-bundle/tests/support/fake-host-cli/claude @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runFakeHost } from './fake-host.mjs'; + +runFakeHost('claude'); diff --git a/packages/agent-bundle/tests/support/fake-host-cli/codex b/packages/agent-bundle/tests/support/fake-host-cli/codex new file mode 100755 index 000000000..96b3bb688 --- /dev/null +++ b/packages/agent-bundle/tests/support/fake-host-cli/codex @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runFakeHost } from './fake-host.mjs'; + +runFakeHost('codex'); diff --git a/packages/agent-bundle/tests/support/fake-host-cli/fake-host.mjs b/packages/agent-bundle/tests/support/fake-host-cli/fake-host.mjs new file mode 100644 index 000000000..bb9f2c024 --- /dev/null +++ b/packages/agent-bundle/tests/support/fake-host-cli/fake-host.mjs @@ -0,0 +1,253 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, readFile, readdir, rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { createInterface } from 'node:readline'; + +const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')); + +const hostHome = (host) => host === 'claude' + ? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + : process.env.CODEX_HOME ?? join(homedir(), '.codex'); + +const cacheRoot = (host) => join(hostHome(host), 'plugins', 'cache'); + +const findInstall = async (host) => { + const cache = cacheRoot(host); + const visit = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isFile() && entry.name === '.agent-bundle-dev.json') { + const marker = await readJson(path); + if (marker.host === host && marker.projectRoot === process.cwd()) { + return join(cache, ...relative(cache, directory).split(/[\\/]/u).slice(0, 3)); + } + } + if (entry.isDirectory()) { + const found = await visit(path); + if (found !== undefined) return found; + } + } + return undefined; + }; + const install = await visit(cache); + if (install === undefined) throw new Error(`No ${host} development install found in ${cache}.`); + return install; +}; + +const installedPlugins = async (host) => { + const cache = cacheRoot(host); + const rows = []; + const visit = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + if (entry.isFile() && entry.name === 'plugin.json' && dirname(path).endsWith('.claude-plugin')) { + const manifest = await readJson(path); + const install = dirname(dirname(path)); + const [marketplace] = relative(cache, install).split('/'); + rows.push({ install, marketplace, name: manifest.name, version: manifest.version }); + } + } + }; + await visit(cache); + return rows; +}; + +const handleHostCommand = async (host, args) => { + const verb = args.slice(0, 4).join(' '); + if (verb === 'plugin list --json') { + const installed = await installedPlugins(host); + const result = host === 'claude' + ? installed.map((row) => ({ + enabled: true, + id: `${row.name}@${row.marketplace}`, + installPath: row.install, + scope: 'user', + version: row.version, + })) + : { installed: installed.map((row) => ({ + enabled: true, + installed: true, + pluginId: `${row.name}@${row.marketplace}`, + version: row.version, + })) }; + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (verb === 'plugin marketplace list --json') { + const marketplaces = await readdir(cacheRoot(host), { withFileTypes: true }).catch(() => []); + const rows = marketplaces.filter((entry) => entry.isDirectory()).map((entry) => ({ name: entry.name })); + process.stdout.write(`${JSON.stringify(host === 'claude' ? rows : { marketplaces: rows })}\n`); + return; + } + if (args.slice(0, 3).join(' ') === 'plugin marketplace add') { + const source = args[3]; + const plugin = await readJson(join(source, '.claude-plugin', 'plugin.json')); + const marketplace = await readJson(join(source, '.claude-plugin', 'marketplace.json')); + const destination = join(cacheRoot(host), marketplace.name, plugin.name, plugin.version); + await mkdir(dirname(destination), { recursive: true }); + await rm(destination, { force: true, recursive: true }); + await cp(source, destination, { recursive: true, verbatimSymlinks: true }); + return; + } +}; + +const run = (command, options, input) => new Promise((resolve, reject) => { + const child = spawn(command, options); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 + ? resolve() + : reject(new Error(`${command} exited with ${code ?? signal ?? 'unknown status'}.`))); + child.stdin.end(input); +}); + +const hookPayload = (host, sessionId) => ({ + cwd: process.cwd(), + hook_event_name: 'SessionStart', + ...(host === 'codex' ? { model: 'fake-codex', permission_mode: 'default' } : {}), + session_id: sessionId, + source: 'startup', + transcript_path: join(process.cwd(), '.fake-transcript.jsonl'), +}); + +const runSessionStart = async (host, install, sessionId) => { + const documentPath = host === 'codex' + ? join(install, '.codex-plugin', 'hooks.json') + : join(install, 'hooks', 'hooks.json'); + const document = await readJson(documentPath); + const hook = document.hooks?.SessionStart + ?.flatMap((group) => group.hooks ?? []) + .find((candidate) => candidate.type === 'command'); + if (typeof hook?.command !== 'string') throw new Error(`${documentPath} has no SessionStart command.`); + const environment = { + ...process.env, + AGENT_BUNDLE_PLUGIN_ROOT: install, + CLAUDE_PLUGIN_ROOT: install, + PLUGIN_ROOT: install, + }; + await run(hook.command, { + cwd: process.cwd(), + env: environment, + shell: true, + stdio: ['pipe', 'ignore', 'inherit'], + }, JSON.stringify(hookPayload(host, sessionId))); +}; + +const commandEnvironment = (host) => { + if (host === 'claude') return process.env; + return Object.fromEntries( + ['PATH', 'HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'TERM', 'LANG'] + .flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]), + ); +}; + +const mcpDocumentPath = (host, install) => host === 'codex' + ? join(install, '.codex-plugin', 'mcp.json') + : join(install, '.mcp.json'); + +const callFirstTool = async (host, install, sessionId, setChild) => { + const document = await readJson(mcpDocumentPath(host, install)); + const server = Object.values(document.mcpServers ?? {})[0]; + if (typeof server?.command !== 'string' || !Array.isArray(server.args)) { + throw new Error('The development MCP document has no stdio server.'); + } + const child = spawn(server.command, server.args, { + cwd: install, + env: commandEnvironment(host), + stdio: ['pipe', 'pipe', 'inherit'], + }); + setChild(child); + const pending = new Map(); + let buffered = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffered += chunk; + for (;;) { + const boundary = buffered.indexOf('\n'); + if (boundary === -1) break; + const line = buffered.slice(0, boundary).trim(); + buffered = buffered.slice(boundary + 1); + if (line === '') continue; + const message = JSON.parse(line); + if (message.id !== undefined) pending.get(message.id)?.resolve(message); + } + }); + child.once('exit', (code, signal) => { + const error = new Error(`MCP proxy exited with ${code ?? signal ?? 'unknown status'}.`); + for (const request of pending.values()) request.reject(error); + pending.clear(); + }); + const request = (id, method, params = {}) => new Promise((resolve, reject) => { + pending.set(id, { + reject, + resolve: (message) => { + pending.delete(id); + if (message.error !== undefined) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + }, + }); + child.stdin.write(`${JSON.stringify({ id, jsonrpc: '2.0', method, params })}\n`); + }); + await request(1, 'initialize', { + capabilities: {}, + clientInfo: { name: `fake-${host}`, version: '1.0.0' }, + protocolVersion: '2025-06-18', + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`); + const listed = await request(2, 'tools/list'); + const tool = listed.tools?.[0]; + if (typeof tool?.name !== 'string') throw new Error('The development MCP server listed no tools.'); + const params = { + arguments: {}, + name: tool.name, + ...(host === 'codex' + ? { _meta: { 'x-codex-turn-metadata': { session_id: sessionId, thread_id: sessionId } } } + : {}), + }; + let result; + try { + result = await request(3, 'tools/call', params); + } catch (error) { + result = { error: error instanceof Error ? error.message : String(error) }; + } + process.stdout.write(`result ${JSON.stringify(result).replaceAll(/\s+/gu, ' ').slice(0, 300)}\n> `); +}; + +export const runFakeHost = (host) => { + const args = process.argv.slice(2); + if (args[0] === 'plugin') { + void handleHostCommand(host, args).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; + }); + return; + } + const prompt = args.join(' '); + const sessionId = `${host}-session-${randomUUID()}`; + let active; + const keepAlive = setInterval(() => undefined, 2_147_483_647); + const stop = () => { + clearInterval(keepAlive); + active?.kill('SIGTERM'); + process.exit(0); + }; + process.once('SIGTERM', stop); + process.once('SIGINT', stop); + process.stdout.write(`Fake ${host === 'claude' ? 'Claude Code' : 'Codex'} host\n> `); + const start = async (input) => { + if (input !== '') process.stdout.write(`${input}\n`); + const install = await findInstall(host); + await runSessionStart(host, install, sessionId); + await callFirstTool(host, install, sessionId, (child) => { active = child; }); + }; + const lines = createInterface({ input: process.stdin }); + lines.on('line', (line) => { + if (line.trim() === 'exit') stop(); + }); + void start(prompt).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); + }); +}; diff --git a/packages/workbench/src/sessions/sessions-page.tsx b/packages/workbench/src/sessions/sessions-page.tsx index cee37e8a1..61d6fafb8 100644 --- a/packages/workbench/src/sessions/sessions-page.tsx +++ b/packages/workbench/src/sessions/sessions-page.tsx @@ -55,7 +55,9 @@ export const SessionsPage = ({ client, onNavigate, session: selectedId }: Sessio }, [onNavigate]); const launch = (host: HostSessionHost): void => { client.launch({ host, ...size.current }).then(select, fail); }; const terminate = (id: string): void => { client.terminate(id).then((session) => dispatch({ session, type: 'session' }), fail); }; - const restart = (id: string): void => { client.restart(id, size.current).then(select, fail); }; + const restart = (session: HostSession): void => { + client.restart(session.id, { cols: session.cols, rows: session.rows }).then(select, fail); + }; const forget = (id: string): void => { client.forget(id).then(() => { dispatch({ id, type: 'forget' }); @@ -127,7 +129,7 @@ export const SessionsPage = ({ client, onNavigate, session: selectedId }: Sessio Trace - +
diff --git a/packages/workbench/src/sessions/terminal.tsx b/packages/workbench/src/sessions/terminal.tsx index 1e11c7b98..5d89e7a57 100644 --- a/packages/workbench/src/sessions/terminal.tsx +++ b/packages/workbench/src/sessions/terminal.tsx @@ -52,6 +52,7 @@ export const SessionTerminal = ({ client, live, onError, onSession, onSize, sess if (callbacks.current.live) client.input(sessionId, data).catch(fail); }); const resized = term.onResize((size) => { + if (size.cols < 1 || size.rows < 1) return; callbacks.current.onSize(size); if (callbacks.current.live) client.resize(sessionId, size).catch(fail); }); diff --git a/packages/workbench/src/trace/trace-model.ts b/packages/workbench/src/trace/trace-model.ts index 98fbfd3b4..fb97569ab 100644 --- a/packages/workbench/src/trace/trace-model.ts +++ b/packages/workbench/src/trace/trace-model.ts @@ -283,7 +283,16 @@ export const traceGroupKeyValue = (group: TraceGroup): string => { /** A group joined on a Workbench host-session id (`hs_…`) links back to that session in the Sessions pane. */ export const traceGroupSessionLocation = (group: TraceGroup): WorkbenchLocation | undefined => { const value = traceGroupKeyValue(group); - return group.keyKind === 'sessionId' && value.startsWith('hs_') ? Object.freeze({ area: 'sessions', session: value }) : undefined; + if (group.keyKind === 'sessionId' && value.startsWith('hs_')) { + return Object.freeze({ area: 'sessions', session: value }); + } + for (const { entry } of group.rows) { + const session = entry.href?.match(/^\/sessions\?session=(hs_[0-9a-z]{16})$/u)?.[1]; + if (entry.source === 'session' && session !== undefined) { + return Object.freeze({ area: 'sessions', session }); + } + } + return undefined; }; const kindLabels: ReadonlyMap = new Map([ diff --git a/packages/workbench/tests/sessions.e2e.test.ts b/packages/workbench/tests/sessions.e2e.test.ts new file mode 100644 index 000000000..54a6fe029 --- /dev/null +++ b/packages/workbench/tests/sessions.e2e.test.ts @@ -0,0 +1,174 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; + +import { expect } from '@rstest/playwright'; +import type { Page } from 'playwright-core'; + +import type { HostSession } from '../../agent-bundle/src/contracts/host-sessions.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { copyExample } from './support/example-acceptance.ts'; +import { + expectHeading, + openWorkbench, + workbenchTestId, +} from './support/workbench-acceptance.ts'; +import { + buildWorkbench, + e2e, + startWorkbenchDevServer, + withWorkbenchServer, + workspaceRoot, +} from './support/workbench-e2e.ts'; + +const browserTimeout = 15_000 * timeScale; +const hostTimeout = 60_000 * timeScale; +const fakeHosts = join(workspaceRoot, 'packages', 'agent-bundle', 'tests', 'support', 'fake-host-cli'); + +const sessions = (page: Page): Promise => page.evaluate(async () => { + const session = await fetch('/api/project/session').then((response) => response.json()) as { readonly token: string }; + const response = await fetch('/api/sessions', { + headers: { 'x-agent-bundle-session': session.token }, + }); + return (await response.json() as { readonly sessions: readonly HostSession[] }).sessions; +}); + +const selectedSessionId = (page: Page): string => { + const id = new URL(page.url()).searchParams.get('session'); + if (id === null) throw new Error('Sessions pane did not select a session.'); + return id; +}; + +const waitForTraceSessionId = async (page: Page, id: string): Promise => { + let traceSessionId: string | undefined; + await expect.poll(async () => { + traceSessionId = (await sessions(page)).find((session) => session.id === id)?.traceSessionId; + return traceSessionId; + }, { timeout: hostTimeout }).toMatch(/^(?:claude|codex)-session-/u); + return traceSessionId!; +}; + +const expectTerminalText = ( + page: Page, + text: string | RegExp, +): Promise => expect(workbenchTestId(page, 'sessionsTerminal').locator('.xterm-rows')) + .toContainText(text, { timeout: hostTimeout }); + +e2e('accepts Claude and Codex host sessions at 1440×900', { timeout: 300_000 * timeScale }, async ({ page }) => { + await buildWorkbench(); + const homes = await mkdtemp(join(tmpdir(), 'agent-bundle-host-sessions-')); + const emptyPath = join(homes, 'empty-path'); + await mkdir(emptyPath); + const previous = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CODEX_HOME: process.env.CODEX_HOME, + HOME: process.env.HOME, + PATH: process.env.PATH, + }; + process.env.CLAUDE_CONFIG_DIR = join(homes, 'claude'); + process.env.CODEX_HOME = join(homes, 'codex'); + process.env.HOME = join(homes, 'home'); + process.env.PATH = `${fakeHosts}${delimiter}${previous.PATH ?? ''}`; + + await withWorkbenchServer({ + createProject: () => copyExample('audiobook-curator'), + dispose: (project) => project.release(), + setup: async (project) => { + const events = join(project.root, 'src', 'events', 'session'); + await mkdir(events, { recursive: true }); + await writeFile( + join(events, 'start.ts'), + "import { Agent } from '@agent-bundle/runtime';\n" + + "import { createElement } from 'react';\n" + + "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };\n" + + "export default async function SessionStart() { return createElement(Agent.Result, null, createElement(Agent.Text, null, 'ready')); }\n", + ); + }, + start: (project) => startWorkbenchDevServer(project, { installHosts: ['claude', 'codex'] }), + teardown: [ + () => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }, + () => rm(homes, { force: true, recursive: true }), + ], + }, async (server, project) => { + await openWorkbench(page, server.url, '/sessions'); + await expectHeading(page, 'Host sessions'); + await expect(page.getByText(/Loading host sessions/u)).toHaveCount(0, { timeout: browserTimeout }); + await expect(workbenchTestId(page, 'sessionsLaunchClaude')).toBeEnabled(); + await expect(workbenchTestId(page, 'sessionsLaunchCodex')).toBeEnabled(); + + const unauthorized = await page.evaluate(async () => { + const response = await fetch('/api/sessions', { + body: JSON.stringify({ cols: 80, host: 'claude', rows: 24 }), + credentials: 'omit', + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + return response.status; + }); + expect(unauthorized).toBeGreaterThanOrEqual(400); + + const launch = async (host: 'claude' | 'codex'): Promise => { + await workbenchTestId(page, host === 'claude' ? 'sessionsLaunchClaude' : 'sessionsLaunchCodex').click(); + await expect(page).toHaveURL(/\/sessions\?session=hs_[0-9a-z]{16}$/u, { timeout: hostTimeout }); + const id = selectedSessionId(page); + await expectTerminalText(page, host === 'claude' ? 'Fake Claude Code host' : 'Fake Codex host'); + const authority = workbenchTestId(page, 'sessionsAuthority'); + await expect(authority).toContainText(project.root); + await expect(authority).toContainText('Epoch'); + await expect(authority).toContainText('Install'); + await expect(authority).toContainText(/Running · pid \d+/u); + const traceSessionId = await waitForTraceSessionId(page, id); + await workbenchTestId(page, 'sessionsTrace').click(); + await expectHeading(page, 'Trace'); + expect(new URL(page.url()).searchParams.get('correlation')).toBe(traceSessionId); + const group = workbenchTestId(page, 'traceGroup'); + await expect(group).toHaveCount(1, { timeout: hostTimeout }); + await expect(group.locator('[data-source="session"]')).not.toHaveCount(0); + await expect(group.locator('[data-kind="hook.received"]')).toBeVisible({ timeout: hostTimeout }); + await expect(group).toContainText('tools/call', { timeout: hostTimeout }); + await expect(workbenchTestId(page, 'traceGroupSession')).toBeVisible(); + await workbenchTestId(page, 'traceGroupSession').click(); + await expectHeading(page, 'Host sessions'); + expect(selectedSessionId(page)).toBe(id); + return id; + }; + + const claude = await launch('claude'); + await workbenchTestId(page, 'sessionsTerminate').click(); + await expect(workbenchTestId(page, 'sessionsState')).toContainText('Terminated', { timeout: hostTimeout }); + await workbenchTestId(page, 'sessionsRestart').click(); + await expect.poll(() => selectedSessionId(page), { timeout: hostTimeout }).not.toBe(claude); + const restarted = selectedSessionId(page); + await expect(workbenchTestId(page, 'sessionsAuthority')).toContainText(`Restart of${claude}`); + await expectTerminalText(page, 'Fake Claude Code host'); + await workbenchTestId(page, 'sessionsTerminate').click(); + await expect(workbenchTestId(page, 'sessionsState')).toContainText('Terminated', { timeout: hostTimeout }); + expect(restarted).toMatch(/^hs_[0-9a-z]{16}$/u); + + await openWorkbench(page, server.url, '/sessions'); + const codex = await launch('codex'); + await workbenchTestId(page, 'sessionsTerminate').click(); + await expect(workbenchTestId(page, 'sessionsState')).toContainText('Terminated', { timeout: hostTimeout }); + expect(codex).toMatch(/^hs_[0-9a-z]{16}$/u); + + await openWorkbench(page, server.url, '/routes/mcp/curator/tool/search_audible'); + await expect(workbenchTestId(page, 'routeOpenInClaude')).toBeEnabled({ timeout: browserTimeout }); + await workbenchTestId(page, 'routeOpenInClaude').click(); + await expectHeading(page, 'Host sessions'); + await expectTerminalText(page, 'Call the tool:curator/search_audible tool of this plugin and explain the result.'); + await workbenchTestId(page, 'sessionsTerminate').click(); + await expect(workbenchTestId(page, 'sessionsState')).toContainText('Terminated', { timeout: hostTimeout }); + + process.env.PATH = emptyPath; + await openWorkbench(page, server.url, '/sessions'); + await expect(workbenchTestId(page, 'sessionsLaunchClaude')).toBeDisabled({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'sessionsLaunchCodex')).toBeDisabled({ timeout: browserTimeout }); + await expect(page.getByText('claude is not on PATH', { exact: true })).toBeVisible(); + await expect(page.getByText('codex is not on PATH', { exact: true })).toBeVisible(); + }); +}); diff --git a/packages/workbench/tests/support/workbench-e2e.ts b/packages/workbench/tests/support/workbench-e2e.ts index 00397538f..27a5013f0 100644 --- a/packages/workbench/tests/support/workbench-e2e.ts +++ b/packages/workbench/tests/support/workbench-e2e.ts @@ -7,7 +7,7 @@ import type { Page } from 'playwright-core'; import { createWorkbenchAssetSource } from '../../../agent-bundle/src/dev/workbench-assets.ts'; import { startDevServer, type DevServerSession, type StartDevServerOptions } from '../../../agent-bundle/src/dev/workbench-server.ts'; -import { createProjectFixture, removeProjectFixture, type ProjectFixture } from '../../../agent-bundle/tests/helpers/project-fixture.ts'; +import type { ProjectFixture } from '../../../agent-bundle/tests/helpers/project-fixture.ts'; import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; import { browserLaunchOptions } from './browser-launch-options.ts'; diff --git a/packages/workbench/tests/trace-model.test.ts b/packages/workbench/tests/trace-model.test.ts index 238e277ae..f55abb477 100644 --- a/packages/workbench/tests/trace-model.test.ts +++ b/packages/workbench/tests/trace-model.test.ts @@ -140,4 +140,8 @@ it('headlines a host-session group by its lifecycle entry and links an hs_ sessi expect(traceGroupSessionLocation(groups[0]!)).toEqual({ area: 'sessions', session: 'hs_0123456789abcdef' }); expect(traceGroupSessionLocation(groups[1]!)).toBeUndefined(); expect(traceGroupSessionLocation(groupTraceEntries([traceEntry(1, { correlation: { conversationId: 'hs_x', sessionId: 'hs_y' }, kind: 'hook.received', occurredAt: at, source: 'hook', summary: 'x' })])[0]!)).toBeUndefined(); + const attached = groupTraceEntries([ + traceEntry(5, { correlation: { conversationId: 'claude-own-id', sessionId: 'claude-own-id' }, href: '/sessions?session=hs_0123456789abcdef', kind: 'session.attached', occurredAt: at, source: 'session', summary: 'Claude session attached' }), + ])[0]!; + expect(traceGroupSessionLocation(attached)).toEqual({ area: 'sessions', session: 'hs_0123456789abcdef' }); }); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index bfea76bb7..92cc0a00b 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -118,6 +118,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/workbench/tests/rsbuild-closure.test.ts', 'packages/workbench/tests/rsbuild-workbench.test.ts', 'packages/workbench/tests/route-editor-atoms-disposal.test.ts', + 'packages/workbench/tests/sessions.e2e.test.ts', 'packages/workbench/tests/web-command.e2e.test.ts', 'packages/workbench/tests/workbench-dev-command.test.ts', ]; @@ -132,6 +133,7 @@ export const integrationTestFiles: readonly string[] = [ */ export const nativeHostTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-adapters.native.test.ts', + 'packages/agent-bundle/tests/native-host-sessions.test.ts', ]; /** diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index e6fe361c4..0a19a4182 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -28,17 +28,19 @@ These are contracts, not defaults: ## Workbench navigation -The primary navigation has four destinations in this release: +The primary navigation has five destinations: - **Application** — the compiled application tree and the workspace for its selected leaf. - **Trace** — the correlated timeline of application activity observed in this foreground development session. +- **Host sessions** — explicit Claude Code and Codex terminals attached to the current dev + plugin. - **Problems** — current compiler, runtime, and contract diagnostics. - **Advanced** — Evals, Artifact, Protocol, Host diagnostics, and Raw logs. The header stays visible across them. It shows the project name, build state and epoch, contract-gate and diagnostic failure count (linked to Problems), and foreground connection -state. Host **Sessions** arrive in a later release and are not part of this Workbench. +state. ## Application tree @@ -127,6 +129,46 @@ A Skill leaf renders the emitted Skill document by default. Source/generated dif frontmatter, resources, and eval coverage live in the inspector. Raw HTML, JSX/MDX, and Mermaid inside Skill Markdown remain inert. +## Host sessions + +Start `agent-bundle dev` with the hosts whose development installs should follow the adopted +epoch: + +```sh +pnpm add -D @lydell/node-pty +npx agent-bundle dev --install-host claude --install-host codex +``` + +**Host sessions** can then launch `claude []` or `codex []` in a PTY. The command +and arguments are fixed by the server, the working directory is always the project root, and no +session starts until you select **Launch Claude**, **Launch Codex**, **Open in Claude**, or +**Open in Codex**. The route actions seed a prompt for tool, event, and CLI leaves and open the +new terminal at `/sessions?session=`. + +The authority strip under the terminal shows the project root, adopted epoch, host install +destination, process id while running, and current state. A restarted session also names the +session it replaced. At most four sessions can be live at once, and each session retains at most +256 KiB of PTY output for reconnecting terminals. Install `@lydell/node-pty` in the project; when +it cannot be resolved or loaded, launch is disabled with `AB8260`. + +The foreground server remains loopback-only and applies the same session cookie or bearer guard +to every `/api/sessions` route. Requests cannot choose an executable, arguments, working +directory, or environment. The child never receives the foreground session token, and Agent +Bundle does not pass a permission or approval bypass to either host. The terminal inherits the +dev server environment plus the hook receipt variables, `AGENT_BUNDLE_DEV_SESSION=`, +`TERM=xterm-256color`, and `COLORTERM=truecolor`. + +`AGENT_BUNDLE_DEV_SESSION` is the Workbench resource id, not a credential. A generated +SessionStart hook receipt carries it as `devSession` alongside the host's own `session_id`. Once +that receipt arrives, Trace aliases the resource to the host session id and groups later +lifecycle, hook, and MCP entries by that id. Claude inherits the variable into its stdio MCP +server, so the proxy supplies the alias header. Codex filters the environment of stdio MCP +servers, so the proxy cannot inherit that variable; it always sends its process id instead, and +the foreground server walks the proxy's parent process chain to the PTY child that owns the +Workbench session. Codex frames can also carry the host session id in +`x-codex-turn-metadata.session_id`. Its hook still inherits `AGENT_BUNDLE_DEV_SESSION` and sends +the `devSession` receipt field. The **Trace** link always uses the learned host id when available. + ## Trace Trace is the live, ordered timeline of what the foreground server observes the application doing. @@ -162,6 +204,7 @@ keys, optional status and duration, and, when a full record is available, an `hr | `kernel` | `kernel.preflight.start`, `kernel.preflight.outcome`, `kernel.execute.start`, provider and render start/finish, `kernel.failure` | `executionId`, route, host, session and conversation identity when available | The corresponding route invocation | | `mcp` | request, response, notification, progress, logging, session, and stderr kinds | `mcpSessionId`, JSON-RPC `mcpRequestId`, `requestId`, route and host metadata when known | The route workspace with `?session=`, or the bound Protocol session | | `hook` | receipt/completion/failure and host session start/end | `requestId`, `executionId`, `sessionId`, `conversationId`, `routeId`, `host` | The event route and captured receipt | +| `session` | `session.started`, `session.attached`, `session.exited`, `session.terminated` | Host session, host, and epoch identity | `/sessions?session=` | | `log` | `log..` for records with a shared key | Any correlation key retained by the safe log projection | Advanced → Raw logs | | `diagnostic` | build, contract, and host-sync failures | Build, epoch, route, and shared request identity when known | Problems or the affected route | @@ -261,6 +304,8 @@ Workbench uses paths and browser history, not `#page` hashes: /routes/skills/ /routes/commands/ /routes/rules/ +/sessions +/sessions?session= /trace/ /trace?correlation= /problems diff --git a/website/docs/en/reference/dev-server-http.mdx b/website/docs/en/reference/dev-server-http.mdx index 48cf7157f..10de175ae 100644 --- a/website/docs/en/reference/dev-server-http.mdx +++ b/website/docs/en/reference/dev-server-http.mdx @@ -28,6 +28,58 @@ projections, diagnostics, and timings. Trace entries link to that full snapshot duplicating it. A cancelled invocation has `status: "cancelled"` and no `outcome`; cancelling an already-final invocation returns `AB8256` (409). +## Host sessions + +Every route in this table requires the foreground session cookie or +`x-agent-bundle-session` bearer value. Session ids match `hs_[0-9a-z]{16}`, JSON request bodies +are strict, input is limited to 16 KiB of UTF-8 text, and terminal dimensions are integers from +1 through 500. + +| Method | Path | Body and response | +| --- | --- | --- | +| `GET` | `/api/sessions` | `{ sessions: HostSession[], hosts: HostAvailability[] }` | +| `POST` | `/api/sessions` | `{ host: "claude" \| "codex", prompt?: string, cols, rows }`; `201` with `{ session }` | +| `GET` | `/api/sessions/` | `{ session }` | +| `GET` | `/api/sessions//stream` | Server-sent `state`, `output`, and terminal `end` events | +| `POST` | `/api/sessions//input` | `{ data: string }`; `204` | +| `POST` | `/api/sessions//resize` | `{ cols, rows }`; `204` | +| `POST` | `/api/sessions//terminate` | `{}`; `200` with `{ session }` | +| `POST` | `/api/sessions//restart` | `{ cols, rows }`; `201` with the replacement `{ session }` | +| `DELETE` | `/api/sessions/` | `204`; a running session cannot be forgotten | + +`HostSession` reports `id`, `host`, `state`, `startedAt`, optional terminal `endedAt`, +`exitCode`, `signal`, `pid`, `cols`, `rows`, the optional seeded `prompt`, and optional +`restartOf` and `traceSessionId`. Its `authority` contains the absolute `projectRoot`, adopted +`epochId`, and host plugin `install` destination. `HostAvailability` contains `host`, +`launchable`, and optional `reason` and resolved `executable`. + +The stream starts with a state frame, replays up to 256 KiB of retained output, then continues +with live state and output. It sends a keep-alive comment every 15 seconds and closes after +`end`. Each event has one JSON `data:` object: + +```text +event: state +data: { "session": HostSession } + +event: output +data: { "data": "" } + +event: end +data: { "session": HostSession } +``` + +Host-session failures use these diagnostics: + +| Code | Meaning | +| --- | --- | +| `AB8260` | `@lydell/node-pty` is unavailable from the project and framework fallback. | +| `AB8261` | The body, path, query, dimensions, input, method state, or live-session delete is malformed. | +| `AB8262` | The session id is well formed but unknown. | +| `AB8263` | The executable is absent from `PATH`, no development install is attached to the adopted epoch, or spawning failed. | +| `AB8264` | Four sessions are already live. | +| `AB8265` | The host-session service is closed or unavailable. | +| `AB8266` | A hook receipt or proxy request supplied an invalid `AGENT_BUNDLE_DEV_SESSION` value. | + ## Development runtime runs `POST /api/runtime/runs` accepts a `DevRuntimeInvocationRequest` with `surfaceId`, `target`, diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index a0f12436a..acb2bba5f 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -24,15 +24,16 @@ npx agent-bundle dev --root . --port 3100 --no-open ## Workbench 导航 -本版本的主导航有四个目的地: +主导航有五个目的地: - **Application** — 已编译的应用树,以及所选叶子的工作区。 - **Trace** — 本次前台开发会话中观察到的应用活动关联时间线。 +- **Host sessions** — 显式启动并附加到当前开发插件的 Claude Code 与 Codex 终端。 - **Problems** — 当前的编译器、运行时与契约诊断。 - **Advanced** — Evals、Artifact、Protocol、Host diagnostics 与 Raw logs。 页眉在这些目的地之间始终可见。它显示项目名、构建状态与 epoch、契约门控与诊断失败计数(链接到 -Problems),以及前台连接状态。宿主 **Sessions** 会在后续版本中到来,不属于本版 Workbench。 +Problems),以及前台连接状态。 ## Application 树 @@ -105,6 +106,37 @@ App 叶子把沙箱化的 MCP App 预览放在工作区中央,并绑定到它 Skill 叶子默认渲染输出的 Skill 文档。源码/生成差异、frontmatter、资源与 eval 覆盖位于检查器中。Skill Markdown 中的原始 HTML、JSX/MDX 与 Mermaid 保持惰性。 +## 宿主会话 + +启动 `agent-bundle dev` 时,指定应跟随已采用 epoch 的开发期宿主安装: + +```sh +pnpm add -D @lydell/node-pty +npx agent-bundle dev --install-host claude --install-host codex +``` + +之后,**Host sessions** 可以在 PTY 中启动 `claude []` 或 `codex []`。命令与参数由 +服务器固定,工作目录始终是项目根目录;只有选择 **Launch Claude**、**Launch Codex**、 +**Open in Claude** 或 **Open in Codex** 后才会创建会话。路由操作会为 tool、event 与 CLI 叶子填入 +提示词,并在 `/sessions?session=` 打开新终端。 + +终端下方的 authority 条显示项目根目录、已采用的 epoch、宿主安装目的地、运行时进程 id 与当前状态。 +重启后的会话还会指出它替代的会话。最多可同时运行四个会话;每个会话最多保留 256 KiB PTY 输出供终端 +重连。项目中必须安装 `@lydell/node-pty`;无法解析或加载时,启动会以 `AB8260` 禁用。 + +前台服务器仍仅限 loopback,并对每条 `/api/sessions` 路由应用相同的会话 cookie 或 bearer 守卫。 +请求不能选择可执行文件、参数、工作目录或环境。子进程不会收到前台会话 token,Agent Bundle 也不会向 +任何宿主传入权限或审批绕过参数。终端继承开发服务器环境,并增加钩子收据变量、 +`AGENT_BUNDLE_DEV_SESSION=`、`TERM=xterm-256color` 与 `COLORTERM=truecolor`。 + +`AGENT_BUNDLE_DEV_SESSION` 是 Workbench 资源 id,不是凭据。生成的 SessionStart 钩子收据会把它作为 +`devSession`,并同时携带宿主自己的 `session_id`。收据到达后,Trace 会把资源别名关联到宿主会话 id, +后续生命周期、钩子与 MCP 条目都按该 id 分组。Claude 会把该变量传给 stdio MCP 服务器,因此代理通过 +别名标头传递它。Codex 会过滤 stdio MCP 服务器的环境,因此代理无法继承该变量;代理始终改为发送自己的 +进程 id,前台服务器沿代理的父进程链查找到拥有 Workbench 会话的 PTY 子进程。Codex 帧还可以通过 +`x-codex-turn-metadata.session_id` 携带宿主会话 id。它的钩子仍继承 +`AGENT_BUNDLE_DEV_SESSION`,并发送收据的 `devSession` 字段。**Trace** 链接在宿主 id 可用后始终使用它。 + ## Trace Trace 是前台服务器所观察到的应用活动的实时有序时间线。例如,运行 @@ -137,6 +169,7 @@ Trace 是前台服务器所观察到的应用活动的实时有序时间线。 | `kernel` | `kernel.preflight.start`、`kernel.preflight.outcome`、`kernel.execute.start`、provider 与 render 的开始/结束,以及 `kernel.failure` | `executionId`、路由、宿主,以及可用时的会话与对话标识 | 对应的路由调用 | | `mcp` | request、response、notification、progress、logging、session 与 stderr kind | `mcpSessionId`、JSON-RPC `mcpRequestId`、`requestId`,以及已知的路由与宿主元数据 | 带 `?session=` 的路由工作区,或绑定的 Protocol 会话 | | `hook` | 收据/完成/失败与宿主会话开始/结束 | `requestId`、`executionId`、`sessionId`、`conversationId`、`routeId`、`host` | 事件路由与捕获的收据 | +| `session` | `session.started`、`session.attached`、`session.exited`、`session.terminated` | 宿主会话、宿主与 epoch 标识 | `/sessions?session=` | | `log` | 具有共享键的记录对应的 `log..` | 安全日志投影保留的任意关联键 | Advanced → Raw logs | | `diagnostic` | 构建、契约与宿主同步失败 | 已知的构建、epoch、路由与共享请求标识 | Problems 或受影响的路由 | @@ -220,6 +253,8 @@ Workbench 使用路径与浏览器历史,而不是 `#page` 哈希: /routes/skills/ /routes/commands/ /routes/rules/ +/sessions +/sessions?session= /trace/ /trace?correlation= /problems diff --git a/website/docs/zh/reference/dev-server-http.mdx b/website/docs/zh/reference/dev-server-http.mdx index feb6f2c10..40bde1a52 100644 --- a/website/docs/zh/reference/dev-server-http.mdx +++ b/website/docs/zh/reference/dev-server-http.mdx @@ -24,6 +24,56 @@ Trace 可以关联此次运行。完成后的信封包含输入、provenance 上 Document、结果、投影、诊断与计时。Trace 条目链接到这份完整快照,而不会复制它。取消后的调用带有 `status: "cancelled"` 且不含 `outcome`;取消已终止的调用会返回 `AB8256`(409)。 +## 宿主会话 + +下表每条路由都要求前台会话 cookie 或 `x-agent-bundle-session` bearer 值。会话 id 匹配 +`hs_[0-9a-z]{16}`,JSON 请求正文采用严格校验,输入最多为 16 KiB UTF-8 文本,终端尺寸必须是 +1 到 500 的整数。 + +| 方法 | 路径 | 正文与响应 | +| --- | --- | --- | +| `GET` | `/api/sessions` | `{ sessions: HostSession[], hosts: HostAvailability[] }` | +| `POST` | `/api/sessions` | `{ host: "claude" \| "codex", prompt?: string, cols, rows }`;`201` 与 `{ session }` | +| `GET` | `/api/sessions/` | `{ session }` | +| `GET` | `/api/sessions//stream` | 服务器发送的 `state`、`output` 与终止 `end` 事件 | +| `POST` | `/api/sessions//input` | `{ data: string }`;`204` | +| `POST` | `/api/sessions//resize` | `{ cols, rows }`;`204` | +| `POST` | `/api/sessions//terminate` | `{}`;`200` 与 `{ session }` | +| `POST` | `/api/sessions//restart` | `{ cols, rows }`;`201` 与替代会话 `{ session }` | +| `DELETE` | `/api/sessions/` | `204`;不能遗忘仍在运行的会话 | + +`HostSession` 报告 `id`、`host`、`state`、`startedAt`、可选的终止字段 `endedAt`、`exitCode`、 +`signal`、`pid`、`cols`、`rows`、可选的初始 `prompt`,以及可选的 `restartOf` 与 +`traceSessionId`。其 `authority` 包含绝对 `projectRoot`、已采用的 `epochId` 与宿主插件 +`install` 目的地。`HostAvailability` 包含 `host`、`launchable`,以及可选的 `reason` 与已解析 +`executable`。 + +流以一条 state 帧开始,重放最多 256 KiB 的保留输出,再继续发送实时 state 与 output。它每 15 秒发送 +一条 keep-alive 注释,并在 `end` 后关闭。每个事件带有一个 JSON `data:` 对象: + +```text +event: state +data: { "session": HostSession } + +event: output +data: { "data": "" } + +event: end +data: { "session": HostSession } +``` + +宿主会话失败使用以下诊断: + +| 代码 | 含义 | +| --- | --- | +| `AB8260` | 项目与框架后备位置均无法提供 `@lydell/node-pty`。 | +| `AB8261` | 正文、路径、查询、尺寸、输入、方法状态或删除活动会话的请求格式错误。 | +| `AB8262` | 会话 id 格式正确但未知。 | +| `AB8263` | `PATH` 中没有可执行文件、已采用 epoch 没有附加开发安装,或生成进程失败。 | +| `AB8264` | 已有四个活动会话。 | +| `AB8265` | 宿主会话服务已关闭或不可用。 | +| `AB8266` | 钩子收据或代理请求提供了无效的 `AGENT_BUNDLE_DEV_SESSION` 值。 | + ## 开发运行时运行 `POST /api/runtime/runs` 接受一份 `DevRuntimeInvocationRequest`,其中包含 `surfaceId`、`target`、 From ceea74721b002db82b53e2dc892470c4f2be643a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 07:47:07 +0000 Subject: [PATCH 13/16] deslop: drop the unneeded public contracts entry, PTY disposer plumbing, and the stream replay buffer --- packages/agent-bundle/package.json | 4 --- packages/agent-bundle/rslib.config.ts | 1 - .../src/dev/sessions/host-session-routes.ts | 10 +++--- packages/agent-bundle/src/dev/sessions/pty.ts | 36 +++---------------- .../tests/host-session-routes.test.ts | 6 ++-- .../tests/host-session-service.test.ts | 6 ++-- 6 files changed, 12 insertions(+), 51 deletions(-) diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 2fb96b246..630c95912 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -61,10 +61,6 @@ "types": "./dist/config/index.d.ts", "import": "./dist/config.js" }, - "./contracts/host-sessions": { - "types": "./dist/contracts/host-sessions.d.ts", - "import": "./dist/contracts/host-sessions.js" - }, "./eval": { "types": "./dist/eval/index.d.ts", "import": "./dist/eval.js" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 4d183b6af..2d0d2ba8e 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -62,7 +62,6 @@ const publicEntries = { api: './src/api.ts', cli: './src/cli.ts', config: './src/config/index.ts', - 'contracts/host-sessions': './src/contracts/host-sessions.ts', eval: './src/eval/index.ts', index: './src/index.ts', 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', diff --git a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts index b8dfa6bc9..701e1e87b 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -26,6 +26,7 @@ import { import { HOST_SESSION_MALFORMED_CODE, HOST_SESSION_UNAVAILABLE_CODE, + HOST_SESSION_UNKNOWN_CODE, HostSessionError, type HostSessionStreamMessage, } from './host-session-service.ts'; @@ -149,7 +150,7 @@ export class HostSessionRoutes { } if (path.kind === 'item' && method === 'GET') { const session = service.read(path.id); - if (session === undefined) throw new HostSessionError('AB8262', `Host session ${JSON.stringify(path.id)} was not found.`, 404); + if (session === undefined) throw new HostSessionError(HOST_SESSION_UNKNOWN_CODE, `Host session ${JSON.stringify(path.id)} was not found.`, 404); responseJson(response, { session }); return true; } @@ -226,15 +227,12 @@ export class HostSessionRoutes { if (stream.keepAlive !== undefined) clearInterval(stream.keepAlive); stream.unsubscribe?.(); }); - const replay: HostSessionStreamMessage[] = []; - let replaying = true; - stream.unsubscribe = service.subscribe(id, (message) => replaying ? replay.push(message) : deliver(message)); + if (service.read(id) === undefined) throw new HostSessionError(HOST_SESSION_UNKNOWN_CODE, `Host session ${JSON.stringify(id)} was not found.`, 404); writeKeepAliveStreamHead(response, { cacheControl: 'no-cache', contentType: 'text/event-stream; charset=utf-8', }); - replaying = false; - for (const message of replay) deliver(message); + stream.unsubscribe = service.subscribe(id, deliver); stream.keepAlive = terminal ? undefined : setInterval(() => writer.enqueue(': keep-alive\n\n'), 15_000); finish(); } diff --git a/packages/agent-bundle/src/dev/sessions/pty.ts b/packages/agent-bundle/src/dev/sessions/pty.ts index b731daebd..059bddd47 100644 --- a/packages/agent-bundle/src/dev/sessions/pty.ts +++ b/packages/agent-bundle/src/dev/sessions/pty.ts @@ -11,8 +11,8 @@ export interface PtySpawnOptions { export interface PtyProcess { readonly pid: number; - onData(listener: (data: string) => void): () => void; - onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void; + onData(listener: (data: string) => void): void; + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): void; write(data: string): void; resize(cols: number, rows: number): void; kill(signal?: NodeJS.Signals): void; @@ -22,38 +22,10 @@ export interface PtyAdapter { spawn(file: string, args: readonly string[], options: PtySpawnOptions): PtyProcess; } -interface NativeDisposable { - dispose(): void; -} - -interface NativePty { - readonly pid: number; - onData(listener: (data: string) => void): NativeDisposable; - onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): NativeDisposable; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(signal?: NodeJS.Signals): void; -} - interface NativePtyModule { - spawn(file: string, args: string[], options: PtySpawnOptions): NativePty; + spawn(file: string, args: string[], options: PtySpawnOptions): PtyProcess; } -const wrap = (process: NativePty): PtyProcess => ({ - pid: process.pid, - kill: (signal) => process.kill(signal), - onData: (listener) => { - const subscription = process.onData(listener); - return () => subscription.dispose(); - }, - onExit: (listener) => { - const subscription = process.onExit(listener); - return () => subscription.dispose(); - }, - resize: (cols, rows) => process.resize(cols, rows), - write: (data) => process.write(data), -}); - export const loadPtyAdapter = (projectRoot: string): PtyAdapter => { let native: NativePtyModule; try { @@ -62,5 +34,5 @@ export const loadPtyAdapter = (projectRoot: string): PtyAdapter => { native = createRequire(import.meta.url)('@lydell/node-pty') as NativePtyModule; } if (typeof native.spawn !== 'function') throw new TypeError('@lydell/node-pty does not export spawn.'); - return { spawn: (file, args, options) => wrap(native.spawn(file, [...args], options)) }; + return { spawn: (file, args, options) => native.spawn(file, [...args], options) }; }; diff --git a/packages/agent-bundle/tests/host-session-routes.test.ts b/packages/agent-bundle/tests/host-session-routes.test.ts index 8e5fa9ffd..60a0fcdc6 100644 --- a/packages/agent-bundle/tests/host-session-routes.test.ts +++ b/packages/agent-bundle/tests/host-session-routes.test.ts @@ -17,13 +17,11 @@ class FakePty implements PtyProcess { for (const listener of this.#exit) listener({ exitCode, ...(signal === undefined ? {} : { signal }) }); } kill(): void {} - onData(listener: (data: string) => void): () => void { + onData(listener: (data: string) => void): void { this.#data.add(listener); - return () => this.#data.delete(listener); } - onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void { + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): void { this.#exit.add(listener); - return () => this.#exit.delete(listener); } resize(): void {} write(): void {} diff --git a/packages/agent-bundle/tests/host-session-service.test.ts b/packages/agent-bundle/tests/host-session-service.test.ts index af6ea42a3..320b26631 100644 --- a/packages/agent-bundle/tests/host-session-service.test.ts +++ b/packages/agent-bundle/tests/host-session-service.test.ts @@ -34,14 +34,12 @@ class FakePty implements PtyProcess { this.kills.push(signal); } - onData(listener: (data: string) => void): () => void { + onData(listener: (data: string) => void): void { this.#data.add(listener); - return () => this.#data.delete(listener); } - onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): () => void { + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): void { this.#exit.add(listener); - return () => this.#exit.delete(listener); } resize(cols: number, rows: number): void { From cb4d1f204588f44b49d356a7880bbf0b71e55352 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 07:48:36 +0000 Subject: [PATCH 14/16] changeset: name #675 --- .changeset/wb600-host-sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wb600-host-sessions.md b/.changeset/wb600-host-sessions.md index 4c2f33e91..c51f1f162 100644 --- a/.changeset/wb600-host-sessions.md +++ b/.changeset/wb600-host-sessions.md @@ -2,4 +2,4 @@ 'agent-bundle': patch --- -Add PTY-backed Claude Code and Codex sessions to `agent-bundle dev`, expose the Workbench `Host sessions` destination and `/api/sessions`, correlate activity through `AGENT_BUNDLE_DEV_SESSION`, and report `AB8260`–`AB8266` for session failures. (#PR) +Add PTY-backed Claude Code and Codex sessions to `agent-bundle dev`, expose the Workbench `Host sessions` destination and `/api/sessions`, correlate activity through `AGENT_BUNDLE_DEV_SESSION`, and report `AB8260`–`AB8266` for session failures. (#675) From a34bf5e223c0880d2a74fcbc343aa63d7bd80cfa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 08:15:06 +0000 Subject: [PATCH 15/16] review round 1: refuse option-shaped prompts, gate devSession on the receipt by shape, replay scrollback as one frame, re-attach on a new host session id --- .../src/dev/sessions/host-session-routes.ts | 3 ++- .../src/dev/sessions/host-session-service.ts | 9 ++------- .../agent-bundle/src/events/trace-receipt.ts | 3 ++- .../agent-bundle/tests/hook-receipts.test.ts | 17 +++++++++++++++++ .../tests/host-session-routes.test.ts | 5 +++++ .../tests/host-session-service.test.ts | 8 ++++++-- website/docs/en/guide/development/workbench.mdx | 15 +++++++++------ website/docs/en/reference/dev-server-http.mdx | 2 +- website/docs/zh/guide/development/workbench.mdx | 10 ++++++---- website/docs/zh/reference/dev-server-http.mdx | 2 +- 10 files changed, 51 insertions(+), 23 deletions(-) diff --git a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts index 701e1e87b..953c70a97 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-routes.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -87,7 +87,8 @@ const dimensions = (value: Readonly>): HostSessionSize = const creation = (value: Readonly>) => { if (!hasOnly(value, ['cols', 'host', 'prompt', 'rows'])) invalid(); if (value.host !== 'claude' && value.host !== 'codex') invalid(); - if (value.prompt !== undefined && typeof value.prompt !== 'string') invalid(); + // The prompt is the host's positional argument: a leading dash would be parsed as an option. + if (value.prompt !== undefined && (typeof value.prompt !== 'string' || value.prompt.trim() === '' || value.prompt.startsWith('-'))) invalid(); return { ...dimensions({ cols: value.cols, rows: value.rows }), host: value.host as HostSessionHost, diff --git a/packages/agent-bundle/src/dev/sessions/host-session-service.ts b/packages/agent-bundle/src/dev/sessions/host-session-service.ts index 9510558f8..965b1af99 100644 --- a/packages/agent-bundle/src/dev/sessions/host-session-service.ts +++ b/packages/agent-bundle/src/dev/sessions/host-session-service.ts @@ -192,12 +192,7 @@ export class HostSessionService { attach(devSession: string, hostSessionId: string | undefined): void { const record = this.#sessions.get(devSession); - if ( - record === undefined - || record.state !== 'running' - || hostSessionId === undefined - || record.traceSessionId !== undefined - ) return; + if (record === undefined || record.state !== 'running' || hostSessionId === undefined || record.traceSessionId === hostSessionId) return; record.traceSessionId = hostSessionId; this.#publish(record, 'session.attached', 'ok', { hostSessionId }); this.#send(record, { session: this.#snapshot(record), type: 'state' }); @@ -342,7 +337,7 @@ export class HostSessionService { subscribe(id: string, listener: (message: HostSessionStreamMessage) => void): () => void { const record = this.#known(id); listener({ session: this.#snapshot(record), type: 'state' }); - for (const output of record.output) listener({ data: output.toString('base64'), type: 'output' }); + if (record.output.length > 0) listener({ data: Buffer.concat(record.output).toString('base64'), type: 'output' }); if (record.state === 'running') record.listeners.add(listener); else listener({ session: this.#snapshot(record), type: 'end' }); return () => record.listeners.delete(listener); diff --git a/packages/agent-bundle/src/events/trace-receipt.ts b/packages/agent-bundle/src/events/trace-receipt.ts index 09908fdbf..73b863df9 100644 --- a/packages/agent-bundle/src/events/trace-receipt.ts +++ b/packages/agent-bundle/src/events/trace-receipt.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { AgentLineage, Observed } from '@agent-bundle/runtime'; +import { isHostSessionId } from '../contracts/host-sessions.ts'; import type { RequestLineageProvenance, RequestProvenanceAxis } from '../contracts/request-provenance.ts'; import { isLoopbackHttpOrigin } from '../core/loopback-origin.ts'; import type { EventTraceEvent, EventTraceExecution, EventTraceObserver } from './trace.ts'; @@ -224,7 +225,7 @@ export const openEventTraceReceipt = async ( sent = true; const session = options.env[EVENT_TRACE_RECEIPT_SESSION_ENV]; const receipt: EventTraceReceipt = { - ...(typeof session === 'string' && session !== '' ? { devSession: session } : {}), + ...(isHostSessionId(session) ? { devSession: session } : {}), events, execution: options.execution, identity, diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index 710c4db8e..d79ed3d5b 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -507,6 +507,23 @@ it('posts a top-level devSession when AGENT_BUNDLE_DEV_SESSION is set and keeps await recorder!.send(); expect(posted[0]!.devSession).toBe(hostSessionId); expect(posted[0]!.identity).toEqual({ conversationId: 'host-session', requestId: 'u', sessionId: 'host-session' }); + + const stray = await openEventTraceReceipt({ + anchor: 'file:///nowhere/hooks/x.mjs', + env: { + [EVENT_TRACE_RECEIPT_SESSION_ENV]: 'not-a-workbench-session', + [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 't', + [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:6000', + }, + execution: traced, + fetch: async (_input, init) => { + posted.push(JSON.parse(init!.body as string) as EventTraceReceipt); + return new Response(null, { status: 204 }); + }, + }); + createEventTracer({ execution: traced, now: () => 1, observer: stray!.observer }).executeStart('standalone'); + await stray!.send(); + expect(posted[1]).not.toHaveProperty('devSession'); }); it('calls attachHostSession with the validated devSession and the host identity', async () => { diff --git a/packages/agent-bundle/tests/host-session-routes.test.ts b/packages/agent-bundle/tests/host-session-routes.test.ts index 60a0fcdc6..4803cd08d 100644 --- a/packages/agent-bundle/tests/host-session-routes.test.ts +++ b/packages/agent-bundle/tests/host-session-routes.test.ts @@ -86,6 +86,11 @@ it('serves the frozen collection, item, control, restart, and delete contract', }); expect(created.status).toBe(201); const body = await created.json() as { readonly session: { readonly id: string } }; + for (const prompt of ['--dangerously-skip-permissions', '-p x', ' ']) { + expect((await fetch(`${started.url}/api/sessions`, { + body: JSON.stringify({ cols: 80, host: 'claude', prompt, rows: 24 }), headers: json, method: 'POST', + })).status).toBe(400); + } await expect(fetch(`${started.url}/api/sessions`).then((response) => response.json())).resolves.toMatchObject({ hosts: [{ host: 'claude', launchable: true }, { host: 'codex', launchable: true }], diff --git a/packages/agent-bundle/tests/host-session-service.test.ts b/packages/agent-bundle/tests/host-session-service.test.ts index 320b26631..7f5e551d0 100644 --- a/packages/agent-bundle/tests/host-session-service.test.ts +++ b/packages/agent-bundle/tests/host-session-service.test.ts @@ -139,12 +139,13 @@ it('reports unavailable hosts and enforces four live sessions', async () => { await expect(service.create({ cols: 80, host: 'claude', rows: 24 })).rejects.toMatchObject({ code: 'AB8264' }); }); -it('replays bounded scrollback before live output', async () => { +it('replays bounded scrollback as one frame before live output', async () => { const adapter = new FakeAdapter(); const service = serviceFor(adapter, { scrollbackBytes: 8 }); const session = await service.create({ cols: 80, host: 'codex', rows: 24 }); adapter.spawns[0]!.pty.emitData('discard'); - adapter.spawns[0]!.pty.emitData('retained'); + adapter.spawns[0]!.pty.emitData('reta'); + adapter.spawns[0]!.pty.emitData('ined'); const messages: HostSessionStreamMessage[] = []; const unsubscribe = service.subscribe(session.id, (message) => messages.push(message)); @@ -168,10 +169,13 @@ it('attaches the host trace id and uses it for later lifecycle entries', async ( const messages: HostSessionStreamMessage[] = []; service.subscribe(session.id, (message) => messages.push(message)); + service.attach(session.id, 'host-session-41'); + service.attach(session.id, 'host-session-42'); service.attach(session.id, 'host-session-42'); service.attach('hs_0000000000000000', 'ignored'); service.attach(session.id, undefined); + expect(traces.filter((entry) => entry.kind === 'session.attached')).toHaveLength(2); expect(service.traceSessionId(session.id)).toBe('host-session-42'); expect(service.traceSessionId('hs_0000000000000000')).toBe('hs_0000000000000000'); expect(service.read(session.id)).toMatchObject({ traceSessionId: 'host-session-42' }); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 0a19a4182..294dfd717 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -153,15 +153,18 @@ it cannot be resolved or loaded, launch is disabled with `AB8260`. The foreground server remains loopback-only and applies the same session cookie or bearer guard to every `/api/sessions` route. Requests cannot choose an executable, arguments, working -directory, or environment. The child never receives the foreground session token, and Agent -Bundle does not pass a permission or approval bypass to either host. The terminal inherits the +directory, or environment; the optional prompt is passed as the host's positional argument and is +refused (`AB8261`) when blank or starting with `-`. The child never receives the foreground +session token, and Agent Bundle does not pass a permission or approval bypass to either host. The terminal inherits the dev server environment plus the hook receipt variables, `AGENT_BUNDLE_DEV_SESSION=`, `TERM=xterm-256color`, and `COLORTERM=truecolor`. -`AGENT_BUNDLE_DEV_SESSION` is the Workbench resource id, not a credential. A generated -SessionStart hook receipt carries it as `devSession` alongside the host's own `session_id`. Once -that receipt arrives, Trace aliases the resource to the host session id and groups later -lifecycle, hook, and MCP entries by that id. Claude inherits the variable into its stdio MCP +`AGENT_BUNDLE_DEV_SESSION` is the Workbench resource id, not a credential. Every generated hook +receipt carries it as `devSession` alongside the host's own `session_id`. Once the first receipt +arrives, Trace aliases the resource to the host session id and groups later lifecycle, hook, and +MCP entries by that id; a receipt with a new host id (Claude Code issues one on `/clear`) moves +the alias and publishes another `session.attached` entry. Entries that arrive before any receipt +stay grouped under the `hs_…` id. Claude inherits the variable into its stdio MCP server, so the proxy supplies the alias header. Codex filters the environment of stdio MCP servers, so the proxy cannot inherit that variable; it always sends its process id instead, and the foreground server walks the proxy's parent process chain to the PTY child that owns the diff --git a/website/docs/en/reference/dev-server-http.mdx b/website/docs/en/reference/dev-server-http.mdx index 10de175ae..7e1f2658a 100644 --- a/website/docs/en/reference/dev-server-http.mdx +++ b/website/docs/en/reference/dev-server-http.mdx @@ -53,7 +53,7 @@ are strict, input is limited to 16 KiB of UTF-8 text, and terminal dimensions ar `epochId`, and host plugin `install` destination. `HostAvailability` contains `host`, `launchable`, and optional `reason` and resolved `executable`. -The stream starts with a state frame, replays up to 256 KiB of retained output, then continues +The stream starts with a state frame, replays up to 256 KiB of retained output as one frame, then continues with live state and output. It sends a keep-alive comment every 15 seconds and closes after `end`. Each event has one JSON `data:` object: diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index acb2bba5f..6ec436999 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -125,13 +125,15 @@ npx agent-bundle dev --install-host claude --install-host codex 重连。项目中必须安装 `@lydell/node-pty`;无法解析或加载时,启动会以 `AB8260` 禁用。 前台服务器仍仅限 loopback,并对每条 `/api/sessions` 路由应用相同的会话 cookie 或 bearer 守卫。 -请求不能选择可执行文件、参数、工作目录或环境。子进程不会收到前台会话 token,Agent Bundle 也不会向 +请求不能选择可执行文件、参数、工作目录或环境;可选的提示词作为宿主的位置参数传入,为空或以 `-` +开头时会被拒绝(`AB8261`)。子进程不会收到前台会话 token,Agent Bundle 也不会向 任何宿主传入权限或审批绕过参数。终端继承开发服务器环境,并增加钩子收据变量、 `AGENT_BUNDLE_DEV_SESSION=`、`TERM=xterm-256color` 与 `COLORTERM=truecolor`。 -`AGENT_BUNDLE_DEV_SESSION` 是 Workbench 资源 id,不是凭据。生成的 SessionStart 钩子收据会把它作为 -`devSession`,并同时携带宿主自己的 `session_id`。收据到达后,Trace 会把资源别名关联到宿主会话 id, -后续生命周期、钩子与 MCP 条目都按该 id 分组。Claude 会把该变量传给 stdio MCP 服务器,因此代理通过 +`AGENT_BUNDLE_DEV_SESSION` 是 Workbench 资源 id,不是凭据。每条生成的钩子收据都会把它作为 +`devSession`,并同时携带宿主自己的 `session_id`。首条收据到达后,Trace 会把资源别名关联到宿主会话 id, +后续生命周期、钩子与 MCP 条目都按该 id 分组;携带新宿主 id 的收据(Claude Code 在 `/clear` 时会签发 +新 id)会移动别名并再发布一条 `session.attached` 条目。在任何收据之前到达的条目仍按 `hs_…` id 分组。Claude 会把该变量传给 stdio MCP 服务器,因此代理通过 别名标头传递它。Codex 会过滤 stdio MCP 服务器的环境,因此代理无法继承该变量;代理始终改为发送自己的 进程 id,前台服务器沿代理的父进程链查找到拥有 Workbench 会话的 PTY 子进程。Codex 帧还可以通过 `x-codex-turn-metadata.session_id` 携带宿主会话 id。它的钩子仍继承 diff --git a/website/docs/zh/reference/dev-server-http.mdx b/website/docs/zh/reference/dev-server-http.mdx index 40bde1a52..f7d383d10 100644 --- a/website/docs/zh/reference/dev-server-http.mdx +++ b/website/docs/zh/reference/dev-server-http.mdx @@ -48,7 +48,7 @@ Document、结果、投影、诊断与计时。Trace 条目链接到这份完整 `install` 目的地。`HostAvailability` 包含 `host`、`launchable`,以及可选的 `reason` 与已解析 `executable`。 -流以一条 state 帧开始,重放最多 256 KiB 的保留输出,再继续发送实时 state 与 output。它每 15 秒发送 +流以一条 state 帧开始,以一帧重放最多 256 KiB 的保留输出,再继续发送实时 state 与 output。它每 15 秒发送 一条 keep-alive 注释,并在 `end` 后关闭。每个事件带有一个 JSON `data:` 对象: ```text From 71149d85c28d5abcedd7a5064b0643af7aa4325c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 08:22:48 +0000 Subject: [PATCH 16/16] review round 2: move the host-session alias only on a session/start receipt --- .../src/dev/hooks/hook-receipt-endpoint.ts | 3 ++- packages/agent-bundle/tests/hook-receipts.test.ts | 11 +++++++++-- website/docs/en/guide/development/workbench.mdx | 4 ++-- website/docs/zh/guide/development/workbench.mdx | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts index ce7e88c48..b09f2d9f8 100644 --- a/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts +++ b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts @@ -106,7 +106,8 @@ export class HookReceiptRoutes { } throw error; } - if (receipt.devSession !== undefined) { + // Only a session start moves the alias: `/clear` issues a new host id, while a nested host run's tool hooks must not flip it. + if (receipt.devSession !== undefined && receipt.execution.event === 'session/start') { this.#attachHostSession?.(receipt.devSession, receipt.identity.sessionId); } const entries: readonly TraceEntryInput[] = lowerHookReceipt(receipt); diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index d79ed3d5b..ca01f75a5 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -526,7 +526,7 @@ it('posts a top-level devSession when AGENT_BUNDLE_DEV_SESSION is set and keeps expect(posted[1]).not.toHaveProperty('devSession'); }); -it('calls attachHostSession with the validated devSession and the host identity', async () => { +it('calls attachHostSession only for a session/start receipt carrying devSession', async () => { const attached: [string, string | undefined][] = []; const hub = new TraceHub({ projectRoot: '/work/project' }); const routes = new HookReceiptRoutes({ @@ -537,8 +537,15 @@ it('calls attachHostSession with the validated devSession and the host identity' trace: hub, }); const { url } = await listen((request, response) => routes.handle(request, response)); - const accepted = await post(url, JSON.stringify({ ...receipt(), devSession: hostSessionId }), jsonHeaders('secret-token')); + const start = { ...execution, event: 'session/start', nativeEvent: 'SessionStart' } as const; + const accepted = await post(url, JSON.stringify({ ...receipt({ execution: start }), devSession: hostSessionId }), jsonHeaders('secret-token')); expect(accepted.status).toBe(204); + // A nested host run's tool hook carries the same devSession with its own session id; it must not move the alias. + const nested = await post(url, JSON.stringify({ + ...receipt({ identity: { conversationId: 'agent-8', requestId: 'toolu_2', sessionId: 'session-2' } }), + devSession: hostSessionId, + }), jsonHeaders('secret-token')); + expect(nested.status).toBe(204); expect(attached).toEqual([[hostSessionId, 'session-1']]); expect(hub.replay().entries[0]?.correlation.sessionId).toBe('session-1'); }); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 294dfd717..e3b3d72e6 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -162,8 +162,8 @@ dev server environment plus the hook receipt variables, `AGENT_BUNDLE_DEV_SESSIO `AGENT_BUNDLE_DEV_SESSION` is the Workbench resource id, not a credential. Every generated hook receipt carries it as `devSession` alongside the host's own `session_id`. Once the first receipt arrives, Trace aliases the resource to the host session id and groups later lifecycle, hook, and -MCP entries by that id; a receipt with a new host id (Claude Code issues one on `/clear`) moves -the alias and publishes another `session.attached` entry. Entries that arrive before any receipt +MCP entries by that id; a `session/start` receipt with a new host id (Claude Code issues one on +`/clear`) moves the alias and publishes another `session.attached` entry. Entries that arrive before any receipt stay grouped under the `hs_…` id. Claude inherits the variable into its stdio MCP server, so the proxy supplies the alias header. Codex filters the environment of stdio MCP servers, so the proxy cannot inherit that variable; it always sends its process id instead, and diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 6ec436999..31857fe95 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -132,8 +132,8 @@ npx agent-bundle dev --install-host claude --install-host codex `AGENT_BUNDLE_DEV_SESSION` 是 Workbench 资源 id,不是凭据。每条生成的钩子收据都会把它作为 `devSession`,并同时携带宿主自己的 `session_id`。首条收据到达后,Trace 会把资源别名关联到宿主会话 id, -后续生命周期、钩子与 MCP 条目都按该 id 分组;携带新宿主 id 的收据(Claude Code 在 `/clear` 时会签发 -新 id)会移动别名并再发布一条 `session.attached` 条目。在任何收据之前到达的条目仍按 `hs_…` id 分组。Claude 会把该变量传给 stdio MCP 服务器,因此代理通过 +后续生命周期、钩子与 MCP 条目都按该 id 分组;携带新宿主 id 的 `session/start` 收据(Claude Code 在 +`/clear` 时会签发新 id)会移动别名并再发布一条 `session.attached` 条目。在任何收据之前到达的条目仍按 `hs_…` id 分组。Claude 会把该变量传给 stdio MCP 服务器,因此代理通过 别名标头传递它。Codex 会过滤 stdio MCP 服务器的环境,因此代理无法继承该变量;代理始终改为发送自己的 进程 id,前台服务器沿代理的父进程链查找到拥有 Workbench 会话的 PTY 子进程。Codex 帧还可以通过 `x-codex-turn-metadata.session_id` 携带宿主会话 id。它的钩子仍继承