@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 +76 -6
- package/dist/acp-client.d.ts +36 -12
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +172 -31
- package/dist/backend.d.ts +7 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/codex.d.ts +4 -0
- package/dist/backends/codex.d.ts.map +1 -1
- package/dist/backends/codex.js +6 -1
- package/dist/backends/custom.d.ts +1 -0
- package/dist/backends/custom.d.ts.map +1 -1
- package/dist/backends/custom.js +3 -0
- package/dist/capabilities.d.ts +18 -7
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +80 -22
- package/dist/client-handlers.d.ts +27 -0
- package/dist/client-handlers.d.ts.map +1 -0
- package/dist/client-handlers.js +39 -0
- package/dist/events.d.ts +13 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/interactive.d.ts +114 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +139 -0
- package/dist/permissions.d.ts +9 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pool.d.ts +7 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +6 -0
- package/dist/prompt.d.ts +21 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +75 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +19 -0
- package/dist/runner.d.ts +41 -9
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +165 -60
- package/package.json +2 -2
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. |
|
package/dist/acp-client.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
121
|
-
* wire (capability-gated, bounded, never fatal). The PROCESS is NOT
|
|
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
|
-
|
|
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(
|
|
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. */
|
package/dist/acp-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"AAwBA,OAAO,EACL,oBAAoB,
|
|
1
|
+
{"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"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"}
|
package/dist/acp-client.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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.
|
|
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
|
|
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:
|
|
353
|
-
//
|
|
354
|
-
|
|
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
|
|
404
|
-
//
|
|
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:
|
|
437
|
-
* wire (capability-gated, bounded, never fatal). The PROCESS is NOT
|
|
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(
|
|
765
|
+
async prompt(content, promptMeta) {
|
|
627
766
|
this.opts.signal?.throwIfAborted();
|
|
628
767
|
this.state.beginTurn();
|
|
629
|
-
const prompt =
|
|
630
|
-
|
|
631
|
-
|
|
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));
|