Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9e16d2e
chore(deps): @lydell/node-pty root devDependency; @xterm/xterm + addo…
ScriptedAlchemy Sep 6, 2026
4bcd30f
feat(dev): join hook receipts and host MCP frames on AGENT_BUNDLE_DEV…
ScriptedAlchemy Sep 6, 2026
c34c0ef
feat(dev): add PTY-backed host sessions
ScriptedAlchemy Sep 6, 2026
974df95
workbench(trace): add the session trace source and link hs_ groups to…
ScriptedAlchemy Sep 6, 2026
31710cd
workbench(sessions): host-session client, xterm terminal, Sessions pa…
ScriptedAlchemy Sep 6, 2026
2a473d3
lane S3: LANE-NOTES
ScriptedAlchemy Sep 6, 2026
2723ea5
integrate S1
ScriptedAlchemy Sep 6, 2026
bc7e281
integrate S2
ScriptedAlchemy Sep 6, 2026
359e4e2
wire host-session attach/traceSessionId into receipts and host MCP ro…
ScriptedAlchemy Sep 6, 2026
fafe7ef
integrate S3
ScriptedAlchemy Sep 6, 2026
e00a03e
share host-session wire shapes through agent-bundle/contracts; drop t…
ScriptedAlchemy Sep 6, 2026
60328d1
feat(dev): resolve a host MCP proxy's Workbench session by process an…
ScriptedAlchemy Sep 6, 2026
1673acc
host sessions: record only a measured exit signal
ScriptedAlchemy Sep 6, 2026
651c0bf
workbench(sessions): send only cols/rows on resize (xterm's onResize …
ScriptedAlchemy Sep 6, 2026
6eba57c
test: accept workbench host sessions
ScriptedAlchemy Sep 6, 2026
2f13405
integrate S4
ScriptedAlchemy Sep 6, 2026
85ccbfb
Merge remote-tracking branch 'origin/main' into wb600-pr3-sessions
ScriptedAlchemy Sep 6, 2026
ceea747
deslop: drop the unneeded public contracts entry, PTY disposer plumbi…
ScriptedAlchemy Sep 6, 2026
cb4d1f2
changeset: name #675
ScriptedAlchemy Sep 6, 2026
a34bf5e
review round 1: refuse option-shaped prompts, gate devSession on the …
ScriptedAlchemy Sep 6, 2026
71149d8
review round 2: move the host-session alias only on a session/start r…
ScriptedAlchemy Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wb600-host-sessions.md
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<command>` id was used instead of its canonical `tool:<server>/<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/<id>/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). |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions packages/agent-bundle/src/contracts/host-sessions.ts
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 14 additions & 2 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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' }));
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 21 additions & 3 deletions packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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)) {
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-bundle/src/dev/hooks/hook-receipts.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -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);
};
Expand Down Expand Up @@ -123,6 +134,12 @@ const decodeLineage = (value: unknown): RequestProvenanceAxis<RequestLineageProv
});
};

const decodeDevSession = (value: unknown): string | undefined => {
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');
Expand Down Expand Up @@ -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');
Expand All @@ -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'),
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 24 additions & 1 deletion packages/agent-bundle/src/dev/host-mcp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeJS.ProcessEnv> = process.env,
pid = process.pid,
): { readonly requestInit: { readonly headers: Readonly<Record<string, string>> } } => {
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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading