toolnexus 0.24.0 → 0.25.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.
Files changed (55) hide show
  1. package/README.md +116 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/client.d.ts +32 -8
  5. package/dist/client.js +102 -23
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/mcp.d.ts +32 -7
  9. package/dist/mcp.js +125 -36
  10. package/dist/mcp2026/auth.d.ts +137 -0
  11. package/dist/mcp2026/auth.js +446 -0
  12. package/dist/mcp2026/client.d.ts +48 -0
  13. package/dist/mcp2026/client.js +160 -0
  14. package/dist/mcp2026/protocol.d.ts +54 -0
  15. package/dist/mcp2026/protocol.js +113 -0
  16. package/dist/mcp2026/skills.d.ts +5 -0
  17. package/dist/mcp2026/skills.js +25 -0
  18. package/dist/mcp2026/source.d.ts +86 -0
  19. package/dist/mcp2026/source.js +372 -0
  20. package/dist/mcp2026/stdio.d.ts +24 -0
  21. package/dist/mcp2026/stdio.js +99 -0
  22. package/dist/mcp2026/toolsearch.d.ts +5 -0
  23. package/dist/mcp2026/toolsearch.js +58 -0
  24. package/dist/mcphandler.d.ts +169 -0
  25. package/dist/mcphandler.js +1145 -0
  26. package/dist/mcpheader.d.ts +5 -0
  27. package/dist/mcpheader.js +18 -0
  28. package/dist/mcpserve.d.ts +24 -15
  29. package/dist/mcpserve.js +105 -2
  30. package/dist/mcpserverauth.d.ts +48 -0
  31. package/dist/mcpserverauth.js +203 -0
  32. package/dist/skill.d.ts +21 -0
  33. package/dist/skill.js +44 -0
  34. package/dist/toolkit.d.ts +20 -1
  35. package/dist/toolkit.js +25 -3
  36. package/dist/types.d.ts +22 -0
  37. package/package.json +4 -3
  38. package/src/acp.ts +258 -55
  39. package/src/client.ts +124 -27
  40. package/src/index.ts +2 -0
  41. package/src/mcp.ts +162 -43
  42. package/src/mcp2026/auth.ts +473 -0
  43. package/src/mcp2026/client.ts +161 -0
  44. package/src/mcp2026/protocol.ts +135 -0
  45. package/src/mcp2026/skills.ts +22 -0
  46. package/src/mcp2026/source.ts +407 -0
  47. package/src/mcp2026/stdio.ts +102 -0
  48. package/src/mcp2026/toolsearch.ts +56 -0
  49. package/src/mcphandler.ts +1252 -0
  50. package/src/mcpheader.ts +19 -0
  51. package/src/mcpserve.ts +115 -2
  52. package/src/mcpserverauth.ts +216 -0
  53. package/src/skill.ts +50 -0
  54. package/src/toolkit.ts +31 -4
  55. package/src/types.ts +23 -0
package/README.md CHANGED
@@ -93,7 +93,8 @@ tool. Runnable: `npm run example:prompt-cache` ([`examples/prompt_cache.ts`](exa
93
93
  - `agents.compactor({ onCompact })`, `agent.asToolWith(ctx => ({ hooks, waitFor }))`, runtime
94
94
  `waitFor`, `ctx.callId` on tools, `listModels(opts)`.
95
95
 
96
- ACP host hooks (permission choice, update stream, cancel, streaming) are Go-only for now.
96
+ An in-process model streams for real when it is given as `generateStream(request, emit)`; a plain
97
+ `generate` still refuses `stream()`.
97
98
 
98
99
  ## Simple judgments
99
100
 
@@ -158,6 +159,120 @@ const res = await agent.run("Draw a fox, then animate it.", { toolkit: tk })
158
159
  for (const f of res.files) console.log(f.from, f.mimeType, f.url)
159
160
  ```
160
161
 
162
+ ## MCP 2026-07-28 + OAuth
163
+
164
+ `loadMcp` / `Toolkit.create({ mcpConfig })` now speak MCP **2026-07-28** first, with no MCP SDK on
165
+ that path: every server (stdio and HTTP) gets `server/discover` before anything else. A pre-2026
166
+ server answers it with an error, a 400/404/405, or (stdio) silence for `min(timeout, 5 s)`, and is
167
+ then served by the unchanged SDK path. Tool names, result mapping, status, `tools` filters and
168
+ `disableTools` are the same on both paths.
169
+
170
+ ```ts
171
+ const toolkit = await Toolkit.create({
172
+ mcpConfig: {
173
+ mcpServers: {
174
+ docs: { url: "https://mcp.example.com/mcp", auth: { clientId: "my-registered-app" } },
175
+ big: { command: ["my-server"], toolSearch: true, description: "the billing API" },
176
+ },
177
+ },
178
+ waitFor, // answers MRTR input requests and `pause`-mode authorization
179
+ tokenStore, // { load(resource), save(resource, token), delete(resource) }; default in memory
180
+ openUrl: (url) => open(url), // how `auto` OAuth opens the browser; default: the OS opener
181
+ })
182
+ ```
183
+
184
+ - **MRTR** (`input_required`) goes through `waitFor` (form ⇒ `kind:"input"` with `data.schema`,
185
+ URL ⇒ `kind:"authorization"`). Without `waitFor` the tool returns a §10 pending result; the
186
+ resumed call (with `ctx.answer`) sends that round once and is never re-asked.
187
+ - **OAuth 2.1** on a 401: protected-resource metadata → AS metadata → a pre-registered `clientId`
188
+ (`clientIds` per issuer, optional `clientSecret`) or a Client ID Metadata Document
189
+ (`clientMetadataUrl`). **No dynamic client registration.** PKCE S256 + `resource`, `iss`/`state`
190
+ checks, refresh (single-flight; `omitRefreshResource` for Entra) and step-up on
191
+ `insufficient_scope`. `mode: "auto"` (loopback callback + `openUrl`) or `mode: "pause"` (needs
192
+ `redirectUri`; answer the `authorization` request with `data: { callbackUrl }`). Tokens are never
193
+ logged or put in error text.
194
+ - **Old servers + OAuth:** the bearer is attached to the SDK transport through its `fetch` hook,
195
+ and a 401 there runs the same flow once.
196
+ - **Skills over MCP:** a server advertising the skills extension contributes its skills to the
197
+ `skill` tool (base `skill://<name>/`).
198
+ - **Tool search:** `toolSearch: true` replaces a server's tools with `<server>_search_tools` and
199
+ `<server>_call_tool`.
200
+ - A failed `tools/call` (timeout, 5xx, dropped connection) is never re-sent.
201
+
202
+ ## Embeddable MCP server (`mcpHandler`)
203
+
204
+ `serve(addr, { mcp })` owns a port and exposes one static tool list. `toolkit.mcpHandler(cfg)`
205
+ returns a plain Node request listener `(req, res) => void` (plus `notifyToolsChanged()`) that you
206
+ mount on **your** server or router, behind your own TLS, CORS and OAuth. Every policy is a hook:
207
+ `authenticate` (+ `wwwAuthenticate` for the 401), `listTools` per request (a name outside it is never
208
+ executed), `beforeCall` allow / deny / confirm (confirm uses MCP elicitation when the client supports
209
+ it, otherwise returns your fallback `result`), `onCall` telemetry, `serverInfo`, resources/prompts,
210
+ pluggable `sessionIds`, `stateless`, `protocolVersions`. Hooks and tools read the caller with
211
+ `identityFrom(ctx)`, `requestFrom(ctx)`, `mcpSessionIdFrom(ctx)` and `mcpRequestMetaFrom(ctx)`.
212
+ `ToolResult.structured` / `meta` and `Tool.outputSchema` / `annotations` go on the wire as
213
+ `structuredContent` / `_meta` / `outputSchema` / `annotations`. SPEC §7C.1; runnable:
214
+ `npm run example:mcp-handler`.
215
+
216
+ ```ts
217
+ import http from "node:http"
218
+ import { createToolkit, identityFrom } from "toolnexus"
219
+
220
+ const tk = await createToolkit({ builtins: false })
221
+ const mcp = tk.mcpHandler({
222
+ authenticate: (req) => verifyBearer(req.headers.authorization), // throw ⇒ 401
223
+ listTools: (ctx) => toolsFor(identityFrom(ctx)),
224
+ beforeCall: (_ctx, tool) =>
225
+ tool.annotations?.destructiveHint ? { kind: "confirm", message: `Run ${tool.name}?` } : { kind: "allow" },
226
+ })
227
+ http.createServer((req, res) => (req.url?.startsWith("/mcp") ? mcp(req, res) : res.writeHead(404).end())).listen(8080)
228
+ ```
229
+
230
+ ### MCP 2026-07-28 (opt-in)
231
+
232
+ List `"2026-07-28"` in `protocolVersions` to serve the stateless 2026 wire (`server/discover`,
233
+ header validation, `resultType`, MRTR): `["2026-07-28"]` alone, or `["2026-07-28", "2025-06-18"]` for
234
+ both wires on one mount. It is never a default and `initialize` never negotiates it. A `confirm`
235
+ decision and a tool suspension (§10 `pending(...)`) leave as `input_required`; the client's retry
236
+ reaches the tool as `ctx.answer`. `listSkills` publishes skills through the skills extension
237
+ (`skills/list`, `skills/get`, `resources/read` of `skill://` URIs); `loadMcpSkills(dir)` builds that
238
+ list from a skills folder. `McpServerAuth` makes the handler an OAuth protected resource: its
239
+ `authenticate` verifies RS256 JWTs against the issuer's JWKS (or your `verify`), applies per-tool
240
+ `scopes`, answers with exact `WWW-Authenticate` challenges, and returns an `McpPrincipal`;
241
+ `metadata()` serves the RFC 9728 document. Bring your own authorization server; the
242
+ auto-approving dev authorization server the suite uses is test tooling (`test/support`), not part of
243
+ the package. SPEC §7C.2.
244
+
245
+ ```ts
246
+ import { McpServerAuth, loadMcpSkills } from "toolnexus"
247
+
248
+ const auth = new McpServerAuth({ issuer: "https://login.example", scopes: { "*": ["mcp"], wipe: ["mcp:admin"] } })
249
+ const mcp = tk.mcpHandler({
250
+ protocolVersions: ["2026-07-28", "2025-06-18"],
251
+ authenticate: auth.authenticate,
252
+ listSkills: () => loadMcpSkills("./skills"),
253
+ })
254
+ const prm = auth.metadata() // mount at /.well-known/oauth-protected-resource[/mcp]
255
+ ```
256
+
257
+ ## Agents via ACP
258
+
259
+ toolnexus ships no per-agent client. Three vendor-neutral pieces compose (SPEC §8C):
260
+ `toolkit.asMcpServer()` serves the toolkit on `127.0.0.1` behind a bearer token (`{url, token,
261
+ close}`); `loadACP({command, args, mcpServers, sessionMeta, clientCapsMeta, model})` drives any
262
+ ACP agent, passing `mcpServers` verbatim; and its `generateStream` plugs into
263
+ `createInProcessClient({ generateStream })` for incremental `stream()` (text, `thought`, and the
264
+ agent's own `tool_call`/`tool_result` with `via: "acp"`).
265
+
266
+ ```ts
267
+ const srv = await tk.asMcpServer()
268
+ const acp = await loadACP({
269
+ command: "my-acp-agent",
270
+ mcpServers: [{ type: "http", name: "toolnexus", url: srv.url,
271
+ headers: [{ name: "Authorization", value: `Bearer ${srv.token}` }] }],
272
+ })
273
+ const client = createInProcessClient({ generate: acp.generate, generateStream: acp.generateStream })
274
+ ```
275
+
161
276
  ## Documentation
162
277
 
163
278
  Everything else — the full surface, with runnable examples — lives on the docs site:
package/dist/acp.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { InProcessRequest, InProcessResponse } from "./client.js";
1
+ import type { InProcessRequest, InProcessResponse, StreamEvent } from "./client.js";
2
2
  /** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
3
3
  * process; everything else tunes the session handshake. */
4
4
  export interface ACPOptions {
5
- /** The ACP agent binary, e.g. `"devin"`. */
5
+ /** The ACP agent binary. */
6
6
  command: string;
7
- /** Arguments to the agent, e.g. `["acp"]`. */
7
+ /** Arguments to the agent, passed untouched. */
8
8
  args?: string[];
9
- /** Working directory for `session/new`. Real ACP agents (e.g. `devin acp`)
9
+ /** Working directory for `session/new`. Real ACP agents
10
10
  * reject a relative `cwd` with `-32602 Invalid params` — this is always
11
11
  * resolved to an absolute path before it is sent. Default: `process.cwd()`. */
12
12
  cwd?: string;
@@ -22,19 +22,50 @@ export interface ACPOptions {
22
22
  permissionTimeoutMs?: number;
23
23
  /** Optional `session/set_mode` sent once, right after `session/new`. */
24
24
  mode?: string;
25
+ /** Sent as `session/new` `mcpServers` VERBATIM and in order (absent ⇒ `[]`): toolnexus does
26
+ * not validate, rename, reshape or pick between the http/stdio forms (SPEC §8C). */
27
+ mcpServers?: Record<string, unknown>[];
28
+ /** Sent as `session/new` `_meta`; the key is absent when unset. Opaque to toolnexus. */
29
+ sessionMeta?: Record<string, unknown>;
30
+ /** Sent as `initialize` `clientCapabilities._meta` (vendor flags). Opaque to toolnexus. */
31
+ clientCapsMeta?: Record<string, unknown>;
32
+ /** Passed through unchanged after `session/new`: `session/set_config_option`
33
+ * `{configId:"model"}`, then `session/set_model` if the agent rejects that. Both failing
34
+ * fails `loadACP`. */
35
+ model?: string;
36
+ /** Receives the agent's stderr (default: discarded). */
37
+ stderr?: (chunk: string) => void;
38
+ /** Decides each `session/request_permission`: return an `optionId` to select it, or
39
+ * `""`/undefined to answer with the first `reject*` option. Absent ⇒ the first `allow*`
40
+ * option, as before. */
41
+ onPermission?: (params: any) => string | undefined | Promise<string | undefined>;
42
+ /** Observes every raw `session/update` params object (must not throw). */
43
+ onUpdate?: (params: any) => void;
44
+ /** Bounds each handshake request (`initialize`, `session/new`, …). Absent ⇒ unbounded;
45
+ * a request still fails at once when the agent exits. */
46
+ requestTimeoutMs?: number;
25
47
  }
26
48
  /** One warm ACP connection: one child process, one session, alive across many
27
49
  * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
28
50
  * wants, so it plugs straight into {@link createInProcessClient}. */
29
51
  export interface ACPClient {
30
52
  generate: (request: InProcessRequest) => Promise<InProcessResponse>;
31
- /** Terminates the agent process. Idempotent — a second call is a no-op. */
53
+ /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
54
+ * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
55
+ * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
56
+ * (both via "acp": the agent's own tools, reported, never executed by the loop).
57
+ * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
58
+ * message text only. */
59
+ generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>;
60
+ /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
32
61
  close: () => Promise<void>;
62
+ /** The agent's raw `initialize` result. */
63
+ readonly initializeResult: any;
33
64
  }
34
65
  /**
35
66
  * Opens one ACP connection: spawns `command`, performs `initialize` →
36
67
  * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
37
- * trap real `devin acp` enforces with `-32602` otherwise) → an optional
68
+ * trap real agents enforce with `-32602` otherwise) → an optional
38
69
  * `session/set_mode`, then returns a client whose `.generate` sends exactly
39
70
  * one `session/prompt` per call, accumulating only `agent_message_chunk`
40
71
  * text.
package/dist/acp.js CHANGED
@@ -4,8 +4,7 @@
4
4
  * openspec/changes/add-acp-model-source.
5
5
  *
6
6
  * ACP is to *agents* what MCP is to *tools*: JSON-RPC 2.0, one object per line,
7
- * over a child process's stdin/stdout (`devin acp`, Gemini CLI, Zed's agents all
8
- * speak it). This file ships ACP as nothing more than a `generate` function —
7
+ * over a child process's stdin/stdout. This file ships ACP as nothing more than a `generate` function —
9
8
  * the exact shape {@link InProcessOptions.generate} already defines — so the
10
9
  * tool-calling loop, skills, MCP tools, adapters and sub-agents are entirely
11
10
  * untouched. `loadACP` opens the connection once (`initialize` → `session/new` →
@@ -81,7 +80,7 @@ function renderPrompt(request) {
81
80
  /**
82
81
  * Opens one ACP connection: spawns `command`, performs `initialize` →
83
82
  * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
84
- * trap real `devin acp` enforces with `-32602` otherwise) → an optional
83
+ * trap real agents enforce with `-32602` otherwise) → an optional
85
84
  * `session/set_mode`, then returns a client whose `.generate` sends exactly
86
85
  * one `session/prompt` per call, accumulating only `agent_message_chunk`
87
86
  * text.
@@ -89,16 +88,56 @@ function renderPrompt(request) {
89
88
  export async function loadACP(opts) {
90
89
  const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
91
90
  const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000;
91
+ // Its own process group (detached), so close() reaches an adapter's children too — an
92
+ // `npx …` launch is a tree, not one process.
92
93
  const child = spawn(opts.command, opts.args ?? [], {
93
94
  cwd,
94
95
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
95
- stdio: ["pipe", "pipe", "ignore"],
96
+ stdio: ["pipe", "pipe", opts.stderr ? "pipe" : "ignore"],
97
+ detached: process.platform !== "win32",
96
98
  });
99
+ if (opts.stderr) {
100
+ child.stderr.setEncoding("utf8");
101
+ child.stderr.on("data", (d) => {
102
+ try {
103
+ opts.stderr(d);
104
+ }
105
+ catch {
106
+ // a host sink never breaks the session
107
+ }
108
+ });
109
+ }
97
110
  let closed = false;
98
111
  let nextId = 0;
99
112
  const pending = new Map();
100
113
  let updateSink = null;
114
+ let exited = false;
115
+ let startError;
116
+ const childGone = new Promise((resolve) => {
117
+ child.once("exit", () => resolve());
118
+ child.once("error", () => resolve());
119
+ });
120
+ // Fail fast: a request still waiting when the agent's stdout closes fails at once
121
+ // instead of waiting out a timeout. setImmediate lets a spawn `error` land first.
122
+ const failPending = () => {
123
+ exited = true;
124
+ for (const [id, p] of pending) {
125
+ pending.delete(id);
126
+ p.cb({ error: { code: 0, message: exitMessage(p.method) } });
127
+ }
128
+ };
129
+ const exitMessage = (method) => startError
130
+ ? `toolnexus: acp: start ${JSON.stringify(opts.command)}: ${startError.message}`
131
+ : `toolnexus: acp: agent exited before replying to ${method}`;
132
+ child.on("error", (e) => {
133
+ startError = e;
134
+ setImmediate(failPending);
135
+ });
136
+ child.stdin.on("error", () => {
137
+ // EPIPE after the agent died: surfaced through failPending, never as a crash.
138
+ });
101
139
  const rl = createInterface({ input: child.stdout });
140
+ rl.on("close", () => setImmediate(failPending));
102
141
  rl.on("line", (raw) => {
103
142
  const line = raw.trim();
104
143
  if (!line)
@@ -113,6 +152,8 @@ export async function loadACP(opts) {
113
152
  handleLine(msg);
114
153
  });
115
154
  function write(msg) {
155
+ if (exited || child.stdin.destroyed)
156
+ return;
116
157
  child.stdin.write(JSON.stringify(msg) + "\n");
117
158
  }
118
159
  function reply(id, result) {
@@ -121,12 +162,30 @@ export async function loadACP(opts) {
121
162
  function call(method, params) {
122
163
  const id = `c-${++nextId}`;
123
164
  return new Promise((resolve, reject) => {
124
- pending.set(id, (msg) => {
125
- if (msg.error)
126
- reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`));
127
- else
128
- resolve(msg.result);
165
+ if (exited) {
166
+ reject(new Error(exitMessage(method)));
167
+ return;
168
+ }
169
+ let timer;
170
+ pending.set(id, {
171
+ method,
172
+ cb: (msg) => {
173
+ clearTimeout(timer);
174
+ if (msg.error && msg.error.code === 0)
175
+ reject(new Error(msg.error.message));
176
+ else if (msg.error)
177
+ reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`));
178
+ else
179
+ resolve(msg.result);
180
+ },
129
181
  });
182
+ if (opts.requestTimeoutMs && opts.requestTimeoutMs > 0) {
183
+ const ms = opts.requestTimeoutMs;
184
+ timer = setTimeout(() => {
185
+ pending.delete(id);
186
+ reject(new Error(`toolnexus: acp: ${method} timed out after ${ms}ms`));
187
+ }, ms);
188
+ }
130
189
  write({ jsonrpc: "2.0", id, method, params });
131
190
  });
132
191
  }
@@ -136,66 +195,167 @@ export async function loadACP(opts) {
136
195
  * server-initiated requests like `session/request_permission`. */
137
196
  function handleLine(msg) {
138
197
  if (msg.method === undefined && msg.id !== undefined) {
139
- const cb = pending.get(String(msg.id));
140
- if (cb) {
198
+ const p = pending.get(String(msg.id));
199
+ if (p) {
141
200
  pending.delete(String(msg.id));
142
- cb(msg);
201
+ p.cb(msg);
143
202
  }
144
203
  return;
145
204
  }
146
205
  if (msg.method === "session/update") {
206
+ if (opts.onUpdate) {
207
+ try {
208
+ opts.onUpdate(msg.params);
209
+ }
210
+ catch {
211
+ // an observer never breaks the session
212
+ }
213
+ }
147
214
  updateSink?.(msg.params);
148
215
  return;
149
216
  }
150
217
  if (msg.method === "session/request_permission" && msg.id !== undefined) {
151
- answerPermission(msg);
218
+ void answerPermission(msg);
152
219
  return;
153
220
  }
154
- // An unhandled server->client request still needs a reply or a
155
- // strict agent may itself hang waiting on one.
156
- if (msg.id !== undefined)
157
- reply(msg.id, {});
221
+ // An agent->client REQUEST we do not implement (fs/*, terminal/* — declared
222
+ // unsupported in clientCapabilities). Answer it, or the agent waits forever.
223
+ if (msg.id !== undefined) {
224
+ write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `method not found: ${msg.method}` } });
225
+ }
158
226
  }
159
227
  /** Answers with the FIRST option whose `kind` starts with `allow` — an
160
228
  * unanswered permission request hangs the turn forever (proven in
161
229
  * spikes/acp). This runs the instant the request arrives, never waiting
162
230
  * for anything else. */
163
- function answerPermission(req) {
231
+ async function answerPermission(req) {
164
232
  const options = req.params?.options ?? [];
165
- const chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
233
+ let chosen;
234
+ if (opts.onPermission) {
235
+ let picked;
236
+ try {
237
+ picked = await opts.onPermission(req.params);
238
+ }
239
+ catch {
240
+ picked = undefined;
241
+ }
242
+ chosen = picked
243
+ ? { optionId: picked }
244
+ : options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
245
+ }
246
+ else {
247
+ chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
248
+ }
166
249
  const result = chosen
167
250
  ? { outcome: { outcome: "selected", optionId: chosen.optionId } }
168
251
  : { outcome: { outcome: "cancelled" } };
169
252
  reply(req.id, result);
170
253
  }
171
- await call("initialize", {
172
- protocolVersion: opts.protocolVersion ?? 1,
173
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
174
- });
175
- const sessionResult = await call("session/new", { cwd, mcpServers: [] });
254
+ const clientCapabilities = {
255
+ fs: { readTextFile: false, writeTextFile: false },
256
+ terminal: false,
257
+ };
258
+ if (opts.clientCapsMeta)
259
+ clientCapabilities._meta = opts.clientCapsMeta;
260
+ const step = async (label, fn) => {
261
+ try {
262
+ return await fn();
263
+ }
264
+ catch (e) {
265
+ await close();
266
+ const m = e instanceof Error ? e.message : String(e);
267
+ throw new Error(startError ? m : `toolnexus: acp: ${label}: ${m}`);
268
+ }
269
+ };
270
+ const initializeResult = await step("initialize", () => call("initialize", { protocolVersion: opts.protocolVersion ?? 1, clientCapabilities }));
271
+ const sessionParams = { cwd, mcpServers: opts.mcpServers ?? [] };
272
+ if (opts.sessionMeta)
273
+ sessionParams._meta = opts.sessionMeta;
274
+ const sessionResult = await step("session/new", () => call("session/new", sessionParams));
176
275
  const sessionId = sessionResult?.sessionId;
177
276
  if (opts.mode) {
178
- await call("session/set_mode", { sessionId, modeId: opts.mode });
277
+ await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }));
278
+ }
279
+ if (opts.model) {
280
+ // Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
281
+ const model = opts.model;
282
+ await step(`set model ${JSON.stringify(model)}`, async () => {
283
+ try {
284
+ await call("session/set_config_option", { sessionId, configId: "model", value: model });
285
+ }
286
+ catch (e1) {
287
+ try {
288
+ await call("session/set_model", { sessionId, modelId: model });
289
+ }
290
+ catch (e2) {
291
+ throw new Error(`${e1.message}; ${e2.message}`);
292
+ }
293
+ }
294
+ });
179
295
  }
180
296
  // Serialises turns: one ACP session is one conversation, so concurrent
181
297
  // prompts must not interleave into it. Idiomatic promise-chaining queue —
182
298
  // each generate() call attaches after whatever is already queued, and the
183
299
  // queue itself never rejects (a failed turn does not poison later ones).
184
300
  let turnQueue = Promise.resolve();
185
- async function promptOnce(request) {
301
+ async function promptOnce(request, emit) {
186
302
  const text = renderPrompt(request);
187
303
  const id = `c-${++nextId}`;
188
304
  let content = "";
305
+ const toolNames = new Map();
306
+ const send = (ev) => {
307
+ try {
308
+ emit?.(ev);
309
+ }
310
+ catch {
311
+ // a consumer never breaks the session
312
+ }
313
+ };
314
+ // Runs from the stdout read loop, so every event is emitted the moment its
315
+ // notification is processed — never buffered to the end of the turn.
189
316
  updateSink = (params) => {
190
- const update = params?.update;
191
- if (update?.sessionUpdate === "agent_message_chunk") {
192
- content += update?.content?.text ?? "";
317
+ const u = params?.update;
318
+ switch (u?.sessionUpdate) {
319
+ case "agent_message_chunk": {
320
+ const t = u?.content?.text ?? "";
321
+ content += t;
322
+ if (t)
323
+ send({ type: "text", delta: t });
324
+ break;
325
+ }
326
+ // Thought and tool narration never reach the content — mixing them into
327
+ // the reply corrupts structured output a caller expects to parse.
328
+ case "agent_thought_chunk": {
329
+ const t = u?.content?.text ?? "";
330
+ if (t)
331
+ send({ type: "thought", delta: t, via: "acp" });
332
+ break;
333
+ }
334
+ case "tool_call": {
335
+ const name = u.title || u.kind || "";
336
+ toolNames.set(u.toolCallId ?? "", name);
337
+ send({ type: "tool_call", id: u.toolCallId ?? "", name, args: u.rawInput ?? {}, via: "acp" });
338
+ break;
339
+ }
340
+ case "tool_call_update": {
341
+ if (u.status !== "completed" && u.status !== "failed")
342
+ break;
343
+ const name = u.title || toolNames.get(u.toolCallId ?? "") || "";
344
+ send({
345
+ type: "tool_result",
346
+ id: u.toolCallId ?? "",
347
+ name,
348
+ output: acpToolContentText(u.content),
349
+ isError: u.status === "failed",
350
+ via: "acp",
351
+ });
352
+ break;
353
+ }
193
354
  }
194
- // agent_thought_chunk and tool_call/tool_call_update narration are
195
- // deliberately dropped — mixing them into the reply corrupts
196
- // structured output a caller expects to parse.
197
355
  };
198
- const replyPromise = new Promise((resolve) => pending.set(id, resolve));
356
+ if (exited)
357
+ throw new Error(exitMessage("session/prompt"));
358
+ const replyPromise = new Promise((resolve) => pending.set(id, { method: "session/prompt", cb: resolve }));
199
359
  write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } });
200
360
  let timer;
201
361
  const timeout = new Promise((_, reject) => {
@@ -203,6 +363,8 @@ export async function loadACP(opts) {
203
363
  });
204
364
  try {
205
365
  const msg = await Promise.race([replyPromise, timeout]);
366
+ if (msg.error && msg.error.code === 0)
367
+ throw new Error(msg.error.message);
206
368
  if (msg.error)
207
369
  throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`);
208
370
  return { content };
@@ -213,34 +375,59 @@ export async function loadACP(opts) {
213
375
  updateSink = null;
214
376
  }
215
377
  }
216
- const generate = (request) => {
378
+ const generateStream = (request, emit) => {
217
379
  if (closed)
218
380
  return Promise.reject(new Error("toolnexus: ACP client is closed"));
219
- const turn = turnQueue.then(() => promptOnce(request));
381
+ const turn = turnQueue.then(() => promptOnce(request, emit));
220
382
  turnQueue = turn.then(() => undefined, () => undefined);
221
383
  return turn;
222
384
  };
223
- const close = () => {
385
+ async function close() {
224
386
  if (closed)
225
- return Promise.resolve();
387
+ return;
226
388
  closed = true;
227
389
  rl.close();
228
- return new Promise((resolve) => {
229
- if (child.exitCode !== null || child.signalCode !== null) {
230
- resolve();
231
- return;
232
- }
233
- const done = () => resolve();
234
- child.once("exit", done);
235
- child.kill();
236
- // Belt-and-braces: resolve even if the child never emits `exit` (e.g.
237
- // already reaped), so close() itself never hangs.
238
- setTimeout(done, 1000);
239
- });
240
- };
241
- const client = { generate, close };
390
+ child.stdin.end();
391
+ if (startError || child.pid === undefined)
392
+ return;
393
+ if (child.exitCode !== null || child.signalCode !== null) {
394
+ killGroup(child.pid, "SIGKILL"); // reap any orphaned children of the group
395
+ return;
396
+ }
397
+ killGroup(child.pid, "SIGTERM");
398
+ const t = await Promise.race([childGone.then(() => "gone"), new Promise((r) => setTimeout(() => r("late"), 3000).unref())]);
399
+ killGroup(child.pid, "SIGKILL");
400
+ if (t === "late")
401
+ await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())]);
402
+ }
403
+ const generate = (request) => generateStream(request);
404
+ const client = { generate, generateStream, close, initializeResult };
242
405
  // Non-enumerable, undocumented — internal test hook only, so the public
243
- // ACPClient surface stays exactly {generate, close}.
406
+ // ACPClient surface stays exactly {generate, generateStream, close}.
244
407
  Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
245
408
  return client;
246
409
  }
410
+ /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
411
+ function killGroup(pid, signal) {
412
+ if (process.platform !== "win32") {
413
+ try {
414
+ process.kill(-pid, signal);
415
+ return;
416
+ }
417
+ catch {
418
+ // group already gone; fall through to the pid itself
419
+ }
420
+ }
421
+ try {
422
+ process.kill(pid, signal);
423
+ }
424
+ catch {
425
+ // already gone
426
+ }
427
+ }
428
+ /** Joins the text of an ACP tool_call_update's `content` array ({content:{text}} parts). */
429
+ function acpToolContentText(content) {
430
+ if (!Array.isArray(content))
431
+ return "";
432
+ return content.map((p) => (typeof p?.content?.text === "string" ? p.content.text : "")).join("");
433
+ }