toolnexus 0.24.0 → 0.26.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 (63) hide show
  1. package/README.md +151 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/classifier.d.ts +189 -31
  5. package/dist/classifier.js +589 -30
  6. package/dist/client.d.ts +38 -8
  7. package/dist/client.js +102 -23
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/judge.d.ts +6 -3
  11. package/dist/judge.js +13 -1
  12. package/dist/mcp.d.ts +32 -7
  13. package/dist/mcp.js +125 -36
  14. package/dist/mcp2026/auth.d.ts +137 -0
  15. package/dist/mcp2026/auth.js +446 -0
  16. package/dist/mcp2026/client.d.ts +48 -0
  17. package/dist/mcp2026/client.js +160 -0
  18. package/dist/mcp2026/protocol.d.ts +54 -0
  19. package/dist/mcp2026/protocol.js +113 -0
  20. package/dist/mcp2026/skills.d.ts +5 -0
  21. package/dist/mcp2026/skills.js +25 -0
  22. package/dist/mcp2026/source.d.ts +86 -0
  23. package/dist/mcp2026/source.js +372 -0
  24. package/dist/mcp2026/stdio.d.ts +24 -0
  25. package/dist/mcp2026/stdio.js +99 -0
  26. package/dist/mcp2026/toolsearch.d.ts +5 -0
  27. package/dist/mcp2026/toolsearch.js +58 -0
  28. package/dist/mcphandler.d.ts +169 -0
  29. package/dist/mcphandler.js +1145 -0
  30. package/dist/mcpheader.d.ts +5 -0
  31. package/dist/mcpheader.js +18 -0
  32. package/dist/mcpserve.d.ts +24 -15
  33. package/dist/mcpserve.js +105 -2
  34. package/dist/mcpserverauth.d.ts +48 -0
  35. package/dist/mcpserverauth.js +203 -0
  36. package/dist/serve.js +7 -0
  37. package/dist/skill.d.ts +21 -0
  38. package/dist/skill.js +44 -0
  39. package/dist/toolkit.d.ts +20 -1
  40. package/dist/toolkit.js +25 -3
  41. package/dist/types.d.ts +22 -0
  42. package/package.json +4 -3
  43. package/src/acp.ts +258 -55
  44. package/src/classifier.ts +677 -46
  45. package/src/client.ts +130 -27
  46. package/src/index.ts +2 -0
  47. package/src/judge.ts +19 -4
  48. package/src/mcp.ts +162 -43
  49. package/src/mcp2026/auth.ts +473 -0
  50. package/src/mcp2026/client.ts +161 -0
  51. package/src/mcp2026/protocol.ts +135 -0
  52. package/src/mcp2026/skills.ts +22 -0
  53. package/src/mcp2026/source.ts +407 -0
  54. package/src/mcp2026/stdio.ts +102 -0
  55. package/src/mcp2026/toolsearch.ts +56 -0
  56. package/src/mcphandler.ts +1252 -0
  57. package/src/mcpheader.ts +19 -0
  58. package/src/mcpserve.ts +115 -2
  59. package/src/mcpserverauth.ts +216 -0
  60. package/src/serve.ts +7 -0
  61. package/src/skill.ts +50 -0
  62. package/src/toolkit.ts +31 -4
  63. package/src/types.ts +23 -0
package/dist/types.d.ts CHANGED
@@ -21,6 +21,21 @@ export interface ToolResult {
21
21
  */
22
22
  parts?: ContentPart[];
23
23
  metadata?: Record<string, unknown>;
24
+ /**
25
+ * Machine-readable result (MCP `structuredContent`, SPEC §1). Absent ⇒ byte-identical wire.
26
+ * When set and `output` is empty, MCP serve emits the JSON text of `structured` as the text block.
27
+ */
28
+ structured?: unknown;
29
+ /** Protocol metadata for the wire result (MCP `_meta`, SPEC §1). Absent ⇒ omitted. */
30
+ meta?: Record<string, unknown>;
31
+ }
32
+ /** MCP tool annotations (SPEC §1, §7C.1), sent verbatim on an MCP `tools/list` entry. */
33
+ export interface ToolAnnotations {
34
+ title?: string;
35
+ readOnlyHint?: boolean;
36
+ destructiveHint?: boolean;
37
+ idempotentHint?: boolean;
38
+ openWorldHint?: boolean;
24
39
  }
25
40
  export interface ToolContext {
26
41
  signal?: AbortSignal;
@@ -30,6 +45,9 @@ export interface ToolContext {
30
45
  /** The model's id for this tool call, set by the client loop (§4C). A message's calls run
31
46
  * concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
32
47
  callId?: string;
48
+ /** Set by `toolkit.mcpHandler` (§7C.1): the inbound MCP request this call serves. Read it with
49
+ * `identityFrom(ctx)` / `requestFrom(ctx)` / `mcpSessionIdFrom(ctx)` / `mcpRequestMetaFrom(ctx)`. */
50
+ mcp?: import("./mcphandler.js").McpContext;
33
51
  }
34
52
  /**
35
53
  * Advisory behavior hints (named after MCP tool annotations, §4C), for host permission gates
@@ -99,6 +117,10 @@ export interface Tool {
99
117
  /** Decides per call whether this tool may run alongside other safe calls of the same model
100
118
  * response (SPEC §8 "Tool scheduling"). Absent ⇒ safe only when `hints.readOnly`. */
101
119
  concurrencySafe?: (args: Record<string, unknown>) => boolean;
120
+ /** JSON Schema of `ToolResult.structured` (SPEC §1); absent ⇒ omitted everywhere. */
121
+ outputSchema?: Record<string, unknown>;
122
+ /** MCP annotations (§7C.1), verbatim on the MCP wire; absent ⇒ derived from `hints`, else omitted. */
123
+ annotations?: ToolAnnotations;
102
124
  }
103
125
  export type McpStatus = "connected" | "disabled" | "failed";
104
126
  /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,12 +17,13 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts",
22
22
  "example:judge": "node --experimental-strip-types examples/judge.ts",
23
23
  "example:multimodal": "node --experimental-strip-types examples/multimodal.ts",
24
24
  "example:media": "node --experimental-strip-types examples/media.ts",
25
- "example:prompt-cache": "node --experimental-strip-types examples/prompt_cache.ts"
25
+ "example:prompt-cache": "node --experimental-strip-types examples/prompt_cache.ts",
26
+ "example:mcp-handler": "node --experimental-strip-types examples/mcp_handler.ts"
26
27
  },
27
28
  "keywords": [
28
29
  "mcp",
package/src/acp.ts 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` →
@@ -35,16 +34,16 @@
35
34
  import { spawn } from "node:child_process"
36
35
  import { createInterface } from "node:readline"
37
36
  import path from "node:path"
38
- import type { InProcessRequest, InProcessResponse } from "./client.js"
37
+ import type { InProcessRequest, InProcessResponse, StreamEvent } from "./client.js"
39
38
 
40
39
  /** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
41
40
  * process; everything else tunes the session handshake. */
42
41
  export interface ACPOptions {
43
- /** The ACP agent binary, e.g. `"devin"`. */
42
+ /** The ACP agent binary. */
44
43
  command: string
45
- /** Arguments to the agent, e.g. `["acp"]`. */
44
+ /** Arguments to the agent, passed untouched. */
46
45
  args?: string[]
47
- /** Working directory for `session/new`. Real ACP agents (e.g. `devin acp`)
46
+ /** Working directory for `session/new`. Real ACP agents
48
47
  * reject a relative `cwd` with `-32602 Invalid params` — this is always
49
48
  * resolved to an absolute path before it is sent. Default: `process.cwd()`. */
50
49
  cwd?: string
@@ -60,6 +59,28 @@ export interface ACPOptions {
60
59
  permissionTimeoutMs?: number
61
60
  /** Optional `session/set_mode` sent once, right after `session/new`. */
62
61
  mode?: string
62
+ /** Sent as `session/new` `mcpServers` VERBATIM and in order (absent ⇒ `[]`): toolnexus does
63
+ * not validate, rename, reshape or pick between the http/stdio forms (SPEC §8C). */
64
+ mcpServers?: Record<string, unknown>[]
65
+ /** Sent as `session/new` `_meta`; the key is absent when unset. Opaque to toolnexus. */
66
+ sessionMeta?: Record<string, unknown>
67
+ /** Sent as `initialize` `clientCapabilities._meta` (vendor flags). Opaque to toolnexus. */
68
+ clientCapsMeta?: Record<string, unknown>
69
+ /** Passed through unchanged after `session/new`: `session/set_config_option`
70
+ * `{configId:"model"}`, then `session/set_model` if the agent rejects that. Both failing
71
+ * fails `loadACP`. */
72
+ model?: string
73
+ /** Receives the agent's stderr (default: discarded). */
74
+ stderr?: (chunk: string) => void
75
+ /** Decides each `session/request_permission`: return an `optionId` to select it, or
76
+ * `""`/undefined to answer with the first `reject*` option. Absent ⇒ the first `allow*`
77
+ * option, as before. */
78
+ onPermission?: (params: any) => string | undefined | Promise<string | undefined>
79
+ /** Observes every raw `session/update` params object (must not throw). */
80
+ onUpdate?: (params: any) => void
81
+ /** Bounds each handshake request (`initialize`, `session/new`, …). Absent ⇒ unbounded;
82
+ * a request still fails at once when the agent exits. */
83
+ requestTimeoutMs?: number
63
84
  }
64
85
 
65
86
  /** One warm ACP connection: one child process, one session, alive across many
@@ -67,8 +88,17 @@ export interface ACPOptions {
67
88
  * wants, so it plugs straight into {@link createInProcessClient}. */
68
89
  export interface ACPClient {
69
90
  generate: (request: InProcessRequest) => Promise<InProcessResponse>
70
- /** Terminates the agent process. Idempotent — a second call is a no-op. */
91
+ /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
92
+ * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
93
+ * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
94
+ * (both via "acp": the agent's own tools, reported, never executed by the loop).
95
+ * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
96
+ * message text only. */
97
+ generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>
98
+ /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
71
99
  close: () => Promise<void>
100
+ /** The agent's raw `initialize` result. */
101
+ readonly initializeResult: any
72
102
  }
73
103
 
74
104
  /** The literal marker every rendered prompt ends with, naming the current
@@ -126,7 +156,7 @@ function renderPrompt(request: InProcessRequest): string {
126
156
  /**
127
157
  * Opens one ACP connection: spawns `command`, performs `initialize` →
128
158
  * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
129
- * trap real `devin acp` enforces with `-32602` otherwise) → an optional
159
+ * trap real agents enforce with `-32602` otherwise) → an optional
130
160
  * `session/set_mode`, then returns a client whose `.generate` sends exactly
131
161
  * one `session/prompt` per call, accumulating only `agent_message_chunk`
132
162
  * text.
@@ -135,18 +165,59 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
135
165
  const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd()
136
166
  const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000
137
167
 
168
+ // Its own process group (detached), so close() reaches an adapter's children too — an
169
+ // `npx …` launch is a tree, not one process.
138
170
  const child = spawn(opts.command, opts.args ?? [], {
139
171
  cwd,
140
172
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
141
- stdio: ["pipe", "pipe", "ignore"],
173
+ stdio: ["pipe", "pipe", opts.stderr ? "pipe" : "ignore"],
174
+ detached: process.platform !== "win32",
142
175
  })
176
+ if (opts.stderr) {
177
+ child.stderr!.setEncoding("utf8")
178
+ child.stderr!.on("data", (d: string) => {
179
+ try {
180
+ opts.stderr!(d)
181
+ } catch {
182
+ // a host sink never breaks the session
183
+ }
184
+ })
185
+ }
143
186
 
144
187
  let closed = false
145
188
  let nextId = 0
146
- const pending = new Map<string, (msg: RpcMessage) => void>()
189
+ const pending = new Map<string, { method: string; cb: (msg: RpcMessage) => void }>()
147
190
  let updateSink: ((params: any) => void) | null = null
191
+ let exited = false
192
+ let startError: Error | undefined
193
+ const childGone = new Promise<void>((resolve) => {
194
+ child.once("exit", () => resolve())
195
+ child.once("error", () => resolve())
196
+ })
197
+
198
+ // Fail fast: a request still waiting when the agent's stdout closes fails at once
199
+ // instead of waiting out a timeout. setImmediate lets a spawn `error` land first.
200
+ const failPending = () => {
201
+ exited = true
202
+ for (const [id, p] of pending) {
203
+ pending.delete(id)
204
+ p.cb({ error: { code: 0, message: exitMessage(p.method) } })
205
+ }
206
+ }
207
+ const exitMessage = (method: string) =>
208
+ startError
209
+ ? `toolnexus: acp: start ${JSON.stringify(opts.command)}: ${startError.message}`
210
+ : `toolnexus: acp: agent exited before replying to ${method}`
211
+ child.on("error", (e) => {
212
+ startError = e
213
+ setImmediate(failPending)
214
+ })
215
+ child.stdin!.on("error", () => {
216
+ // EPIPE after the agent died: surfaced through failPending, never as a crash.
217
+ })
148
218
 
149
219
  const rl = createInterface({ input: child.stdout! })
220
+ rl.on("close", () => setImmediate(failPending))
150
221
  rl.on("line", (raw) => {
151
222
  const line = raw.trim()
152
223
  if (!line) return
@@ -160,6 +231,7 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
160
231
  })
161
232
 
162
233
  function write(msg: Record<string, unknown>): void {
234
+ if (exited || child.stdin!.destroyed) return
163
235
  child.stdin!.write(JSON.stringify(msg) + "\n")
164
236
  }
165
237
 
@@ -170,10 +242,27 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
170
242
  function call(method: string, params: unknown): Promise<any> {
171
243
  const id = `c-${++nextId}`
172
244
  return new Promise((resolve, reject) => {
173
- pending.set(id, (msg) => {
174
- if (msg.error) reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`))
175
- else resolve(msg.result)
245
+ if (exited) {
246
+ reject(new Error(exitMessage(method)))
247
+ return
248
+ }
249
+ let timer: NodeJS.Timeout | undefined
250
+ pending.set(id, {
251
+ method,
252
+ cb: (msg) => {
253
+ clearTimeout(timer)
254
+ if (msg.error && msg.error.code === 0) reject(new Error(msg.error.message))
255
+ else if (msg.error) reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`))
256
+ else resolve(msg.result)
257
+ },
176
258
  })
259
+ if (opts.requestTimeoutMs && opts.requestTimeoutMs > 0) {
260
+ const ms = opts.requestTimeoutMs
261
+ timer = setTimeout(() => {
262
+ pending.delete(id)
263
+ reject(new Error(`toolnexus: acp: ${method} timed out after ${ms}ms`))
264
+ }, ms)
265
+ }
177
266
  write({ jsonrpc: "2.0", id, method, params })
178
267
  })
179
268
  }
@@ -184,49 +273,100 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
184
273
  * server-initiated requests like `session/request_permission`. */
185
274
  function handleLine(msg: RpcMessage): void {
186
275
  if (msg.method === undefined && msg.id !== undefined) {
187
- const cb = pending.get(String(msg.id))
188
- if (cb) {
276
+ const p = pending.get(String(msg.id))
277
+ if (p) {
189
278
  pending.delete(String(msg.id))
190
- cb(msg)
279
+ p.cb(msg)
191
280
  }
192
281
  return
193
282
  }
194
283
  if (msg.method === "session/update") {
284
+ if (opts.onUpdate) {
285
+ try {
286
+ opts.onUpdate(msg.params)
287
+ } catch {
288
+ // an observer never breaks the session
289
+ }
290
+ }
195
291
  updateSink?.(msg.params)
196
292
  return
197
293
  }
198
294
  if (msg.method === "session/request_permission" && msg.id !== undefined) {
199
- answerPermission(msg)
295
+ void answerPermission(msg)
200
296
  return
201
297
  }
202
- // An unhandled server->client request still needs a reply or a
203
- // strict agent may itself hang waiting on one.
204
- if (msg.id !== undefined) reply(msg.id, {})
298
+ // An agent->client REQUEST we do not implement (fs/*, terminal/* — declared
299
+ // unsupported in clientCapabilities). Answer it, or the agent waits forever.
300
+ if (msg.id !== undefined) {
301
+ write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `method not found: ${msg.method}` } })
302
+ }
205
303
  }
206
304
 
207
305
  /** Answers with the FIRST option whose `kind` starts with `allow` — an
208
306
  * unanswered permission request hangs the turn forever (proven in
209
307
  * spikes/acp). This runs the instant the request arrives, never waiting
210
308
  * for anything else. */
211
- function answerPermission(req: RpcMessage): void {
309
+ async function answerPermission(req: RpcMessage): Promise<void> {
212
310
  const options: Array<{ optionId: string; kind?: string }> = req.params?.options ?? []
213
- const chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"))
311
+ let chosen: { optionId: string } | undefined
312
+ if (opts.onPermission) {
313
+ let picked: string | undefined
314
+ try {
315
+ picked = await opts.onPermission(req.params)
316
+ } catch {
317
+ picked = undefined
318
+ }
319
+ chosen = picked
320
+ ? { optionId: picked }
321
+ : options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))
322
+ } else {
323
+ chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"))
324
+ }
214
325
  const result = chosen
215
326
  ? { outcome: { outcome: "selected", optionId: chosen.optionId } }
216
327
  : { outcome: { outcome: "cancelled" } }
217
328
  reply(req.id, result)
218
329
  }
219
330
 
220
- await call("initialize", {
221
- protocolVersion: opts.protocolVersion ?? 1,
222
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
223
- })
224
-
225
- const sessionResult = await call("session/new", { cwd, mcpServers: [] })
331
+ const clientCapabilities: Record<string, unknown> = {
332
+ fs: { readTextFile: false, writeTextFile: false },
333
+ terminal: false,
334
+ }
335
+ if (opts.clientCapsMeta) clientCapabilities._meta = opts.clientCapsMeta
336
+ const step = async <T>(label: string, fn: () => Promise<T>): Promise<T> => {
337
+ try {
338
+ return await fn()
339
+ } catch (e) {
340
+ await close()
341
+ const m = e instanceof Error ? e.message : String(e)
342
+ throw new Error(startError ? m : `toolnexus: acp: ${label}: ${m}`)
343
+ }
344
+ }
345
+ const initializeResult = await step("initialize", () =>
346
+ call("initialize", { protocolVersion: opts.protocolVersion ?? 1, clientCapabilities }),
347
+ )
348
+ const sessionParams: Record<string, unknown> = { cwd, mcpServers: opts.mcpServers ?? [] }
349
+ if (opts.sessionMeta) sessionParams._meta = opts.sessionMeta
350
+ const sessionResult = await step("session/new", () => call("session/new", sessionParams))
226
351
  const sessionId = sessionResult?.sessionId
227
352
 
228
353
  if (opts.mode) {
229
- await call("session/set_mode", { sessionId, modeId: opts.mode })
354
+ await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }))
355
+ }
356
+ if (opts.model) {
357
+ // Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
358
+ const model = opts.model
359
+ await step(`set model ${JSON.stringify(model)}`, async () => {
360
+ try {
361
+ await call("session/set_config_option", { sessionId, configId: "model", value: model })
362
+ } catch (e1) {
363
+ try {
364
+ await call("session/set_model", { sessionId, modelId: model })
365
+ } catch (e2) {
366
+ throw new Error(`${(e1 as Error).message}; ${(e2 as Error).message}`)
367
+ }
368
+ }
369
+ })
230
370
  }
231
371
 
232
372
  // Serialises turns: one ACP session is one conversation, so concurrent
@@ -235,22 +375,61 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
235
375
  // queue itself never rejects (a failed turn does not poison later ones).
236
376
  let turnQueue: Promise<unknown> = Promise.resolve()
237
377
 
238
- async function promptOnce(request: InProcessRequest): Promise<InProcessResponse> {
378
+ async function promptOnce(request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> {
239
379
  const text = renderPrompt(request)
240
380
  const id = `c-${++nextId}`
241
381
 
242
382
  let content = ""
383
+ const toolNames = new Map<string, string>()
384
+ const send = (ev: StreamEvent) => {
385
+ try {
386
+ emit?.(ev)
387
+ } catch {
388
+ // a consumer never breaks the session
389
+ }
390
+ }
391
+ // Runs from the stdout read loop, so every event is emitted the moment its
392
+ // notification is processed — never buffered to the end of the turn.
243
393
  updateSink = (params: any) => {
244
- const update = params?.update
245
- if (update?.sessionUpdate === "agent_message_chunk") {
246
- content += update?.content?.text ?? ""
394
+ const u = params?.update
395
+ switch (u?.sessionUpdate) {
396
+ case "agent_message_chunk": {
397
+ const t: string = u?.content?.text ?? ""
398
+ content += t
399
+ if (t) send({ type: "text", delta: t })
400
+ break
401
+ }
402
+ // Thought and tool narration never reach the content — mixing them into
403
+ // the reply corrupts structured output a caller expects to parse.
404
+ case "agent_thought_chunk": {
405
+ const t: string = u?.content?.text ?? ""
406
+ if (t) send({ type: "thought", delta: t, via: "acp" })
407
+ break
408
+ }
409
+ case "tool_call": {
410
+ const name: string = u.title || u.kind || ""
411
+ toolNames.set(u.toolCallId ?? "", name)
412
+ send({ type: "tool_call", id: u.toolCallId ?? "", name, args: u.rawInput ?? {}, via: "acp" })
413
+ break
414
+ }
415
+ case "tool_call_update": {
416
+ if (u.status !== "completed" && u.status !== "failed") break
417
+ const name: string = u.title || toolNames.get(u.toolCallId ?? "") || ""
418
+ send({
419
+ type: "tool_result",
420
+ id: u.toolCallId ?? "",
421
+ name,
422
+ output: acpToolContentText(u.content),
423
+ isError: u.status === "failed",
424
+ via: "acp",
425
+ })
426
+ break
427
+ }
247
428
  }
248
- // agent_thought_chunk and tool_call/tool_call_update narration are
249
- // deliberately dropped — mixing them into the reply corrupts
250
- // structured output a caller expects to parse.
251
429
  }
252
430
 
253
- const replyPromise = new Promise<RpcMessage>((resolve) => pending.set(id, resolve))
431
+ if (exited) throw new Error(exitMessage("session/prompt"))
432
+ const replyPromise = new Promise<RpcMessage>((resolve) => pending.set(id, { method: "session/prompt", cb: resolve }))
254
433
  write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } })
255
434
 
256
435
  let timer: NodeJS.Timeout | undefined
@@ -263,6 +442,7 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
263
442
 
264
443
  try {
265
444
  const msg = await Promise.race([replyPromise, timeout])
445
+ if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
266
446
  if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
267
447
  return { content }
268
448
  } finally {
@@ -272,9 +452,9 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
272
452
  }
273
453
  }
274
454
 
275
- const generate = (request: InProcessRequest): Promise<InProcessResponse> => {
455
+ const generateStream = (request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> => {
276
456
  if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
277
- const turn = turnQueue.then(() => promptOnce(request))
457
+ const turn = turnQueue.then(() => promptOnce(request, emit))
278
458
  turnQueue = turn.then(
279
459
  () => undefined,
280
460
  () => undefined,
@@ -282,27 +462,50 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
282
462
  return turn
283
463
  }
284
464
 
285
- const close = (): Promise<void> => {
286
- if (closed) return Promise.resolve()
465
+ async function close(): Promise<void> {
466
+ if (closed) return
287
467
  closed = true
288
468
  rl.close()
289
- return new Promise((resolve) => {
290
- if (child.exitCode !== null || child.signalCode !== null) {
291
- resolve()
292
- return
293
- }
294
- const done = () => resolve()
295
- child.once("exit", done)
296
- child.kill()
297
- // Belt-and-braces: resolve even if the child never emits `exit` (e.g.
298
- // already reaped), so close() itself never hangs.
299
- setTimeout(done, 1000)
300
- })
469
+ child.stdin!.end()
470
+ if (startError || child.pid === undefined) return
471
+ if (child.exitCode !== null || child.signalCode !== null) {
472
+ killGroup(child.pid, "SIGKILL") // reap any orphaned children of the group
473
+ return
474
+ }
475
+ killGroup(child.pid, "SIGTERM")
476
+ const t = await Promise.race([childGone.then(() => "gone"), new Promise((r) => setTimeout(() => r("late"), 3000).unref())])
477
+ killGroup(child.pid, "SIGKILL")
478
+ if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
301
479
  }
302
480
 
303
- const client: ACPClient = { generate, close }
481
+ const generate = (request: InProcessRequest): Promise<InProcessResponse> => generateStream(request)
482
+
483
+ const client: ACPClient = { generate, generateStream, close, initializeResult }
304
484
  // Non-enumerable, undocumented — internal test hook only, so the public
305
- // ACPClient surface stays exactly {generate, close}.
485
+ // ACPClient surface stays exactly {generate, generateStream, close}.
306
486
  Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
307
487
  return client
308
488
  }
489
+
490
+ /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
491
+ function killGroup(pid: number, signal: NodeJS.Signals): void {
492
+ if (process.platform !== "win32") {
493
+ try {
494
+ process.kill(-pid, signal)
495
+ return
496
+ } catch {
497
+ // group already gone; fall through to the pid itself
498
+ }
499
+ }
500
+ try {
501
+ process.kill(pid, signal)
502
+ } catch {
503
+ // already gone
504
+ }
505
+ }
506
+
507
+ /** Joins the text of an ACP tool_call_update's `content` array ({content:{text}} parts). */
508
+ function acpToolContentText(content: any): string {
509
+ if (!Array.isArray(content)) return ""
510
+ return content.map((p: any) => (typeof p?.content?.text === "string" ? p.content.text : "")).join("")
511
+ }