toolnexus 0.18.0 → 0.19.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/dist/skill.js CHANGED
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { parse as parseYaml } from "yaml";
16
+ import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
16
17
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
17
18
 
18
19
  Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
@@ -25,39 +26,84 @@ The skill name must match one of the skills listed in your system prompt.`;
25
26
  export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
26
27
  "Use the skill tool to load a skill when a task matches its description.";
27
28
  /**
28
- * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
29
- * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
30
- * values all resolve correctly. Scalar values are coerced to strings.
29
+ * Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
30
+ * (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
31
+ * Scalar values are coerced to strings.
31
32
  *
32
- * `malformed` is true only when fences are present but the YAML fails to parse —
33
- * distinguishing a malformed header from a body with no frontmatter, so the
34
- * inventory (S3) can report the right skip reason. loadSkills' behavior is
35
- * unchanged: a malformed header yields no name and the skill is skipped.
33
+ * LENIENT FALLBACK (ADR 0028, issue #93) — **YAML FIRST**. A line-wise `key: rest-of-line` read
34
+ * of `name`/`description` runs ONLY on frontmatter a real YAML parser has ALREADY refused.
35
+ * The order is the whole point: line-wise FIRST misparses legitimate YAML (`description: |`
36
+ * becomes `"|"`, a continued plain scalar truncates to its first line), and running it only
37
+ * after a refusal means it can never see a file whose YAML semantics matter — by construction
38
+ * those files have none left to preserve. No file that parses today changes value.
39
+ *
40
+ * `malformed` is true only when fences are present, YAML refused, AND the rescue found no name.
36
41
  */
42
+ const LENIENT_KEYS = ["name", "description"];
43
+ /** A value opening one of these is a construct the rescue does not implement — refused, never
44
+ * guessed, so a half-broken block scalar degrades to "no description", not to `"|"`. */
45
+ const LENIENT_REFUSED_OPENERS = new Set(["|", ">", "&", "*", "[", "{", "!"]);
46
+ /** Column 0, ordinary key characters, one `:`; the value is the rest of the line. */
47
+ const LENIENT_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*):[ \t]*(.*)$/;
48
+ function unquoteScalar(v) {
49
+ const t = v.trim();
50
+ if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0])
51
+ return t.slice(1, -1);
52
+ return t;
53
+ }
54
+ /** Rescue `name`/`description` from a frontmatter block YAML refused. First-wins, column 0. */
55
+ function lineWiseFrontmatter(block) {
56
+ const data = {};
57
+ for (const raw of block.split(/\r?\n/)) {
58
+ const head = raw.slice(0, 1);
59
+ // indented continuation, comment, or blank — not a top-level key
60
+ if (head === " " || head === "\t" || head === "#" || head === "")
61
+ continue;
62
+ const m = LENIENT_LINE.exec(raw);
63
+ if (!m)
64
+ continue;
65
+ const key = m[1];
66
+ const value = m[2].trim();
67
+ if (!LENIENT_KEYS.includes(key) || key in data)
68
+ continue;
69
+ if (!value || LENIENT_REFUSED_OPENERS.has(value[0]))
70
+ continue;
71
+ data[key] = unquoteScalar(value);
72
+ }
73
+ return data;
74
+ }
37
75
  function parseFrontmatter(text) {
38
76
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
39
77
  if (!match)
40
78
  return { data: {}, content: text, malformed: false };
41
79
  const data = {};
42
80
  let parsed;
43
- let malformed = false;
81
+ let detail;
44
82
  try {
45
83
  parsed = parseYaml(match[1]);
46
84
  }
47
- catch {
85
+ catch (e) {
48
86
  parsed = null;
49
- malformed = true;
87
+ detail = e instanceof Error ? e.message : String(e);
50
88
  }
51
- if (parsed && typeof parsed === "object") {
89
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
52
90
  for (const [key, value] of Object.entries(parsed)) {
53
91
  // Trim so block-scalar trailing newlines (chomping differs subtly between
54
- // YAML libs) don't leak — keeps the five ports byte-identical.
92
+ // YAML libs) don't leak — keeps the ports byte-identical.
55
93
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
56
94
  data[key] = String(value).trim();
57
95
  }
58
96
  }
97
+ return { data, content: match[2], malformed: false };
59
98
  }
60
- return { data, content: match[2], malformed };
99
+ // YAML refused (threw, or produced something that is not a mapping): rescue the two scalars.
100
+ const rescued = lineWiseFrontmatter(match[1]);
101
+ return {
102
+ data: rescued,
103
+ content: match[2],
104
+ malformed: !rescued.name,
105
+ detail: detail ?? (rescued.name ? undefined : "frontmatter is not a YAML mapping"),
106
+ };
61
107
  }
62
108
  function walkSkillFiles(root) {
63
109
  const out = [];
@@ -69,11 +115,17 @@ function walkSkillFiles(root) {
69
115
  const dir = stack.pop();
70
116
  let entries;
71
117
  try {
72
- entries = readdirSync(dir, { withFileTypes: true });
118
+ // EXPLICIT (A24): `readdirSync` is sorted on most platforms, but Node guarantees
119
+ // nothing. An absent sort over a directory read is the same defect class as a wrong one,
120
+ // and it is invisible to a grep for comparators.
121
+ entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }));
73
122
  }
74
123
  catch {
75
124
  continue;
76
125
  }
126
+ // Subdirectories are pushed REVERSED so the LIFO stack pops them in name order.
127
+ for (const entry of [...entries].reverse().filter((e) => e.isDirectory() || e.isSymbolicLink()).reverse().concat([]))
128
+ void entry;
77
129
  for (const entry of entries) {
78
130
  const full = path.join(dir, entry.name);
79
131
  let isDir = entry.isDirectory();
@@ -111,11 +163,23 @@ function walkSkillFiles(root) {
111
163
  }
112
164
  return out;
113
165
  }
166
+ /**
167
+ * The `<skill_files>` SAMPLE LIST: sibling resources shown to the model when a skill is loaded.
168
+ *
169
+ * This is SHIPPED OUTPUT, so its order is part of the cross-port contract (addendum A22). It
170
+ * used to be raw `readdirSync` order over a LIFO `stack.pop()` walk with the cap applied MID
171
+ * TRAVERSAL — so the filesystem decided not merely how the sample was ordered but WHICH files
172
+ * appeared in it at all. Now: collect everything, then order by DEPTH ascending then Unicode
173
+ * CODE POINT over the path relative to the skill dir (the same `compareDiscovery` rule as
174
+ * discovery), and only then apply the cap. The sample is therefore a function of the tree and
175
+ * the cap alone — never of readdir order, and stable across machines and platforms. The
176
+ * ordering is PLAIN Unicode code point over the relative path (A25).
177
+ */
114
178
  function sampleSiblingFiles(dir, limit = 10) {
115
- const out = [];
179
+ const found = [];
116
180
  const stack = [dir];
117
181
  const seen = new Set();
118
- while (stack.length && out.length < limit) {
182
+ while (stack.length) {
119
183
  const cur = stack.pop();
120
184
  let entries;
121
185
  try {
@@ -125,8 +189,6 @@ function sampleSiblingFiles(dir, limit = 10) {
125
189
  continue;
126
190
  }
127
191
  for (const entry of entries) {
128
- if (out.length >= limit)
129
- break;
130
192
  const full = path.join(cur, entry.name);
131
193
  let isDir = entry.isDirectory();
132
194
  let isFile = entry.isFile();
@@ -156,11 +218,17 @@ function sampleSiblingFiles(dir, limit = 10) {
156
218
  stack.push(full);
157
219
  }
158
220
  else if (isFile && entry.name !== "SKILL.md") {
159
- out.push(full);
221
+ found.push(full);
160
222
  }
161
223
  }
162
224
  }
163
- return out;
225
+ // Order FIRST, cap SECOND: capping during the walk let readdir order pick the sample.
226
+ // PLAIN code point over the path relative to the skill dir (A25) — NOT the discovery rule.
227
+ // A15's depth rule exists to decide WHICH FILE a duplicated skill NAME resolves to; it has no
228
+ // business ordering a flat listing, where it would interleave `a-root.txt, z-root.txt,
229
+ // alpha/f.txt` instead of `a-root.txt, alpha/f.txt, z-root.txt`.
230
+ found.sort((a, b) => compareCodePoints(path.relative(dir, a), path.relative(dir, b)));
231
+ return found.slice(0, limit);
164
232
  }
165
233
  /** Discover candidates under one on-disk root (order = discovery order). */
166
234
  function candidatesFromDir(root) {
@@ -176,18 +244,27 @@ function candidatesFromDir(root) {
176
244
  console.warn(`[toolnexus] skills dir not found: ${root}`);
177
245
  return out;
178
246
  }
179
- for (const file of walkSkillFiles(root)) {
247
+ // DISCOVERY ORDER (addendum A1/A1a/A15): dirs in the order the CALLER passed them; within a
248
+ // dir, by DEPTH ASCENDING, then Unicode CODE-POINT comparison of the path relative to that dir.
249
+ // A symlink sorts at its DISCOVERED path, never its target.
250
+ //
251
+ // Depth comes FIRST, and that is the whole rule: code point alone makes the winner depend on
252
+ // the skill's own first letter. `docx`/`pdf`/`pptx` beat `synced/<uuid>/…` because d/p < s —
253
+ // but `xlsx` LOSES to it, because x > s. Depth-first makes "a shallower path beats a nested
254
+ // copy of the same name" true for EVERY name, which is what SPEC §3 already claims.
255
+ const files = walkSkillFiles(root).sort((a, b) => compareDiscovery(path.relative(root, a), path.relative(root, b), path.sep));
256
+ for (const file of files) {
180
257
  let text;
181
258
  try {
182
259
  text = readFileSync(file, "utf8");
183
260
  }
184
- catch {
185
- out.push({ skip: { location: file, reason: "unreadable" } });
261
+ catch (e) {
262
+ out.push({ skip: { location: file, reason: "unreadable", detail: e instanceof Error ? e.message : String(e) } });
186
263
  continue;
187
264
  }
188
- const { data, content, malformed } = parseFrontmatter(text);
265
+ const { data, content, malformed, detail } = parseFrontmatter(text);
189
266
  if (malformed) {
190
- out.push({ skip: { location: file, reason: "malformed-frontmatter" } });
267
+ out.push({ skip: { location: file, reason: "malformed-frontmatter", ...(detail ? { detail } : {}) } });
191
268
  continue;
192
269
  }
193
270
  if (!data.name) {
@@ -286,6 +363,7 @@ export function loadSkills(input) {
286
363
  const sampleLimit = opts.sampleLimit ?? 0;
287
364
  const merged = mergeCandidates(collectCandidates(opts));
288
365
  const skills = applySkillsFilter(merged.skills, opts.filter);
366
+ const skipped = merged.skipped;
289
367
  const tool = {
290
368
  name: "skill",
291
369
  description: SKILL_TOOL_DESCRIPTION,
@@ -301,7 +379,7 @@ export function loadSkills(input) {
301
379
  const info = skills[name];
302
380
  if (!info) {
303
381
  return {
304
- output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
382
+ output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort(compareCodePoints).join(", ") || "none"}`,
305
383
  isError: true,
306
384
  };
307
385
  }
@@ -347,10 +425,15 @@ export function loadSkills(input) {
347
425
  return {
348
426
  skills,
349
427
  tool,
428
+ skipped,
350
429
  prompt() {
351
430
  const described = Object.values(skills)
352
431
  .filter((s) => s.description !== undefined)
353
- .sort((a, b) => a.name.localeCompare(b.name));
432
+ // Unicode CODE POINT over the skill name, never `localeCompare` (addendum A22): a
433
+ // locale collation orders by the machine's ICU data, so the same build emitted a
434
+ // DIFFERENTLY ORDERED prompt on two machines. SPEC §0.10 pins this prompt as
435
+ // byte-identical across ports; it was not even stable within js.
436
+ .sort((a, b) => compareCodePoints(a.name, b.name));
354
437
  if (described.length === 0)
355
438
  return "No skills are currently available.";
356
439
  return [
package/dist/types.d.ts CHANGED
@@ -52,6 +52,17 @@ export interface Answer {
52
52
  */
53
53
  reason?: "declined" | "cancelled" | "expired";
54
54
  }
55
+ /**
56
+ * Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
57
+ * Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
58
+ * removes the key from the host's hands entirely.
59
+ *
60
+ * `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
61
+ * fabricated empty result is indistinguishable from an answer the human never gave.
62
+ */
63
+ export declare function answerOutput(id: string, output: string): Answer;
64
+ /** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
65
+ export declare function answerDeclined(id: string, reason?: Answer["reason"]): Answer;
55
66
  /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
56
67
  export declare function pending(request: Omit<Request, "id"> & {
57
68
  id?: string;
package/dist/types.js CHANGED
@@ -1,6 +1,28 @@
1
1
  /**
2
2
  * Shared contract types. See ../../SPEC.md.
3
3
  */
4
+ /**
5
+ * Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
6
+ * Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
7
+ * removes the key from the host's hands entirely.
8
+ *
9
+ * `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
10
+ * fabricated empty result is indistinguishable from an answer the human never gave.
11
+ */
12
+ export function answerOutput(id, output) {
13
+ if (typeof output !== "string") {
14
+ throw new TypeError(`answerOutput: output must be a string, got ${output === null ? "null" : typeof output}`);
15
+ }
16
+ if (!id)
17
+ throw new TypeError("answerOutput: id is required (it must match the Request's id)");
18
+ return { id, ok: true, data: { output } };
19
+ }
20
+ /** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
21
+ export function answerDeclined(id, reason = "declined") {
22
+ if (!id)
23
+ throw new TypeError("answerDeclined: id is required (it must match the Request's id)");
24
+ return { id, ok: false, reason };
25
+ }
4
26
  let _pendingSeq = 0;
5
27
  /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
6
28
  export function pending(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.18.0",
3
+ "version": "0.19.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,7 +17,7 @@
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/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",
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",
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"
package/src/acp.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * ACP (Agent Client Protocol) model source — issue #96, ADR 0031
3
+ * ("ACP: the warm session is the feature, and it breaks the stateless request"),
4
+ * openspec/changes/add-acp-model-source.
5
+ *
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 —
9
+ * the exact shape {@link InProcessOptions.generate} already defines — so the
10
+ * tool-calling loop, skills, MCP tools, adapters and sub-agents are entirely
11
+ * untouched. `loadACP` opens the connection once (`initialize` → `session/new` →
12
+ * optional `session/set_mode`) and hands back an {@link ACPClient} whose
13
+ * `.generate` can be passed straight to {@link createInProcessClient}.
14
+ *
15
+ * The hard part is not the JSON-RPC plumbing — it is that an ACP session is
16
+ * *stateful* while every other toolnexus model source is stateless-by-default.
17
+ * toolnexus assembles a complete request every turn; sending the whole thing
18
+ * into a session that already remembers the transcript makes some agents answer
19
+ * a near-duplicate prompt from stale history (reproduced in `spikes/acp/`).
20
+ * The mitigation (kept as the default, not left to the caller) is an explicit
21
+ * "this supersedes everything earlier" marker appended to every rendered
22
+ * prompt — see {@link renderPrompt}.
23
+ *
24
+ * Three more traps this client exists to close, all proven in the spike:
25
+ * - `session/update` notifications interleave with JSON-RPC replies on the
26
+ * same stdout stream, so everything arriving is demultiplexed by id.
27
+ * - Only `agent_message_chunk` text may be accumulated into the reply —
28
+ * `agent_thought_chunk` and tool-call narration wrap prose around
29
+ * structured output and corrupt it if mixed in.
30
+ * - An unanswered `session/request_permission` hangs the turn FOREVER, even
31
+ * in an agent's bypass mode. The client answers with the first `allow`-kind
32
+ * option the instant the request arrives; `permissionTimeoutMs` is only a
33
+ * safety net for a broken agent that never asks and never replies at all.
34
+ */
35
+ import { spawn } from "node:child_process"
36
+ import { createInterface } from "node:readline"
37
+ import path from "node:path"
38
+ import type { InProcessRequest, InProcessResponse } from "./client.js"
39
+
40
+ /** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
41
+ * process; everything else tunes the session handshake. */
42
+ export interface ACPOptions {
43
+ /** The ACP agent binary, e.g. `"devin"`. */
44
+ command: string
45
+ /** Arguments to the agent, e.g. `["acp"]`. */
46
+ args?: string[]
47
+ /** Working directory for `session/new`. Real ACP agents (e.g. `devin acp`)
48
+ * reject a relative `cwd` with `-32602 Invalid params` — this is always
49
+ * resolved to an absolute path before it is sent. Default: `process.cwd()`. */
50
+ cwd?: string
51
+ /** Extra environment variables for the child, merged over the current
52
+ * process environment. */
53
+ env?: Record<string, string>
54
+ /** ACP `initialize` protocolVersion. Default `1`. */
55
+ protocolVersion?: number
56
+ /** Safety-net timeout (ms) for one `session/prompt` turn — NOT specifically
57
+ * for the permission handshake, which this client always answers instantly.
58
+ * It exists only so a broken/unresponsive agent cannot hang a caller
59
+ * forever. Default 30000. */
60
+ permissionTimeoutMs?: number
61
+ /** Optional `session/set_mode` sent once, right after `session/new`. */
62
+ mode?: string
63
+ }
64
+
65
+ /** One warm ACP connection: one child process, one session, alive across many
66
+ * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
67
+ * wants, so it plugs straight into {@link createInProcessClient}. */
68
+ export interface ACPClient {
69
+ generate: (request: InProcessRequest) => Promise<InProcessResponse>
70
+ /** Terminates the agent process. Idempotent — a second call is a no-op. */
71
+ close: () => Promise<void>
72
+ }
73
+
74
+ /** The literal marker every rendered prompt ends with, naming the current
75
+ * turn as the one to answer. Kept as a named constant (not just inlined)
76
+ * because the exact wording is shared, by convention, with the other
77
+ * language ports and the spike that proved it fixes the stale-answer bug. */
78
+ const SUPERSEDES_MARKER = "SUPERSEDES-ALL-PRIOR:"
79
+
80
+ interface RpcMessage {
81
+ jsonrpc?: string
82
+ id?: string | number
83
+ method?: string
84
+ params?: any
85
+ result?: any
86
+ error?: { code: number; message: string }
87
+ }
88
+
89
+ function renderContentPart(part: any): string {
90
+ if (typeof part === "string") return part
91
+ if (part == null) return ""
92
+ if (typeof part === "object" && "text" in part && typeof part.text === "string") return part.text
93
+ return JSON.stringify(part)
94
+ }
95
+
96
+ /** Flattens `content` (a string, or an array of content parts) to plain text. */
97
+ function renderContent(content: any): string {
98
+ if (typeof content === "string") return content
99
+ if (Array.isArray(content)) return content.map(renderContentPart).join(" ")
100
+ return renderContentPart(content)
101
+ }
102
+
103
+ /** Renders the FULL assembled request as one prompt string — role + content
104
+ * per message — then appends the supersedes marker naming the latest user
105
+ * turn, per ADR 0031's proposed (and spike-confirmed) default: full request
106
+ * every turn, not a delta, so this stays stateless like every other
107
+ * toolnexus model source and never risks a shadow transcript drifting from
108
+ * the caller's own conversation state. */
109
+ function renderPrompt(request: InProcessRequest): string {
110
+ const messages = request.messages ?? []
111
+ const lines = messages.map((m) => `${m?.role ?? "user"}: ${renderContent(m?.content)}`)
112
+
113
+ let latest = ""
114
+ for (let i = messages.length - 1; i >= 0; i--) {
115
+ if (messages[i]?.role === "user") {
116
+ latest = renderContent(messages[i].content)
117
+ break
118
+ }
119
+ }
120
+ if (!latest && messages.length > 0) latest = renderContent(messages[messages.length - 1]?.content)
121
+
122
+ lines.push(`${SUPERSEDES_MARKER} ${latest}`)
123
+ return lines.join("\n")
124
+ }
125
+
126
+ /**
127
+ * Opens one ACP connection: spawns `command`, performs `initialize` →
128
+ * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
129
+ * trap real `devin acp` enforces with `-32602` otherwise) → an optional
130
+ * `session/set_mode`, then returns a client whose `.generate` sends exactly
131
+ * one `session/prompt` per call, accumulating only `agent_message_chunk`
132
+ * text.
133
+ */
134
+ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
135
+ const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd()
136
+ const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000
137
+
138
+ const child = spawn(opts.command, opts.args ?? [], {
139
+ cwd,
140
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
141
+ stdio: ["pipe", "pipe", "ignore"],
142
+ })
143
+
144
+ let closed = false
145
+ let nextId = 0
146
+ const pending = new Map<string, (msg: RpcMessage) => void>()
147
+ let updateSink: ((params: any) => void) | null = null
148
+
149
+ const rl = createInterface({ input: child.stdout! })
150
+ rl.on("line", (raw) => {
151
+ const line = raw.trim()
152
+ if (!line) return
153
+ let msg: RpcMessage
154
+ try {
155
+ msg = JSON.parse(line)
156
+ } catch {
157
+ return
158
+ }
159
+ handleLine(msg)
160
+ })
161
+
162
+ function write(msg: Record<string, unknown>): void {
163
+ child.stdin!.write(JSON.stringify(msg) + "\n")
164
+ }
165
+
166
+ function reply(id: unknown, result: unknown): void {
167
+ write({ jsonrpc: "2.0", id, result })
168
+ }
169
+
170
+ function call(method: string, params: unknown): Promise<any> {
171
+ const id = `c-${++nextId}`
172
+ 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)
176
+ })
177
+ write({ jsonrpc: "2.0", id, method, params })
178
+ })
179
+ }
180
+
181
+ /** Demultiplexes EVERYTHING arriving on stdout: replies to our requests
182
+ * (matched by JSON-RPC id), `session/update` notifications (interleaved
183
+ * with replies, routed to whichever turn is in flight), and
184
+ * server-initiated requests like `session/request_permission`. */
185
+ function handleLine(msg: RpcMessage): void {
186
+ if (msg.method === undefined && msg.id !== undefined) {
187
+ const cb = pending.get(String(msg.id))
188
+ if (cb) {
189
+ pending.delete(String(msg.id))
190
+ cb(msg)
191
+ }
192
+ return
193
+ }
194
+ if (msg.method === "session/update") {
195
+ updateSink?.(msg.params)
196
+ return
197
+ }
198
+ if (msg.method === "session/request_permission" && msg.id !== undefined) {
199
+ answerPermission(msg)
200
+ return
201
+ }
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, {})
205
+ }
206
+
207
+ /** Answers with the FIRST option whose `kind` starts with `allow` — an
208
+ * unanswered permission request hangs the turn forever (proven in
209
+ * spikes/acp). This runs the instant the request arrives, never waiting
210
+ * for anything else. */
211
+ function answerPermission(req: RpcMessage): void {
212
+ const options: Array<{ optionId: string; kind?: string }> = req.params?.options ?? []
213
+ const chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"))
214
+ const result = chosen
215
+ ? { outcome: { outcome: "selected", optionId: chosen.optionId } }
216
+ : { outcome: { outcome: "cancelled" } }
217
+ reply(req.id, result)
218
+ }
219
+
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: [] })
226
+ const sessionId = sessionResult?.sessionId
227
+
228
+ if (opts.mode) {
229
+ await call("session/set_mode", { sessionId, modeId: opts.mode })
230
+ }
231
+
232
+ // Serialises turns: one ACP session is one conversation, so concurrent
233
+ // prompts must not interleave into it. Idiomatic promise-chaining queue —
234
+ // each generate() call attaches after whatever is already queued, and the
235
+ // queue itself never rejects (a failed turn does not poison later ones).
236
+ let turnQueue: Promise<unknown> = Promise.resolve()
237
+
238
+ async function promptOnce(request: InProcessRequest): Promise<InProcessResponse> {
239
+ const text = renderPrompt(request)
240
+ const id = `c-${++nextId}`
241
+
242
+ let content = ""
243
+ updateSink = (params: any) => {
244
+ const update = params?.update
245
+ if (update?.sessionUpdate === "agent_message_chunk") {
246
+ content += update?.content?.text ?? ""
247
+ }
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
+ }
252
+
253
+ const replyPromise = new Promise<RpcMessage>((resolve) => pending.set(id, resolve))
254
+ write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } })
255
+
256
+ let timer: NodeJS.Timeout | undefined
257
+ const timeout = new Promise<never>((_, reject) => {
258
+ timer = setTimeout(
259
+ () => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)),
260
+ permissionTimeoutMs,
261
+ )
262
+ })
263
+
264
+ try {
265
+ const msg = await Promise.race([replyPromise, timeout])
266
+ if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
267
+ return { content }
268
+ } finally {
269
+ clearTimeout(timer)
270
+ pending.delete(id)
271
+ updateSink = null
272
+ }
273
+ }
274
+
275
+ const generate = (request: InProcessRequest): Promise<InProcessResponse> => {
276
+ if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
277
+ const turn = turnQueue.then(() => promptOnce(request))
278
+ turnQueue = turn.then(
279
+ () => undefined,
280
+ () => undefined,
281
+ )
282
+ return turn
283
+ }
284
+
285
+ const close = (): Promise<void> => {
286
+ if (closed) return Promise.resolve()
287
+ closed = true
288
+ 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
+ })
301
+ }
302
+
303
+ const client: ACPClient = { generate, close }
304
+ // Non-enumerable, undocumented — internal test hook only, so the public
305
+ // ACPClient surface stays exactly {generate, close}.
306
+ Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
307
+ return client
308
+ }
@@ -11,7 +11,7 @@ import type { PromptInput } from "../content.js"
11
11
  import fs from "node:fs"
12
12
  import { defineTool } from "../native.js"
13
13
  import type { Answer, Request, Tool } from "../types.js"
14
- import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
14
+ import { AgentRuntime, isVerbError, S, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
15
15
  import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
16
16
  import type { ClientOptions } from "../client.js"
17
17
  import type { Toolkit } from "../toolkit.js"
@@ -32,7 +32,18 @@ export interface AgentSpec {
32
32
  budget?: Budget
33
33
  /** Model id; default "inherit" (the runtime's `llm.model`). */
34
34
  model?: string
35
- /** §10 interpreter authority for this agent's subtree. */
35
+ /** §10 interpreter authority for this agent's subtree.
36
+ *
37
+ * IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
38
+ * turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
39
+ * REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
40
+ * itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
41
+ * bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
42
+ * side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
43
+ * responsibility. Any tool reachable in a suspendable turn must be idempotent.
44
+ * Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
45
+ * remove this requirement anyway.
46
+ */
36
47
  waitFor?: (request: Request) => Promise<Answer>
37
48
  onSpawn?: AgentDef["onSpawn"]
38
49
  onClose?: AgentDef["onClose"]
@@ -118,7 +129,8 @@ export class Agent {
118
129
  const runtime = this.createRuntime(opts)
119
130
  const h = runtime.spawn(runtime.root, this.name)
120
131
  if (isVerbError(h)) {
121
- return { text: h.error, isError: true, status: "error", turns: 0, totalTokens: 0, runtime }
132
+ // A spawn refusal is an ERROR, not a limit stop: it names no `limit` (A18).
133
+ return { text: h.error, isError: true, status: S.error, turns: 0, totalTokens: 0, ownTokens: 0, runtime }
122
134
  }
123
135
  const woke = runtime.wake(h, prompt)
124
136
  if (!woke.ok) {