diff --git a/.changeset/wb600-host-sessions.md b/.changeset/wb600-host-sessions.md new file mode 100644 index 000000000..c51f1f162 --- /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. (#675) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 02e63244b..50bb4773d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -51,6 +51,13 @@ 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. | +| `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/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/agent-bundle/src/contracts/host-sessions.ts b/packages/agent-bundle/src/contracts/host-sessions.ts new file mode 100644 index 000000000..ed087323b --- /dev/null +++ b/packages/agent-bundle/src/contracts/host-sessions.ts @@ -0,0 +1,48 @@ +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; +} + +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/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/hooks/hook-receipt-endpoint.ts b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts index fe9498af7..b09f2d9f8 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,23 @@ 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; } + // 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); for (const entry of entries) this.#trace.publish(entry); response.writeHead(204, { 'cache-control': 'no-store' }); response.end(); @@ -114,6 +127,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 +150,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-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/host-mcp-proxy.ts b/packages/agent-bundle/src/dev/host-mcp-proxy.ts index 2426591d1..8e1f51132 100644 --- a/packages/agent-bundle/src/dev/host-mcp-proxy.ts +++ b/packages/agent-bundle/src/dev/host-mcp-proxy.ts @@ -6,9 +6,29 @@ 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 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, + pid = process.pid, +): { readonly requestInit: { readonly headers: Readonly> } } => { + const session = env.AGENT_BUNDLE_DEV_SESSION; + return { + requestInit: { + headers: { + [HOST_MCP_DEV_PID_HEADER]: String(pid), + ...(isHostSessionId(session) ? { [HOST_MCP_DEV_SESSION_HEADER]: session } : {}), + }, + }, + }; +}; + export const hostMcpUnavailableCode = 'AB8025'; export interface RunHostMcpProxyOptions { @@ -96,7 +116,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..66d38ba6d 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -4,18 +4,46 @@ 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'; +const hostDevPidHeader = 'x-agent-bundle-dev-pid'; + +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; +}; + +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; @@ -48,11 +76,14 @@ 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; + /** 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 => { @@ -81,9 +112,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 +130,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 +269,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 +394,16 @@ export class HostMcpRoutes { readonly #mcpSessions: McpSessionService; readonly #sessions = new Map(); readonly #subscription: ProjectEventSubscription; + readonly #sessionForProcess: HostMcpRoutesOptions['sessionForProcess']; + readonly #traceSessionId: HostMcpRoutesOptions['traceSessionId']; #closed = false; constructor(options: HostMcpRoutesOptions) { 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); }); @@ -389,9 +432,18 @@ export class HostMcpRoutes { return true; } + const pid = hostDevProcessId(request.headers); + const devSession = hostDevSessionId(request.headers) + ?? (pid === undefined ? undefined : await this.#sessionForProcess?.(pid)); 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/sessions/host-session-routes.ts b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts new file mode 100644 index 000000000..953c70a97 --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/host-session-routes.ts @@ -0,0 +1,240 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { + type HostAvailability, + type HostSession, + type HostSessionHost, + type HostSessionLaunchRequest, + type HostSessionSize, + isHostSessionId, +} 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, + HOST_SESSION_UNKNOWN_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 (!isHostSessionId(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>): HostSessionSize => { + 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(); + // 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, + ...(value.prompt === undefined ? {} : { prompt: value.prompt as string }), + }; +}; + +export interface HostSessionRouteService { + availability(): Promise; + close?(): Promise | void; + 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: HostSessionSize): 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(HOST_SESSION_UNKNOWN_CODE, `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?.(); + }); + 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', + }); + 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/host-session-service.ts b/packages/agent-bundle/src/dev/sessions/host-session-service.ts new file mode 100644 index 000000000..965b1af99 --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/host-session-service.ts @@ -0,0 +1,451 @@ +import { Buffer } from 'node:buffer'; +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { access, readFile } from 'node:fs/promises'; +import { delimiter, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +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 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; +}; + +/** 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 || signal === 0 ? 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 === hostSessionId) 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; + } + + /** 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) { + 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' }); + 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); + } + + 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 = 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..059bddd47 --- /dev/null +++ b/packages/agent-bundle/src/dev/sessions/pty.ts @@ -0,0 +1,38 @@ +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 NativePtyModule { + spawn(file: string, args: string[], options: PtySpawnOptions): PtyProcess; +} + +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) => 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..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/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 2029210e0..11c0eefaf 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, @@ -621,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); @@ -831,7 +836,24 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, platformRuntime, }); - const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions }); + 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, + sessionForProcess: (pid) => hostSessions.sessionForProcess(pid), + traceSessionId: (devSession) => hostSessions.traceSessionId(devSession), + }); const hookPlayground = new HookPlaygroundService({ epochStore, hookService: new HookService({ @@ -1046,6 +1068,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun hookReceipts: hookReceipts.routes, hostDiscovery, hostMcp, + hostSessions, inspector, lifecycleReplay, logs, diff --git a/packages/agent-bundle/src/events/trace-receipt.ts b/packages/agent-bundle/src/events/trace-receipt.ts index ee08e4551..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'; @@ -19,6 +20,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 +47,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 +223,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 = { + ...(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 e2a6ff911..ca01f75a5 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,70 @@ 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' }); + + 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 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({ + attachHostSession: (devSession, hostSessionId) => { + attached.push([devSession, hostSessionId]); + }, + token: 'secret-token', + trace: hub, + }); + const { url } = await listen((request, response) => routes.handle(request, response)); + 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'); +}); + 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..3b59daed3 --- /dev/null +++ b/packages/agent-bundle/tests/host-mcp-routes.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from '@rstest/core'; + +import { isHostSessionId } from '../src/contracts/host-sessions.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'; + +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('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-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..4803cd08d --- /dev/null +++ b/packages/agent-bundle/tests/host-session-routes.test.ts @@ -0,0 +1,174 @@ +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); + } + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): void { + this.#exit.add(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 } }; + 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 }], + 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..7f5e551d0 --- /dev/null +++ b/packages/agent-bundle/tests/host-session-service.test.ts @@ -0,0 +1,234 @@ +import { spawn } from 'node:child_process'; + +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); + } + + onExit(listener: (event: { readonly exitCode: number; readonly signal?: number }) => void): void { + this.#exit.add(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; + }> = []; + + constructor(readonly pids: readonly number[] = []) {} + + spawn(file: string, args: readonly string[], options: PtySpawnOptions): PtyProcess { + const pty = new FakePty(this.pids[this.spawns.length] ?? 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 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('reta'); + adapter.spawns[0]!.pty.emitData('ined'); + 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-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' }); + 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('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 }); + 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/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/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/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/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/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-model.ts b/packages/workbench/src/sessions/host-session-model.ts new file mode 100644 index 000000000..b62ceb375 --- /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 '../../../agent-bundle/src/contracts/host-sessions.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..53a78e4a1 --- /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 '../../../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'; + +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..61d6fafb8 --- /dev/null +++ b/packages/workbench/src/sessions/sessions-page.tsx @@ -0,0 +1,156 @@ +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 '../../../agent-bundle/src/contracts/host-sessions.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 = (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' }); + 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..f256fb1ac --- /dev/null +++ b/packages/workbench/src/sessions/terminal.tsx @@ -0,0 +1,93 @@ +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 '../../../agent-bundle/src/contracts/host-sessions.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(({ cols, rows }) => { + const size = { cols, rows }; + 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/src/trace/trace-model.ts b/packages/workbench/src/trace/trace-model.ts index 679fb2e45..fb97569ab 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,27 @@ 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); + 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([ ['invocation.started', 'invocation started'], ['invocation.completed', 'invocation completed'], @@ -294,6 +320,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/host-session-client.test.ts b/packages/workbench/tests/host-session-client.test.ts new file mode 100644 index 000000000..3a3fdd7f4 --- /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 '../../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' }), + 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..0bfeb5c1b --- /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 '../../agent-bundle/src/contracts/host-sessions.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/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-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/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/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/trace-model.test.ts b/packages/workbench/tests/trace-model.test.ts index 35f528737..f55abb477 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,27 @@ 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(); + 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/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"'); 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"'); 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 diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 53dc20a30..92cc0a00b 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', @@ -117,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', ]; @@ -131,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..e3b3d72e6 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,49 @@ 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 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. 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 `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 +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 +207,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 +307,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 4db8da2da..7e1f2658a 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 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: + +```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`, @@ -75,6 +127,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/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index a0f12436a..31857fe95 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,39 @@ 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 守卫。 +请求不能选择可执行文件、参数、工作目录或环境;可选的提示词作为宿主的位置参数传入,为空或以 `-` +开头时会被拒绝(`AB8261`)。子进程不会收到前台会话 token,Agent Bundle 也不会向 +任何宿主传入权限或审批绕过参数。终端继承开发服务器环境,并增加钩子收据变量、 +`AGENT_BUNDLE_DEV_SESSION=`、`TERM=xterm-256color` 与 `COLORTERM=truecolor`。 + +`AGENT_BUNDLE_DEV_SESSION` 是 Workbench 资源 id,不是凭据。每条生成的钩子收据都会把它作为 +`devSession`,并同时携带宿主自己的 `session_id`。首条收据到达后,Trace 会把资源别名关联到宿主会话 id, +后续生命周期、钩子与 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。它的钩子仍继承 +`AGENT_BUNDLE_DEV_SESSION`,并发送收据的 `devSession` 字段。**Trace** 链接在宿主 id 可用后始终使用它。 + ## Trace Trace 是前台服务器所观察到的应用活动的实时有序时间线。例如,运行 @@ -137,6 +171,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 +255,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 77656dde7..f7d383d10 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`、 @@ -69,6 +119,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 原生冒烟测试。 |