@automatalabs/acp-agents 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,18 @@ try {
45
45
  }
46
46
  ```
47
47
 
48
- `run()` accepts the full `RunOptions` seam: `schema`, `model`, `tier`, `cwd`, `instructions`, `label`, `signal` (cancellation), `toolNames` / `disallowedToolNames`, `mcpServers`, `runId`, `baseInstructions` / `developerInstructions` (Codex-only, see below), `onUsage`, `onModelResolved`, `onModelFallback`, and `onHistory`. See `@automatalabs/shared-types` for the field-by-field contract.
48
+ `run()` accepts the full `RunOptions` seam: `schema`, `model`, `tier`, `cwd`, `instructions`, `label`, `signal` (cancellation), `toolNames` / `disallowedToolNames`, `mcpServers`, `images` (see below), `runId`, `baseInstructions` / `developerInstructions` (Codex-only, see below), `onUsage`, `onModelResolved`, `onModelFallback`, and `onHistory`. See `@automatalabs/shared-types` for the field-by-field contract.
49
+
50
+ ### Image attachments (`images`)
51
+
52
+ `images` appends base64 image `ContentBlock`s to the first prompt turn. The client adapts content to what the connected agent advertised at `initialize`: when the agent does not advertise `promptCapabilities.image`, each attachment degrades to a bracketed text note naming the mime type (never an error, never silently dropped). Repair/re-prompt turns stay text-only.
53
+
54
+ ```ts
55
+ await runner.run("What's in this screenshot?", {
56
+ cwd: "/abs/path/to/worktree",
57
+ images: [{ data: base64Png, mimeType: "image/png" }],
58
+ });
59
+ ```
49
60
 
50
61
  ### Codex session instructions (`baseInstructions` / `developerInstructions`)
51
62
 
@@ -65,9 +76,64 @@ await runner.run("Cut the release.", {
65
76
  });
66
77
  ```
67
78
 
79
+ ## Client-side fs / terminal handlers (`clientHandlers`)
80
+
81
+ By default the agent uses its **own** built-in file and exec tools — the client never sees those operations. Register `clientHandlers` to interpose: the client then advertises exactly what you registered at `initialize` (`fs.readTextFile` / `fs.writeTextFile` per-method; `terminal` only when **all five** terminal methods are provided) and routes the agent's `fs/*` and `terminal/*` requests to your handlers. Every handler receives the request params plus an `AcpSessionContext` — `sessionId`, the session's **own** `cwd`, `label`, `runId` — so a pooled process serving many sessions still gets per-session isolation.
82
+
83
+ **Confinement is your job.** The library routes requests and supplies the session context; enforcing worktree roots, resolving symlinks, scoping environment variables, bounding output, and applying timeouts belongs in your handler implementation. Requests for methods you did not register are rejected with a JSON-RPC method-not-found error (agents that respect the advertisement never send them).
84
+
85
+ ```ts
86
+ const runner = createAcpRunner({
87
+ clientHandlers: {
88
+ fs: {
89
+ readTextFile: async ({ path }, { cwd }) => ({ content: await confinedRead(cwd, path) }),
90
+ writeTextFile: async ({ path, content }, { cwd }) => { await confinedWrite(cwd, path, content); },
91
+ },
92
+ // terminal: { createTerminal, terminalOutput, waitForTerminalExit, killTerminal, releaseTerminal },
93
+ },
94
+ });
95
+ ```
96
+
97
+ ## Interactive sessions
98
+
99
+ Use `runner.openSession(options)` when a host needs to hold one ACP session open across multiple prompt turns. It uses the same backend selection and session/new inputs as `run()` (`model` / `tier`, absolute `cwd`, tool policy, `mcpServers`, `meta`, `runId`, Codex instruction overrides), but it spawns a **dedicated** agent process for that session instead of borrowing from the pool. A long-lived interactive session therefore never starves `run()` calls on the same backend, even with the default pool size of one.
100
+
101
+ Prompt turns are explicit and serialized: call `prompt(content, { images?, promptMeta? })`, await the returned `{ stopReason, text }`, then send the next turn. A second `prompt()` while one is in flight rejects with a host-side error; queue turns in your host if you want queued UX. `text` is only the assistant text from that turn. Per-turn `images` use the same ACP image block path as `run()` and still degrade through capability negotiation when the agent does not advertise image prompts.
102
+
103
+ `cancel()` sends ACP `session/cancel` for the active turn. `release()` is idempotent: it best-effort closes the ACP session and then disposes the dedicated process. Passing `signal` to `openSession()` releases the session on abort, and `runner.dispose()` releases any still-open interactive sessions before closing the pooled processes. Dedicated process death is observed per session: the wrapper auto-releases, session-scoped listeners see `session_close`, and an in-flight prompt rejects through the normal connection-closed path. `backend_error` is connection-scoped observability on the runner bus only; it is not delivered through `session.on()`.
104
+
105
+ ```ts
106
+ const runner = createAcpRunner();
107
+
108
+ try {
109
+ const session = await runner.openSession({
110
+ model: "anthropic/claude-sonnet-4",
111
+ cwd: "/abs/path/to/worktree",
112
+ onPermissionRequest: async (request) => choosePermission(request),
113
+ });
114
+
115
+ const off = session.on("agent_message_chunk", (e) => {
116
+ if (e.content.type === "text") process.stdout.write(e.content.text);
117
+ });
118
+
119
+ try {
120
+ const first = await session.prompt("Inspect the failing test.");
121
+ const second = await session.prompt("Patch the smallest fix.", {
122
+ images: [{ data: base64Png, mimeType: "image/png" }],
123
+ });
124
+ console.log(first.stopReason, second.text);
125
+ } finally {
126
+ off();
127
+ await session.release();
128
+ }
129
+ } finally {
130
+ await runner.dispose();
131
+ }
132
+ ```
133
+
68
134
  ## Listening in: live ACP events
69
135
 
70
- `AcpAgentRunner` is also a typed event bus — `runner.on(name, listener)` bubbles up the live ACP stream of every run (streaming text, tool calls, usage, permissions). Event names are the ACP `sessionUpdate` discriminants (`agent_message_chunk`, `tool_call`, `usage_update`, …) plus the cross-cutting `session_update` (catch-all), `permission_request`, `raw_message`, `session_open` / `session_close`, and `backend_error`. Each payload carries a `{ sessionId, backendId, label?, runId? }` context envelope (a pooled runner multiplexes many runs at once). `on()` / `once()` return an unsubscribe thunk; `off()` and `removeAllListeners()` round it out. Listeners are best-effort observers — a throwing listener never affects the run.
136
+ `AcpAgentRunner` is also a typed event bus — `runner.on(name, listener)` bubbles up the live ACP stream of every run (streaming text, tool calls, usage, permissions). Event names are the ACP `sessionUpdate` discriminants (`agent_message_chunk`, `tool_call`, `usage_update`, …) plus the cross-cutting `session_update` (catch-all), `permission_pending`, `permission_request`, `raw_message`, `session_open` / `session_close`, and `backend_error`. Each payload carries a `{ sessionId, backendId, label?, runId? }` context envelope (a pooled runner multiplexes many runs at once). `permission_pending` is resolver-only and carries `{ request }` before the host resolver is invoked; `permission_request` fires exactly once with the final `{ request, outcome }` returned to the agent. `on()` / `once()` return an unsubscribe thunk; `off()` and `removeAllListeners()` round it out. Listeners are best-effort observers — a throwing listener never affects the run.
71
137
 
72
138
  ```ts
73
139
  const off = runner.on("agent_message_chunk", (e) => {
@@ -85,20 +151,24 @@ The full event map (`AcpRunnerEventMap`) and helpers (`TypedEventEmitter`) are e
85
151
  From [`src/index.ts`](./src/index.ts):
86
152
 
87
153
  - **`createAcpRunner(options?)`** — factory returning an `AcpAgentRunner` (this is what `@automatalabs/workflows` injects into the engine).
88
- - **`AcpAgentRunner`** — the `AgentRunner` implementation; `run(prompt, options)` and `dispose()`.
154
+ - **`AcpAgentRunner`** — the `AgentRunner` implementation; `run(prompt, options)`, `openSession(options)`, and `dispose()`.
155
+ - **`InteractiveSession` / `InteractiveSessionOptions` / `InteractiveTurn`** — the held-open multi-turn session surface returned by `openSession()`.
89
156
  - **`selectBackend({ model, tier }, registry?)`** — the cross-provider routing rule: which backend a spec maps to (registered custom names match first, exact or `name/<inner-model>`).
90
157
  - **`ClaudeBackend` / `CodexBackend`** — the two built-in backend strategies (spawn config + per-backend schema wiring).
91
- - **`CustomAcpBackend` / `resolveBackendRegistry` / `BACKENDS_ENV`** — the custom-backend registry: run **any** ACP agent as a named backend via `createAcpRunner({ backends: { name: { command, args?, env?, sessionMeta? } } })` or the `AGENTPRISM_BACKENDS` env var (JSON, same shape; the option wins per name; `claude`/`codex` reserved). Custom backends carry a `schema` as turn-level `_meta.outputSchema` and read the result off the final message as JSON.
158
+ - **`CustomAcpBackend` / `resolveBackendRegistry` / `BACKENDS_ENV`** — the custom-backend registry: run **any** ACP agent as a named backend via `createAcpRunner({ backends: { name: { command, args?, env?, sessionMeta?, customCapabilities? } } })` or the `AGENTPRISM_BACKENDS` env var (JSON, same shape; the option wins per name; `claude`/`codex` reserved). Custom backends carry a `schema` as turn-level `_meta.outputSchema` and read the result off the final message as JSON. `customCapabilities: { namespace, gatedKeys }` declares the agent's `agentCapabilities._meta` negotiation contract: once the agent advertises that namespace, each declared bare `_meta` key is sent only when its same-named flag is `true` (no declaration = never gated).
159
+ - **`PermissionResolver`** — async human-in-the-loop permission resolution for runner-wide or interactive sessions.
160
+ - **`clientCapabilitiesFor` + the `ClientHandlers` / `FsHandlers` / `TerminalHandlers` / `AcpSessionContext` types** — the client-side fs/terminal interposition surface (see above).
161
+ - **`negotiateCapabilities` / `adaptPromptContent` / `gateCustomMeta` / `unsupportedMcpServer` + `NegotiatedCapabilities`** — the `initialize` capability-negotiation primitives; the negotiated record for a live connection is exposed on `PooledConnection.capabilities`.
92
162
  - **`toJsonSchema(schema)` / `toStrictJsonSchema(schema)`** — turn a typebox schema into the on-the-wire shapes: a plain JSON Schema for Claude `outputFormat`, and an OpenAI-strict-normalized schema for Codex `outputSchema`.
93
163
 
94
- Also exported: `AcpAgentPool` / `resolvePoolSize`, `PooledConnection` / `SessionHandle`, `decidePermission`, `UsageAccumulator`, `resolveStructuredOutput` / `extractValidated` / `findJsonBlock` / `validateValue`, `errorText` / `mapThrownError`, and the event surface `TypedEventEmitter` / `AcpRunnerEventMap` / `AcpEventName` / `AcpEventListener` / `AcpEventContext` / `AcpSessionUpdate` (+ the per-event payload types), plus their associated types.
164
+ Also exported: `AcpAgentPool` / `resolvePoolSize`, `PooledConnection` / `SessionHandle`, `decidePermission`, `UsageAccumulator`, `resolveStructuredOutput` / `extractValidated` / `findJsonBlock` / `validateValue`, `errorText` / `mapThrownError`, and the event surface `TypedEventEmitter` / `AcpRunnerEventMap` / `AcpEventName` / `AcpEventListener` / `AcpEventContext` / `AcpSessionUpdate` (+ the per-event payload types, including `AcpPermissionPendingEvent`), plus their associated types.
95
165
 
96
166
  ## Environment overrides
97
167
 
98
168
  | Variable | Effect |
99
169
  | --- | --- |
100
170
  | `AGENTPRISM_DEFAULT_BACKEND` | Backend when `model`/`tier` don't pick one (`codex` selects Codex; a registered custom name selects that backend; anything else is Claude). |
101
- | `AGENTPRISM_BACKENDS` | Custom ACP backends as JSON: `{"<name>": {"command": "…", "args": […], "env": {…}, "sessionMeta": {…}}}`. |
171
+ | `AGENTPRISM_BACKENDS` | Custom ACP backends as JSON: `{"<name>": {"command": "…", "args": […], "env": {…}, "sessionMeta": {…}, "customCapabilities": {"namespace": "…", "gatedKeys": […]}}}`. |
102
172
  | `AGENTPRISM_ACP_INIT_TIMEOUT_MS` | Deadline (default `60000`) for a backend's one-time ACP `initialize` handshake — a non-ACP command fails fast instead of hanging. |
103
173
  | `AGENTPRISM_ACP_POOL_SIZE` | Long-lived processes to keep per backend (default `1`). |
104
174
  | `AGENTPRISM_CLAUDE_ACP_CMD` / `AGENTPRISM_CLAUDE_ACP_ARGS` | Override the command (and args) used to spawn the Claude ACP server. |
@@ -1,35 +1,46 @@
1
- import { ClientSideConnection, type PromptResponse, type SessionConfigOption, type SessionNotification } from "@agentclientprotocol/sdk";
1
+ import { ClientSideConnection, type ContentBlock, type PromptResponse, type RequestPermissionResponse, type SessionConfigOption, type SessionNotification } from "@agentclientprotocol/sdk";
2
2
  import type { TSchema } from "typebox";
3
3
  import { type AgentHistoryEntry, type McpServerConfig } from "@automatalabs/shared-types";
4
4
  import type { Backend, BackendId, StructuredSource } from "./backend.js";
5
5
  import { type NegotiatedCapabilities } from "./capabilities.js";
6
6
  import { type AcpEventSink } from "./events.js";
7
- import { type ToolPolicy } from "./permissions.js";
7
+ import { type PermissionResolver, type ToolPolicy } from "./permissions.js";
8
8
  import { UsageAccumulator } from "./usage.js";
9
+ import { type ClientHandlers } from "./client-handlers.js";
9
10
  interface RawResultSuccess {
10
11
  type: string;
11
12
  subtype: string;
12
13
  structured_output?: unknown;
13
14
  }
14
15
  /** Per-session accumulator: assistant text, tool history, usage, the Claude raw structured_output,
15
- * and the tool policy used to auto-answer permission requests for THIS session. */
16
+ * and the permission policy/resolver used to answer permission requests for THIS session. */
16
17
  declare class SessionState {
18
+ readonly cwd: string;
17
19
  readonly policy: ToolPolicy;
20
+ readonly permissionResolver?: PermissionResolver | undefined;
18
21
  readonly label?: string | undefined;
19
22
  readonly runId?: string | undefined;
23
+ private readonly retainSessionLog;
20
24
  readonly textChunks: string[];
21
25
  readonly history: AgentHistoryEntry[];
22
26
  readonly usage: UsageAccumulator;
27
+ readonly pendingPermissions: Set<(outcome: RequestPermissionResponse) => void>;
23
28
  rawResultSuccess: RawResultSuccess | undefined;
24
29
  private turnStartIndex;
25
30
  /** `label`/`runId` are carried here ONLY so the MultiplexClient can stamp them onto emitted
26
31
  * events as context — they never affect routing or the wire request. */
27
- constructor(policy: ToolPolicy, label?: string | undefined, runId?: string | undefined);
28
- /** Mark the start of a new turn so currentTurnText()/structured_output read only this turn. */
32
+ constructor(cwd: string, policy: ToolPolicy, permissionResolver?: PermissionResolver | undefined, label?: string | undefined, runId?: string | undefined, retainSessionLog?: boolean);
33
+ /** Mark the start of a new turn so currentTurnText()/structured_output read only this turn.
34
+ * Long-lived interactive sessions can opt out of retaining old text/history because hosts
35
+ * stream live events and keep their own transcript; clearing here prevents dead logs from
36
+ * growing for the lifetime of a held-open session. */
29
37
  beginTurn(): void;
30
38
  currentTurnText(): string;
31
39
  applyUpdate(update: SessionNotification["update"]): void;
32
40
  applyRawMessage(message: RawResultSuccess | undefined): void;
41
+ /** Settle every deferred permission still parked on this session. Used by release/cancel/death
42
+ * teardown so an interactive resolver can never strand an ACP prompt turn. */
43
+ settlePendingPermissions(): void;
33
44
  }
34
45
  export interface AcpSessionOptions {
35
46
  /** Absolute working directory for the ACP session (worktree isolation). */
@@ -37,6 +48,9 @@ export interface AcpSessionOptions {
37
48
  /** The schema for this run, if any (drives the backend's session/prompt `_meta`). */
38
49
  schema: TSchema | undefined;
39
50
  policy: ToolPolicy;
51
+ /** Session-scoped permission resolver. When present it wins over the runner default and
52
+ * replaces the synchronous ToolPolicy auto-response path for this session. */
53
+ permissionResolver?: PermissionResolver;
40
54
  signal?: AbortSignal;
41
55
  /** Client-provided MCP servers to attach at session/new. Omitted => `[]` (the default). */
42
56
  mcpServers?: McpServerConfig[];
@@ -53,6 +67,11 @@ export interface AcpSessionOptions {
53
67
  * (bare keys) for the codex-acp adapter; the Claude backend ignores them. Omitted => unset. */
54
68
  baseInstructions?: string;
55
69
  developerInstructions?: string;
70
+ /** Retain accumulated assistant text/tool history for the lifetime of this ACP session.
71
+ * Default true preserves run()'s diagnostic history contract. Held-open interactive sessions
72
+ * pass false because hosts stream live events / keep their own transcript; retaining old turns
73
+ * there is dead memory for day-long sessions. */
74
+ retainSessionLog?: boolean;
56
75
  }
57
76
  /** Notified by a PooledConnection when its process dies, so the pool can drop it. */
58
77
  export interface PooledConnectionDeps {
@@ -61,6 +80,10 @@ export interface PooledConnectionDeps {
61
80
  * session lifecycle change on this connection is bubbled up through it (additive observability;
62
81
  * it is invoked AFTER the drain accumulation and never affects the run). */
63
82
  onEvent?: AcpEventSink;
83
+ /** Runner-wide permission resolver default. SessionState.permissionResolver overrides it. */
84
+ permissionResolver?: PermissionResolver;
85
+ /** Client-side ACP fs/terminal handlers advertised once and routed by sessionId. */
86
+ clientHandlers?: ClientHandlers;
64
87
  }
65
88
  /**
66
89
  * One long-lived ACP server subprocess + its held ACP client connection. Initialized ONCE and
@@ -76,6 +99,7 @@ export declare class PooledConnection {
76
99
  private readonly client;
77
100
  private readonly onDead;
78
101
  private readonly onEvent;
102
+ private readonly clientHandlers;
79
103
  /** Set true at the start of dispose() so the graceful-shutdown death is NOT reported as a crash. */
80
104
  private disposing;
81
105
  /** Resolves once `initialize` completed (or rejects if the process died first). */
@@ -98,8 +122,8 @@ export declare class PooledConnection {
98
122
  /** The capabilities negotiated on this connection's one-time initialize handshake, or undefined
99
123
  * until it completes — derived-state-behind-a-getter, like `alive`/`activeSessions`. */
100
124
  get capabilities(): NegotiatedCapabilities | undefined;
101
- /** Drop the fork's custom bare `_meta` keys the connected agent did not advertise support for
102
- * (see gateCustomMeta). Applied to BOTH session/new and session/prompt `_meta`. */
125
+ /** Drop the backend-declared bare `_meta` keys the connected agent did not advertise support
126
+ * for (see gateCustomMeta). Applied to BOTH session/new and session/prompt `_meta`. */
103
127
  gateCustomMeta(meta: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
104
128
  /** Mark this connection dead exactly once, then ask the pool to evict it. Idempotent. */
105
129
  private die;
@@ -117,9 +141,9 @@ export declare class PooledConnection {
117
141
  /** Best-effort ACP cancel for one session (wired to opts.signal). The PROCESS stays pooled. */
118
142
  cancelSession(sessionId: string): Promise<void>;
119
143
  /**
120
- * Release a session: stop routing it, free the load slot, and best-effort session/close on the
121
- * wire (capability-gated, bounded, never fatal). The PROCESS is NOT killed — it returns to the
122
- * pool for the next agent() call.
144
+ * Release a session: move it to teardown-only routing, free the load slot, and best-effort
145
+ * session/close on the wire (capability-gated, bounded, never fatal). The PROCESS is NOT
146
+ * killed — it returns to the pool for the next agent() call.
123
147
  */
124
148
  releaseSession(sessionId: string): Promise<void>;
125
149
  /** Synchronous best-effort kill for a process-exit hook (no time to await a graceful close). */
@@ -134,7 +158,7 @@ export declare class PooledConnection {
134
158
  */
135
159
  export declare class SessionHandle implements StructuredSource {
136
160
  private readonly pooled;
137
- private readonly sessionId;
161
+ readonly sessionId: string;
138
162
  private readonly state;
139
163
  private readonly opts;
140
164
  private configOptions;
@@ -180,7 +204,7 @@ export declare class SessionHandle implements StructuredSource {
180
204
  /** Set one session config option via the wire method and adopt the echoed catalog. */
181
205
  private applyConfigOption;
182
206
  /** Send a prompt turn and drain it; returns the final PromptResponse. */
183
- prompt(text: string, promptMeta?: Record<string, unknown>): Promise<PromptResponse>;
207
+ prompt(content: string | ContentBlock[], promptMeta?: Record<string, unknown>): Promise<PromptResponse>;
184
208
  /** StructuredSource — the latest turn's assistant text. */
185
209
  currentTurnText(): string;
186
210
  /** StructuredSource — Claude's raw structured_output for the latest turn, if any. */
@@ -1 +1 @@
1
- {"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"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"}
@@ -22,12 +22,13 @@
22
22
  // other sessions' updates interleave on the same wire.
23
23
  import { spawn } from "node:child_process";
24
24
  import { Readable, Writable } from "node:stream";
25
- import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
25
+ import { ClientSideConnection, CLIENT_METHODS, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
26
26
  import { META_KEYS, WorkflowError, WorkflowErrorCode, } from "@automatalabs/shared-types";
27
- import { gateCustomMeta, isSupportedProtocolVersion, negotiateCapabilities, unsupportedMcpServer, } from "./capabilities.js";
27
+ import { adaptPromptContent, gateCustomMeta, isSupportedProtocolVersion, negotiateCapabilities, unsupportedMcpServer, } from "./capabilities.js";
28
28
  import { emitSessionUpdate } from "./events.js";
29
29
  import { decidePermission } from "./permissions.js";
30
30
  import { UsageAccumulator } from "./usage.js";
31
+ import { clientCapabilitiesFor, } from "./client-handlers.js";
31
32
  /** A benign client identity. NOT JetBrains/IntelliJ 2026.1 — that exact identity makes
32
33
  * codex-acp disable session config options (our model/effort routing channel). */
33
34
  const CLIENT_INFO = {
@@ -40,27 +41,45 @@ const CLAUDE_RAW_MESSAGE_METHOD = "_claude/sdkMessage";
40
41
  const CLOSE_SESSION_TIMEOUT_MS = 5_000;
41
42
  /** Bound the graceful SIGTERM shutdown before escalating to SIGKILL. */
42
43
  const DISPOSE_SIGKILL_GRACE_MS = 2_000;
44
+ const TOMBSTONE_SESSION_CAP = 64;
43
45
  /** Per-session accumulator: assistant text, tool history, usage, the Claude raw structured_output,
44
- * and the tool policy used to auto-answer permission requests for THIS session. */
46
+ * and the permission policy/resolver used to answer permission requests for THIS session. */
45
47
  class SessionState {
48
+ cwd;
46
49
  policy;
50
+ permissionResolver;
47
51
  label;
48
52
  runId;
53
+ retainSessionLog;
49
54
  textChunks = [];
50
55
  history = [];
51
56
  usage = new UsageAccumulator();
57
+ pendingPermissions = new Set();
52
58
  rawResultSuccess;
53
59
  turnStartIndex = 0;
54
60
  /** `label`/`runId` are carried here ONLY so the MultiplexClient can stamp them onto emitted
55
61
  * events as context — they never affect routing or the wire request. */
56
- constructor(policy, label, runId) {
62
+ constructor(cwd, policy, permissionResolver, label, runId, retainSessionLog = true) {
63
+ this.cwd = cwd;
57
64
  this.policy = policy;
65
+ this.permissionResolver = permissionResolver;
58
66
  this.label = label;
59
67
  this.runId = runId;
68
+ this.retainSessionLog = retainSessionLog;
60
69
  }
61
- /** Mark the start of a new turn so currentTurnText()/structured_output read only this turn. */
70
+ /** Mark the start of a new turn so currentTurnText()/structured_output read only this turn.
71
+ * Long-lived interactive sessions can opt out of retaining old text/history because hosts
72
+ * stream live events and keep their own transcript; clearing here prevents dead logs from
73
+ * growing for the lifetime of a held-open session. */
62
74
  beginTurn() {
63
- this.turnStartIndex = this.textChunks.length;
75
+ if (this.retainSessionLog) {
76
+ this.turnStartIndex = this.textChunks.length;
77
+ }
78
+ else {
79
+ this.textChunks.length = 0;
80
+ this.history.length = 0;
81
+ this.turnStartIndex = 0;
82
+ }
64
83
  this.rawResultSuccess = undefined;
65
84
  }
66
85
  currentTurnText() {
@@ -106,6 +125,12 @@ class SessionState {
106
125
  this.rawResultSuccess = message;
107
126
  }
108
127
  }
128
+ /** Settle every deferred permission still parked on this session. Used by release/cancel/death
129
+ * teardown so an interactive resolver can never strand an ACP prompt turn. */
130
+ settlePendingPermissions() {
131
+ for (const settle of [...this.pendingPermissions])
132
+ settle(cancelledPermissionResponse());
133
+ }
109
134
  }
110
135
  /** The single ACP Client handler for one pooled connection. It ROUTES every notification and
111
136
  * permission request to the per-session SessionState by `sessionId`, so one process can serve
@@ -113,39 +138,141 @@ class SessionState {
113
138
  class MultiplexClient {
114
139
  backendId;
115
140
  onEvent;
141
+ handlers;
142
+ permissionResolver;
116
143
  sessions = new Map();
144
+ /** Recently unregistered sessions kept ONLY for the teardown window: ACP agents may
145
+ * legitimately release terminals (or finish fs/terminal cleanup) after this client releases
146
+ * the session because session/close is cancel + free resources and the Agent owns terminal
147
+ * release. Store only the slim routing context and cap it FIFO so the memory cost is bounded. */
148
+ tombstones = new Map();
117
149
  /** `backendId` stamps event context; `onEvent` (optional) bubbles every notification, permission
118
- * request and session lifecycle change up to the runner's typed bus. */
119
- constructor(backendId, onEvent) {
150
+ * request and session lifecycle change up to the runner's typed bus. `permissionResolver` is
151
+ * the runner-wide default; a SessionState resolver wins when present. */
152
+ constructor(backendId, onEvent, handlers, permissionResolver) {
120
153
  this.backendId = backendId;
121
154
  this.onEvent = onEvent;
155
+ this.handlers = handlers;
156
+ this.permissionResolver = permissionResolver;
122
157
  }
123
158
  contextFor(sessionId, state) {
124
159
  return { sessionId, backendId: this.backendId, label: state?.label, runId: state?.runId };
125
160
  }
161
+ handlerContext(params) {
162
+ const state = this.sessions.get(params.sessionId);
163
+ if (state)
164
+ return { sessionId: params.sessionId, cwd: state.cwd, label: state.label, runId: state.runId };
165
+ const tombstone = this.tombstones.get(params.sessionId);
166
+ if (tombstone)
167
+ return { sessionId: params.sessionId, ...tombstone };
168
+ throw unknownSession(params.sessionId);
169
+ }
170
+ dispatch(params, wireMethod, handler) {
171
+ if (typeof handler !== "function")
172
+ throw methodNotAdvertised(wireMethod);
173
+ const ctx = this.handlerContext(params);
174
+ return handler(params, ctx);
175
+ }
126
176
  register(sessionId, state) {
177
+ this.tombstones.delete(sessionId);
127
178
  this.sessions.set(sessionId, state);
128
179
  this.onEvent?.("session_open", this.contextFor(sessionId, state));
129
180
  }
130
181
  unregister(sessionId) {
131
182
  const state = this.sessions.get(sessionId);
183
+ state?.settlePendingPermissions();
132
184
  this.sessions.delete(sessionId);
133
- if (state)
185
+ if (state) {
186
+ this.tombstones.set(sessionId, {
187
+ cwd: state.cwd,
188
+ label: state.label,
189
+ runId: state.runId,
190
+ });
191
+ while (this.tombstones.size > TOMBSTONE_SESSION_CAP) {
192
+ const oldest = this.tombstones.keys().next().value;
193
+ if (oldest === undefined)
194
+ break;
195
+ this.tombstones.delete(oldest);
196
+ }
134
197
  this.onEvent?.("session_close", this.contextFor(sessionId, state));
198
+ }
199
+ }
200
+ settlePendingPermissions(sessionId) {
201
+ this.sessions.get(sessionId)?.settlePendingPermissions();
202
+ }
203
+ settleAllPendingPermissions() {
204
+ for (const state of this.sessions.values()) {
205
+ state.settlePendingPermissions();
206
+ }
135
207
  }
136
208
  requestPermission(params) {
137
209
  const state = this.sessions.get(params.sessionId);
138
210
  // Unknown/closed session: refuse rather than silently allow a tool we can't attribute.
139
211
  if (!state)
140
- return { outcome: { outcome: "cancelled" } };
212
+ return cancelledPermissionResponse();
213
+ const ctx = this.contextFor(params.sessionId, state);
214
+ const resolver = state.permissionResolver ?? this.permissionResolver;
215
+ if (resolver)
216
+ return this.requestPermissionViaResolver(params, state, ctx, resolver);
141
217
  const outcome = decidePermission(params, state.policy);
142
218
  this.onEvent?.("permission_request", {
143
- ...this.contextFor(params.sessionId, state),
219
+ ...ctx,
144
220
  request: params,
145
221
  outcome,
146
222
  });
147
223
  return outcome;
148
224
  }
225
+ requestPermissionViaResolver(params, state, ctx, resolver) {
226
+ let settled = false;
227
+ let settle;
228
+ const response = new Promise((resolve) => {
229
+ settle = (outcome) => {
230
+ if (settled)
231
+ return;
232
+ settled = true;
233
+ state.pendingPermissions.delete(settle);
234
+ this.onEvent?.("permission_request", { ...ctx, request: params, outcome });
235
+ resolve(outcome);
236
+ };
237
+ state.pendingPermissions.add(settle);
238
+ this.onEvent?.("permission_pending", { ...ctx, request: params });
239
+ try {
240
+ Promise.resolve(resolver(params, ctx)).then((outcome) => {
241
+ settle(outcome);
242
+ }, () => {
243
+ // No session-scoped resolver-error event exists; the permission_request event still
244
+ // reports the FINAL outcome exactly once, so rejection is observable as cancellation.
245
+ settle(cancelledPermissionResponse());
246
+ });
247
+ }
248
+ catch {
249
+ settle(cancelledPermissionResponse());
250
+ }
251
+ });
252
+ response.catch(() => { });
253
+ return response;
254
+ }
255
+ readTextFile(params) {
256
+ return this.dispatch(params, CLIENT_METHODS.fs_read_text_file, this.handlers?.fs?.readTextFile);
257
+ }
258
+ writeTextFile(params) {
259
+ return this.dispatch(params, CLIENT_METHODS.fs_write_text_file, this.handlers?.fs?.writeTextFile);
260
+ }
261
+ createTerminal(params) {
262
+ return this.dispatch(params, CLIENT_METHODS.terminal_create, this.handlers?.terminal?.createTerminal);
263
+ }
264
+ terminalOutput(params) {
265
+ return this.dispatch(params, CLIENT_METHODS.terminal_output, this.handlers?.terminal?.terminalOutput);
266
+ }
267
+ releaseTerminal(params) {
268
+ return this.dispatch(params, CLIENT_METHODS.terminal_release, this.handlers?.terminal?.releaseTerminal);
269
+ }
270
+ waitForTerminalExit(params) {
271
+ return this.dispatch(params, CLIENT_METHODS.terminal_wait_for_exit, this.handlers?.terminal?.waitForTerminalExit);
272
+ }
273
+ killTerminal(params) {
274
+ return this.dispatch(params, CLIENT_METHODS.terminal_kill, this.handlers?.terminal?.killTerminal);
275
+ }
149
276
  sessionUpdate(params) {
150
277
  const state = this.sessions.get(params.sessionId);
151
278
  // Fold into the accumulator FIRST (the drain contract), THEN bubble the event up unchanged.
@@ -172,6 +299,15 @@ class MultiplexClient {
172
299
  });
173
300
  }
174
301
  }
302
+ function unknownSession(sessionId) {
303
+ return RequestError.invalidParams({ sessionId }, `unknown session: ${sessionId}`);
304
+ }
305
+ function cancelledPermissionResponse() {
306
+ return { outcome: { outcome: "cancelled" } };
307
+ }
308
+ function methodNotAdvertised(method) {
309
+ return new RequestError(-32601, `${method} was not advertised by this client`, { method });
310
+ }
175
311
  const DEFAULT_INIT_TIMEOUT_MS = 60_000;
176
312
  /** Deadline for the one-time ACP `initialize` handshake per pooled process. Overridable via
177
313
  * AGENTPRISM_ACP_INIT_TIMEOUT_MS (e.g. for slow cold-start backends). Clamped to >= 1s. */
@@ -225,6 +361,7 @@ export class PooledConnection {
225
361
  client;
226
362
  onDead;
227
363
  onEvent;
364
+ clientHandlers;
228
365
  /** Set true at the start of dispose() so the graceful-shutdown death is NOT reported as a crash. */
229
366
  disposing = false;
230
367
  /** Resolves once `initialize` completed (or rejects if the process died first). */
@@ -243,7 +380,8 @@ export class PooledConnection {
243
380
  this.backendId = backend.id;
244
381
  this.onDead = deps.onDead;
245
382
  this.onEvent = deps.onEvent;
246
- this.client = new MultiplexClient(this.backendId, this.onEvent);
383
+ this.clientHandlers = deps.clientHandlers;
384
+ this.client = new MultiplexClient(this.backendId, this.onEvent, deps.clientHandlers, deps.permissionResolver);
247
385
  const { command, args, env } = backend.spawnConfig();
248
386
  // NOTE: deliberately NO `cwd` here. cwd is per-SESSION (session/new), so one pooled process
249
387
  // serves runs in different worktrees without losing isolation.
@@ -296,10 +434,10 @@ export class PooledConnection {
296
434
  get capabilities() {
297
435
  return this.negotiated;
298
436
  }
299
- /** Drop the fork's custom bare `_meta` keys the connected agent did not advertise support for
300
- * (see gateCustomMeta). Applied to BOTH session/new and session/prompt `_meta`. */
437
+ /** Drop the backend-declared bare `_meta` keys the connected agent did not advertise support
438
+ * for (see gateCustomMeta). Applied to BOTH session/new and session/prompt `_meta`. */
301
439
  gateCustomMeta(meta) {
302
- return gateCustomMeta(meta, this.negotiated?.customMetaSupport);
440
+ return gateCustomMeta(meta, this.negotiated?.customMetaSupport, this.negotiated?.gatedKeys);
303
441
  }
304
442
  /** Mark this connection dead exactly once, then ask the pool to evict it. Idempotent. */
305
443
  die(error) {
@@ -308,6 +446,7 @@ export class PooledConnection {
308
446
  this._alive = false;
309
447
  this.deathError = error;
310
448
  this.resolveDead();
449
+ this.client.settleAllPendingPermissions();
311
450
  // A crash (not a graceful dispose) is worth surfacing for observability; the engine still
312
451
  // handles it by retrying the run on a fresh process. Best-effort, after death is recorded.
313
452
  if (this.onEvent && !this.disposing) {
@@ -349,15 +488,14 @@ export class PooledConnection {
349
488
  const response = await Promise.race([
350
489
  this.race(this.rpc.initialize({
351
490
  protocolVersion: PROTOCOL_VERSION,
352
- // Truthful advertisement: MultiplexClient implements NONE of the client-side
353
- // fs/terminal methods, so we advertise none (the ACP rule is "advertise only what you
354
- // implement"). Everything omitted here is treated as unsupported by the agent.
355
- clientCapabilities: {},
491
+ // Truthful advertisement: computed from the consumer-provided handlers registered on
492
+ // this runner. Omitted flags are unsupported; false flags are never sent deliberately.
493
+ clientCapabilities: clientCapabilitiesFor(this.clientHandlers),
356
494
  clientInfo: { ...CLIENT_INFO },
357
495
  })),
358
496
  deadline,
359
497
  ]);
360
- const negotiated = negotiateCapabilities(response);
498
+ const negotiated = negotiateCapabilities(response, this.backend.customCapabilities);
361
499
  // Version negotiation: the agent replies with the version it chose (our requested version if
362
500
  // it supports it, else its own latest). If this client cannot speak it, the ACP spec says
363
501
  // CLOSE the connection and inform the user — kill the process (so the pool evicts it) and
@@ -395,13 +533,13 @@ export class PooledConnection {
395
533
  throw new WorkflowError(`MCP server "${unsupported.name}" uses the "${unsupported.transport}" transport, which the ` +
396
534
  `${this.backendId} agent does not support`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: opts.label });
397
535
  }
398
- const state = new SessionState(opts.policy, opts.label, opts.runId);
536
+ const state = new SessionState(opts.cwd, opts.policy, opts.permissionResolver, opts.label, opts.runId, opts.retainSessionLog ?? true);
399
537
  // session/new `_meta`, layered lowest-to-highest precedence: the backend's static
400
538
  // defaults (a custom registry entry's `sessionMeta`), then the generic user passthrough
401
539
  // (opts.meta), then the backend's protocol-critical `_meta` (Claude schema channel;
402
540
  // Codex base/developer instructions), then the engine runId correlation stamp. The result
403
- // is gated against the agent's advertised custom capabilities (a Codex instruction key the
404
- // agent said it does not honor is dropped). When no layer survives, no `_meta` is sent.
541
+ // is gated against the agent's advertised custom capabilities (a declared key the agent
542
+ // said it does not honor is dropped). When no layer survives, no `_meta` is sent.
405
543
  const meta = this.gateCustomMeta(stampRunId(layerMeta(this.backend.sessionMetaDefaults?.(), opts.meta, this.backend.sessionMeta(opts.schema, {
406
544
  baseInstructions: opts.baseInstructions,
407
545
  developerInstructions: opts.developerInstructions,
@@ -423,6 +561,7 @@ export class PooledConnection {
423
561
  }
424
562
  /** Best-effort ACP cancel for one session (wired to opts.signal). The PROCESS stays pooled. */
425
563
  async cancelSession(sessionId) {
564
+ this.client.settlePendingPermissions(sessionId);
426
565
  if (!this._alive)
427
566
  return;
428
567
  try {
@@ -433,9 +572,9 @@ export class PooledConnection {
433
572
  }
434
573
  }
435
574
  /**
436
- * Release a session: stop routing it, free the load slot, and best-effort session/close on the
437
- * wire (capability-gated, bounded, never fatal). The PROCESS is NOT killed — it returns to the
438
- * pool for the next agent() call.
575
+ * Release a session: move it to teardown-only routing, free the load slot, and best-effort
576
+ * session/close on the wire (capability-gated, bounded, never fatal). The PROCESS is NOT
577
+ * killed — it returns to the pool for the next agent() call.
439
578
  */
440
579
  async releaseSession(sessionId) {
441
580
  this.client.unregister(sessionId);
@@ -623,16 +762,18 @@ export class SessionHandle {
623
762
  this.configOptions = response.configOptions;
624
763
  }
625
764
  /** Send a prompt turn and drain it; returns the final PromptResponse. */
626
- async prompt(text, promptMeta) {
765
+ async prompt(content, promptMeta) {
627
766
  this.opts.signal?.throwIfAborted();
628
767
  this.state.beginTurn();
629
- const prompt = [{ type: "text", text }];
630
- // Gate the turn `_meta` against the agent's advertised custom capabilities: the Codex
631
- // outputSchema forward is dropped when the connected agent said it does not honor it.
768
+ const prompt = typeof content === "string"
769
+ ? content
770
+ : adaptPromptContent(content, this.pooled.capabilities?.agent ?? {}, this.pooled.backendId);
771
+ // Gate the turn `_meta` against the agent's advertised custom capabilities: a declared
772
+ // turn-level key is dropped when the connected agent said it does not honor it.
632
773
  const gatedMeta = this.pooled.gateCustomMeta(promptMeta);
633
774
  const request = {
634
775
  sessionId: this.sessionId,
635
- prompt,
776
+ prompt: typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt,
636
777
  ...(gatedMeta ? { _meta: gatedMeta } : {}),
637
778
  };
638
779
  const response = await this.pooled.race(this.pooled.rpc.prompt(request));