teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,7 +1,3 @@
1
- /**
2
- * Tool execution: file ops, shell (with process-group kill + timeout), git.
3
- * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
- */
5
1
  import { spawn } from "node:child_process";
6
2
  import { existsSync, promises as fs, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
7
3
  import path from "node:path";
@@ -20,16 +16,6 @@ function clip(s, max) {
20
16
  async function readText(p) {
21
17
  return fs.readFile(p, "utf8");
22
18
  }
23
- /**
24
- * Is this file binary, i.e. not something `read_file` should return as text?
25
- *
26
- * `readText` decodes with "utf8" unconditionally, so pointing `read_file` at an image returned the
27
- * raw bytes as a lossy string — reported as "画像をread_fileしてバイナリが帰ってきてる". The model then
28
- * spends tokens on unreadable mojibake and cannot tell it is looking at data rather than source.
29
- *
30
- * The test is the classic one, applied to the FIRST block only: a NUL byte means "not text". Reads
31
- * are bounded, because deciding costs a file read and the answer never needs the whole file.
32
- */
33
19
  export async function looksBinary(p) {
34
20
  const fh = await fs.open(p, "r");
35
21
  try {
@@ -37,14 +23,6 @@ export async function looksBinary(p) {
37
23
  const head = Buffer.alloc(Math.min(4096, Math.max(0, st.size)));
38
24
  if (head.length)
39
25
  await fh.read(head, 0, head.length, 0);
40
- /**
41
- * MAGIC BYTES FIRST, then the NUL test.
42
- *
43
- * A NUL scan alone missed PDF and zip: their headers contain no zero byte in the first few
44
- * bytes, so `%PDF-1.4` and `PK\x03\x04` both read as "text" — MEASURED, and it would have
45
- * shipped a silent hole. A known signature is proof regardless of what the bytes happen to
46
- * contain; the NUL test is the fallback for everything unrecognised.
47
- */
48
26
  const kind = sniff(head);
49
27
  if (kind)
50
28
  return { binary: true, bytes: st.size, kind };
@@ -54,7 +32,6 @@ export async function looksBinary(p) {
54
32
  await fh.close();
55
33
  }
56
34
  }
57
- /** the guessed format from magic bytes, or null when nothing is recognised */
58
35
  function sniff(head) {
59
36
  const hex = (n) => head.subarray(0, n).toString("hex");
60
37
  if (hex(8).startsWith("89504e47"))
@@ -75,13 +52,6 @@ function sniff(head) {
75
52
  return "RTF document";
76
53
  return null;
77
54
  }
78
- /**
79
- * Persist a `write_file` payload and report what line endings it used (#84/#109).
80
- *
81
- * Extracted so the two paths — a file whose own EOL was detected, and one decided
82
- * by `.editorconfig` — share one write. The message names CRLF/CR only when a
83
- * conversion actually happened, so an ordinary LF write stays quiet.
84
- */
85
55
  async function finishWrite(p, out, eol, original) {
86
56
  await fs.writeFile(p, out, "utf8");
87
57
  const converted = eol !== null && eol !== "\n" && out !== original;
@@ -91,20 +61,13 @@ async function finishWrite(p, out, eol, original) {
91
61
  (converted ? ` (kept ${eol === "\r\n" ? "CRLF" : "CR"} line endings)` : ""),
92
62
  };
93
63
  }
94
- /**
95
- * The file's dominant line ending, or null when it is mixed or empty.
96
- *
97
- * `null` means "do not normalise" — see the note above.
98
- */
99
64
  export function detectEol(text) {
100
65
  if (!text)
101
66
  return null;
102
67
  const crlf = (text.match(/\r\n/g) ?? []).length;
103
- // a bare CR is a CR not followed by LF
104
68
  const cr = (text.match(/\r(?!\n)/g) ?? []).length;
105
69
  const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
106
70
  const kinds = [crlf, cr, lf].filter((n) => n > 0).length;
107
- // one kind only -> unambiguous. Mixed -> null.
108
71
  if (kinds !== 1)
109
72
  return null;
110
73
  if (crlf > 0)
@@ -113,71 +76,22 @@ export function detectEol(text) {
113
76
  return "\r";
114
77
  return "\n";
115
78
  }
116
- /** Split on ANY of the three line endings, dropping the terminators. */
117
79
  export function splitEolLines(text) {
118
80
  return text.split(/\r\n|\r|\n/);
119
81
  }
120
- /**
121
- * Re-apply `eol` to content the model supplied.
122
- *
123
- * Models emit `\n` essentially always, so a CRLF or CR file rewritten with LF
124
- * content would otherwise be silently converted. Untouched terminators already
125
- * in the right form are left alone, so this is safe to run on merged content.
126
- */
127
82
  export function applyEol(content, eol) {
128
83
  if (!eol || eol === "\n")
129
84
  return content;
130
- // normalise to \n first, then re-apply — avoids double-CR on "\r\n"
131
85
  return content.replace(/\r\n|\r|\n/g, eol);
132
86
  }
133
- /**
134
- * Optimistic-concurrency guard. The model reads a file, thinks, then
135
- * writes — and in between, a sub-agent, a background job or the operator may
136
- * have changed it. Anchored edits usually fail safe (old_text is gone, so the
137
- * call errors), but two paths silently clobber work nobody looked at:
138
- * `replace_all` rewrites every occurrence present NOW, including ones that
139
- * appeared after the model formed its plan, and write_file/apply_patch replace
140
- * whole files unconditionally.
141
- *
142
- * A caller that passes `base_content` (the bytes it actually read) therefore
143
- * gets a refusal instead of a lost update — the same contract the web file
144
- * editor already enforces (see api.ts PUT /file). The check is advisory by
145
- * design: omitting the parameter keeps today's behaviour, so this can never
146
- * break a working agent loop that does not use it.
147
- */
148
87
  function staleGuard(pathArg, base, current) {
149
88
  if (typeof base !== "string")
150
- return null; // not supplied → no check
151
- /**
152
- * A base_content that cannot BE the file is not evidence of a change.
153
- *
154
- * MEASURED in tyb2-9-8155867c: four `edit_file` calls, two of which passed `base_content: "x"` —
155
- * a single byte — and were refused with "changed on disk since you read it … your base_content: 1
156
- * bytes, on disk now: 52351 bytes". The guard was right that the bytes differ and useless about
157
- * it: nothing in the file's history can make "x" the content of a 52KB script, so the message sent
158
- * the model to re-read a file it had never successfully checked against.
159
- *
160
- * A guard whose input cannot be the thing it guards is noise. Below a plausible-file floor the
161
- * value is treated as not supplied — the edit still has to pass the anchor checks, which is where
162
- * a stale read is actually caught.
163
- */
164
- /**
165
- * A base_content that is a tiny FRACTION of the file cannot be its content.
166
- *
167
- * MEASURED in tyb2-9-8155867c: two of four `edit_file` calls passed `base_content: "x"` — one
168
- * byte — and were refused with "changed on disk since you read it … your base_content: 1 bytes, on
169
- * disk now: 52351 bytes". The guard was right that the bytes differ and useless about it: nothing
170
- * can make "x" the content of a 52KB script, so the message sent the model to re-read a file whose
171
- * state was never in question.
172
- *
173
- * The test is "unmistakably not this file", NOT "short": a legitimately small file edited by a
174
- * model that re-read it must keep the guard, which is what the two #5 tests would have lost.
175
- */
89
+ return null;
176
90
  const MIN_PLAUSIBLE_BASE = 16;
177
91
  if (base.length < MIN_PLAUSIBLE_BASE && current !== null && current.length >= MIN_PLAUSIBLE_BASE * 4)
178
92
  return null;
179
93
  if (base === current)
180
- return null; // unchanged → proceed
94
+ return null;
181
95
  const rel = pathArg;
182
96
  return {
183
97
  ok: false,
@@ -186,39 +100,15 @@ function staleGuard(pathArg, base, current) {
186
100
  `Re-read ${rel}, redo the edit against the current content, and pass the fresh text as base_content.`,
187
101
  };
188
102
  }
189
- /** Read the file for a guarded write, or null when it does not exist. */
190
103
  async function currentOrNull(p) {
191
104
  return fs.readFile(p, "utf8").catch(() => null);
192
105
  }
193
- /** Resolve a path inside the workspace; reject escapes (incl. symlink targets).
194
- * KNOWN LIMIT (review finding, accepted): the check-then-open pattern has a
195
- * TOCTOU window — a symlink swapped in between safeJoin() and fs.open() could
196
- * point outside the workspace. Closing it needs openat(2)/O_NOFOLLOW at every
197
- * call site; the operator-facing risk is low (requires workspace write access
198
- * already), so we document rather than rewrite all call sites. */
199
106
  export function safeJoin(cwd, p) {
200
107
  const abs = path.resolve(cwd, p);
201
108
  const rel = path.relative(cwd, abs);
202
109
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
203
110
  throw new Error(`path escapes workspace: ${p}`);
204
111
  }
205
- // symlinks inside the workspace can point anywhere — resolve the real target
206
- // and confine THAT too (path.resolve alone doesn't follow links)
207
- //
208
- // the old code resolved `abs` and swallowed a throw, treating failure as
209
- // "fine". That is exactly backwards, and the failure is the NORMAL case:
210
- // `realpathSync` throws ENOENT for a file that does not exist yet, which is
211
- // every write_file creating something new. So `real` stayed as the LEXICAL
212
- // path — which is inside the workspace — and the symlink guard never ran.
213
- //
214
- // Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
215
- // `esc/pwned.txt` and write_file wrote straight through it, landing outside
216
- // the workspace. The dangling case happened to fail later with ENOTDIR, which
217
- // is luck, not a control.
218
- //
219
- // So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
220
- // giving up. That still catches the escape — the ancestor IS the symlink —
221
- // while allowing genuinely new files inside the workspace.
222
112
  let real = abs;
223
113
  try {
224
114
  real = realpathSync(abs);
@@ -226,11 +116,6 @@ export function safeJoin(cwd, p) {
226
116
  catch (err) {
227
117
  const code = err.code;
228
118
  if (code === "ENOENT" || code === "ENOTDIR") {
229
- // Walk up one component at a time. A component that is itself a SYMLINK
230
- // must be resolved even when its target does not exist — realpathSync
231
- // throws ENOENT for the dangling link itself, so naively walking to the
232
- // first existing ancestor steps OVER the symlink and loses it. So read
233
- // the link and check its target directly.
234
119
  let probe = path.dirname(abs);
235
120
  for (;;) {
236
121
  try {
@@ -240,22 +125,19 @@ export function safeJoin(cwd, p) {
240
125
  catch (inner) {
241
126
  if (inner.code !== "ENOENT")
242
127
  break;
243
- // this component is a dangling symlink: resolve where it POINTS
244
128
  try {
245
129
  const target = readlinkSync(probe);
246
130
  real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
247
131
  break;
248
132
  }
249
133
  catch {
250
- /* not a symlink after all — keep walking */
251
134
  }
252
135
  const next = path.dirname(probe);
253
136
  if (next === probe)
254
- break; // reached the filesystem root
137
+ break;
255
138
  probe = next;
256
139
  }
257
140
  }
258
- // keep the un-resolved tail so the comparison below sees the full path
259
141
  const tail = path.relative(probe, abs);
260
142
  if (tail && !tail.startsWith(".."))
261
143
  real = path.join(real, tail);
@@ -267,10 +149,8 @@ export function safeJoin(cwd, p) {
267
149
  }
268
150
  return abs;
269
151
  }
270
- /** per-workspace background shells (key: workspace path) */
271
152
  const bgShellsByWs = new Map();
272
153
  let bgSeq = 0;
273
- /** how much of each job's output has already been handed to the model */
274
154
  const bgReadCursors = new Map();
275
155
  function bgMapFor(cwd) {
276
156
  let m = bgShellsByWs.get(cwd);
@@ -280,32 +160,10 @@ function bgMapFor(cwd) {
280
160
  }
281
161
  return m;
282
162
  }
283
- /**
284
- * Environment for every shell the agent runs.
285
- *
286
- * `PWD` is deliberately removed: the server's own PWD would contradict the
287
- * `cwd` we spawn with, so a command trusting `$PWD` would act on the wrong
288
- * directory. `bash` re-creates PWD from its actual cwd.
289
- */
290
163
  function shellEnv() {
291
164
  const { PWD: _pwd, ...rest } = process.env;
292
165
  return { ...rest, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" };
293
166
  }
294
- /**
295
- * The shell to run a `bash` tool command in, per platform.
296
- *
297
- * Both call sites used to hardcode `/bin/bash`, so on Windows every `bash` tool invocation
298
- * failed with ENOENT — the tool was entirely unavailable on a platform this package claims to
299
- * support. Windows has no `/bin/bash`; `cmd.exe` is always present, and Git for Windows (which
300
- * the CI runner has) provides `bash.exe` on PATH.
301
- *
302
- * Preference order on Windows: `%TEAPOT_SHELL%`, then `bash.exe` if it is on PATH, then
303
- * `cmd.exe`. `bash.exe` first because teapot's tool prompts and most commands the model writes
304
- * are POSIX (`&&`, `$VAR`, single quotes); a `cmd.exe` fallback keeps the tool working on a bare
305
- * install rather than failing outright.
306
- *
307
- * POSIX is unchanged: `$SHELL`, else `/bin/bash`.
308
- */
309
167
  export function toolShellFor(platform, env, exists = existsSync) {
310
168
  const override = env.TEAPOT_SHELL?.trim();
311
169
  if (override)
@@ -323,23 +181,13 @@ export function toolShellFor(platform, env, exists = existsSync) {
323
181
  return { file: "bash", args: (cmd) => ["-c", cmd] };
324
182
  return { file: env.COMSPEC || "cmd.exe", args: (cmd) => ["/d", "/s", "/c", cmd] };
325
183
  }
326
- /** the shell for THIS process — see `toolShellFor` for the rules and why they exist */
327
184
  function toolShell() {
328
185
  return toolShellFor(process.platform, process.env);
329
186
  }
330
- /**
331
- * Human-readable elapsed-time suffix appended to a shell result so the MODEL
332
- * sees how long a command actually took. Only the operator saw it — the
333
- * model happily re-issued commands that ran for tens of minutes.
334
- *
335
- * Kept terse and single-line: it rides along with the tool output, and mirrors
336
- * what bash_output already prints for a still-running job.
337
- */
338
187
  export function elapsed(t0, now = Date.now()) {
339
188
  return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
340
189
  }
341
190
  const jobsFile = (dir) => path.join(dir, "bg-jobs.json");
342
- /** read the persisted records, tolerating a missing or corrupt file (never throws) */
343
191
  function readJobRecords(dir) {
344
192
  try {
345
193
  const raw = readFileSync(jobsFile(dir), "utf8");
@@ -350,39 +198,22 @@ function readJobRecords(dir) {
350
198
  return [];
351
199
  }
352
200
  }
353
- /**
354
- * Write the records atomically enough for this purpose.
355
- *
356
- * A torn write here is not worth a fsync: the file is a hint for the NEXT boot, and the worst case is
357
- * losing one restart's worth of "this was running". Losing a job's identity entirely (the shipped
358
- * behaviour) is what this exists to stop.
359
- */
360
201
  function writeJobRecords(dir, records) {
361
202
  try {
362
203
  mkdirSync(dir, { recursive: true });
363
204
  writeFileSync(jobsFile(dir), JSON.stringify(records, null, 1));
364
205
  }
365
206
  catch {
366
- /* a read-only session dir must not break the tool */
367
207
  }
368
208
  }
369
- /** add or update one record (by id) */
370
209
  function rememberJob(dir, rec) {
371
210
  const all = readJobRecords(dir).filter((r) => r.id !== rec.id);
372
211
  all.push(rec);
373
212
  writeJobRecords(dir, all);
374
213
  }
375
- /** drop a record — the job finished AND was reported, or was killed */
376
214
  function forgetJob(dir, id) {
377
215
  writeJobRecords(dir, readJobRecords(dir).filter((r) => r.id !== id));
378
216
  }
379
- /**
380
- * Jobs a PREVIOUS process started and whose processes may still be alive.
381
- *
382
- * `alive` is answered by the caller (pidExists) rather than here, so this module stays free of the
383
- * signal-plumbing and the test can drive either answer. A record whose pid is gone is stale and is
384
- * dropped: reporting a finished job as running is the same class of lie as forgetting a live one.
385
- */
386
217
  export function recoverableJobs(dir, alive) {
387
218
  const all = readJobRecords(dir);
388
219
  const live = [];
@@ -396,14 +227,11 @@ export function recoverableJobs(dir, alive) {
396
227
  }
397
228
  function startBackgroundShell(cmd, ctx) {
398
229
  const map = bgMapFor(ctx.cwd);
399
- // opportunistic cleanup of long-dead jobs so the map can't grow forever
400
230
  for (const [id, sh] of map) {
401
231
  if (sh.exited && Date.now() - sh.startedAt > 30 * 60_000 && !map.delete(id))
402
232
  void id;
403
233
  }
404
234
  const id = `bg${++bgSeq}`;
405
- // plain `-c`, not `-lc`: a login shell sources ~/.bash_profile, whose `cd`
406
- // would move background jobs out of the workspace
407
235
  const shellSpec = toolShell();
408
236
  const child = spawn(shellSpec.file, shellSpec.args(cmd), {
409
237
  cwd: ctx.cwd,
@@ -423,13 +251,6 @@ function startBackgroundShell(cmd, ctx) {
423
251
  else
424
252
  sh.truncated = true;
425
253
  };
426
- /**
427
- * Persist the RECORD, so a later process can at least say this job exists.
428
- *
429
- * The `pid` is what makes it checkable on boot: a job whose pid is gone is dropped, one whose pid
430
- * is alive is reported as "still running, output no longer reachable". That is the honest answer
431
- * after a restart — see `ToolContext.stateDir`.
432
- */
433
254
  if (ctx.stateDir) {
434
255
  rememberJob(ctx.stateDir, {
435
256
  id, cmd, owner: ctx.agentId ?? "", workspace: ctx.cwd,
@@ -439,18 +260,6 @@ function startBackgroundShell(cmd, ctx) {
439
260
  }
440
261
  child.stdout?.on("data", collect);
441
262
  child.stderr?.on("data", collect);
442
- // "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
443
- // A backgrounded grandchild that inherits stdout — `npm run dev &`,
444
- // `docker compose up &`, a script that forks a watcher — keeps the pipe open,
445
- // so "close" can stay pending long after the shell itself is dead. Keying
446
- // the exit state off "close" alone left such jobs marked running FOREVER:
447
- // hasRunningBgShells() said yes, the auto-continue loop kept breaking out on
448
- // "background job still running", and the agent sat in "running" with the
449
- // job long dead and no notification ever delivered.
450
- //
451
- // So: "exit" is authoritative for state and for the completion
452
- // notification; "close" only means "output finally drained" and is used to
453
- // capture the tail. The notify() helper keeps this exactly-once.
454
263
  let exitNotified = false;
455
264
  const notify = (code) => {
456
265
  sh.exited = true;
@@ -459,10 +268,8 @@ function startBackgroundShell(cmd, ctx) {
459
268
  if (exitNotified)
460
269
  return;
461
270
  exitNotified = true;
462
- // the job is over: its record must not claim otherwise on the next boot
463
271
  if (ctx.stateDir)
464
272
  forgetJob(ctx.stateDir, id);
465
- // tell the harness so the agent learns the outcome WITHOUT polling
466
273
  try {
467
274
  ctx.onBackgroundExit?.({
468
275
  id,
@@ -473,7 +280,6 @@ function startBackgroundShell(cmd, ctx) {
473
280
  });
474
281
  }
475
282
  catch {
476
- /* notification is best-effort */
477
283
  }
478
284
  };
479
285
  child.on("exit", (code) => notify(code));
@@ -481,42 +287,14 @@ function startBackgroundShell(cmd, ctx) {
481
287
  map.set(id, sh);
482
288
  return id;
483
289
  }
484
- /** Heuristic: did this provider error mean "prompt exceeded the context window"?
485
- * Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
486
290
  export function isContextOverflow(err) {
487
291
  const msg = String(err?.message ?? "").toLowerCase();
488
292
  return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
489
293
  /\b(400|413)\b.*token/.test(msg) ||
490
- // some gateways answer an over-long request with HTTP 200 and put the
491
- // real diagnosis in the body, which llm.ts now surfaces after the "no
492
- // choices" prefix. Without this the compact-and-retry recovery never
493
- // recognised it and the turn simply failed.
494
294
  /no choices:[\s\S]{0,300}context (length|window|limit)/.test(msg));
495
295
  }
496
- /** Any background shell still running for this workspace? The auto-continue
497
- * loop consults this before nudging a finished round: an agent that parked
498
- * its work on `bash(background=true)` — dev server, watcher, long build —
499
- * must NOT be nagged with "Continue working toward the current goal". The
500
- * job's exit notification will wake the agent on its own. */
501
- /**
502
- * The background shells for a workspace, as the UI sees them.
503
- *
504
- * Only a VIEW: no child process, no cursors. The `out` field is the tail kept for `bash_output`, so
505
- * showing it costs nothing extra — the buffer is already there.
506
- *
507
- * `owner` is included because several agents share one workspace (every sub-agent inherits its
508
- * parent's), and "whose job is this" is the first thing an operator asks when deciding what to kill.
509
- */
510
296
  export function listBgShells(cwd, stateDir) {
511
297
  const live = liveShells(cwd);
512
- /**
513
- * Records from a previous process are appended, marked `detached`.
514
- *
515
- * Without them a job that survived a restart is INVISIBLE — MEASURED: a 3-hour GPU sweep kept
516
- * writing while the panel showed nothing and `bash_output` said "forgotten on harness restart".
517
- * The process cannot be re-attached (no fd survives the parent), so `detached` is the whole truth
518
- * the UI can be given: it is running, and its output lives wherever the command redirected it.
519
- */
520
298
  if (!stateDir)
521
299
  return live;
522
300
  const known = new Set(live.map((j) => j.id));
@@ -537,7 +315,6 @@ export function listBgShells(cwd, stateDir) {
537
315
  }));
538
316
  return [...live, ...recovered].sort((a, b) => b.startedAt - a.startedAt);
539
317
  }
540
- /** does this pid exist? (EPERM means it exists but is not ours to signal) */
541
318
  function pidExists(pid) {
542
319
  try {
543
320
  process.kill(pid, 0);
@@ -547,7 +324,6 @@ function pidExists(pid) {
547
324
  return err.code === "EPERM";
548
325
  }
549
326
  }
550
- /** the jobs THIS process is running, as the UI sees them */
551
327
  function liveShells(cwd) {
552
328
  const m = bgShellsByWs.get(cwd);
553
329
  if (!m)
@@ -560,22 +336,12 @@ function liveShells(cwd) {
560
336
  running: !b.exited,
561
337
  code: b.code,
562
338
  startedAt: b.startedAt,
563
- // the LAST slice: a job is watched to see what it is doing NOW, and the head of a 10MB log is
564
- // the least interesting part of it
565
339
  tail: b.out.slice(-4000),
566
340
  bytes: b.out.length,
567
341
  truncated: b.truncated,
568
342
  }))
569
343
  .sort((a, b) => b.startedAt - a.startedAt);
570
344
  }
571
- /**
572
- * Kill ONE background job; returns whether it existed.
573
- *
574
- * A DETACHED job (a record from a previous process) is handled differently on purpose: the harness no
575
- * longer owns that process, so this drops the RECORD and does NOT signal the pid. Killing a pid we
576
- * merely remember is how an unrelated process gets killed after pid reuse — and the operator's own
577
- * instruction was to never touch a job from an earlier session. The caller reports what happened.
578
- */
579
345
  export function killBgShell(cwd, id, stateDir) {
580
346
  const m = bgShellsByWs.get(cwd);
581
347
  const b = m?.get(id);
@@ -584,14 +350,12 @@ export function killBgShell(cwd, id, stateDir) {
584
350
  killTree(b.child);
585
351
  }
586
352
  catch {
587
- /* already gone */
588
353
  }
589
354
  m.delete(id);
590
355
  if (stateDir)
591
356
  forgetJob(stateDir, id);
592
357
  return true;
593
358
  }
594
- // not ours any more: forget it, leave the process alone
595
359
  if (stateDir) {
596
360
  const rec = readJobRecords(stateDir).find((r) => r.id === id);
597
361
  if (rec) {
@@ -606,7 +370,6 @@ export function hasRunningBgShells(cwd) {
606
370
  if (!m)
607
371
  return false;
608
372
  for (const sh of m.values()) {
609
- // opportunistic cleanup mirrors startBackgroundShell's
610
373
  if (sh.exited && Date.now() - sh.startedAt > 30 * 60_000) {
611
374
  m.delete(sh.id);
612
375
  continue;
@@ -616,17 +379,6 @@ export function hasRunningBgShells(cwd) {
616
379
  }
617
380
  return false;
618
381
  }
619
- /**
620
- * Does THIS agent have a background job still running?
621
- *
622
- * `hasRunningBgShells(cwd)` answers for a DIRECTORY, which is the right question for
623
- * `killBgShells` and the wrong one for "is this agent busy". Sub-agents inherit their parent's
624
- * workspace, so a shared directory made every sibling of a busy agent report `live: true` —
625
- * their stop buttons offered to stop work that was already gone.
626
- *
627
- * An empty owner means a job started before owners were recorded; those are treated as
628
- * belonging to nobody, so they cannot make an unrelated agent look busy.
629
- */
630
382
  export function hasRunningBgShellsFor(cwd, owner) {
631
383
  const m = bgShellsByWs.get(cwd);
632
384
  if (!m || !owner)
@@ -639,18 +391,6 @@ export function hasRunningBgShellsFor(cwd, owner) {
639
391
  }
640
392
  return false;
641
393
  }
642
- /**
643
- * Kill every background shell for one workspace and forget the jobs.
644
- *
645
- * `stop()` left these running. They are spawned `detached: true` for group-killing
646
- * and are NOT covered by `toolAbort` (that signal only reaches a tool that is
647
- * still on the stack), so pressing stop on an agent that had parked its round on
648
- * a background job killed nothing: the loop sat in its deliberate "wait for the
649
- * job" idle state while the job kept burning CPU, and no control could end it.
650
- *
651
- *
652
- * Returns how many were actually killed, so a caller can report it.
653
- */
654
394
  export function killBgShells(cwd) {
655
395
  const m = bgShellsByWs.get(cwd);
656
396
  if (!m)
@@ -666,19 +406,13 @@ export function killBgShells(cwd) {
666
406
  m.clear();
667
407
  return killed;
668
408
  }
669
- /** Run a command in its own process group; kill the whole group on timeout. */
670
- function runShell(cmd, ctx, timeoutMs,
671
- /** did the caller pass `timeout_ms`, or is this the default? the message says which */
672
- explicitTimeout = false) {
409
+ function runShell(cmd, ctx, timeoutMs, explicitTimeout = false) {
673
410
  return new Promise((resolve) => {
674
411
  const t0 = Date.now();
675
- // `bash -l` sources /etc/profile + ~/.bash_profile, either of which may
676
- // `cd` somewhere — which silently moved every later relative path out of
677
- // the workspace. Plain `-c` starts in ctx.cwd and stays there.
678
412
  const sh = toolShell();
679
413
  const child = spawn(sh.file, sh.args(cmd), {
680
414
  cwd: ctx.cwd,
681
- detached: true, // own process group
415
+ detached: true,
682
416
  stdio: ["ignore", "pipe", "pipe"],
683
417
  env: shellEnv(),
684
418
  });
@@ -691,32 +425,8 @@ explicitTimeout = false) {
691
425
  };
692
426
  child.stdout.on("data", collect);
693
427
  child.stderr.on("data", collect);
694
- // the kill REPORTS whether it worked. It used to swallow every failure
695
- // as "already gone", and on Windows a group kill throws ALWAYS — so a timeout
696
- // would report `TIMEOUT after …ms` for a process that was still running, and
697
- // shutdown would leave it alive. A timeout that claims success is worse than
698
- // a crash, because nothing afterwards looks wrong.
699
428
  const killGroup = () => killTree(child);
700
429
  const timer = setTimeout(() => {
701
- /**
702
- * The message has to teach the agent what it did not know.
703
- *
704
- * It read `TIMEOUT after 120000ms — killed`, which names neither the limit in human terms nor
705
- * the parameter that changes it. MEASURED: an agent that never noticed `timeout_ms` hit this
706
- * repeatedly, retrying the same command. So the message says WHAT the limit was, WHERE it came
707
- * from (default vs explicit), what to pass next time, and when to switch to `background`.
708
- */
709
- /**
710
- * The message has to teach the agent what it did not know.
711
- *
712
- * It read `TIMEOUT after 120000ms — killed`, which names neither the limit in human terms nor
713
- * the parameter that changes it. MEASURED: an agent that never noticed `timeout_ms` hit this
714
- * repeatedly, retrying the same command. So the message says WHAT the limit was, WHERE it came
715
- * from (default vs explicit), what to pass next time, and when to switch to `background`.
716
- *
717
- * The limit keeps its raw `ms` when it is sub-second: "TIMEOUT after 0s" for a 300ms limit
718
- * both reads wrong and loses the precision a caller needs to judge its next attempt.
719
- */
720
430
  const limit = timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
721
431
  killReason =
722
432
  `TIMEOUT after ${limit} — ${describeKill(killGroup())}. ` +
@@ -728,7 +438,6 @@ explicitTimeout = false) {
728
438
  `or use background=true for a job with no timeout at all.`
729
439
  : `If it needs longer than the maximum, use background=true — a background job has no timeout.`);
730
440
  }, timeoutMs);
731
- // harness shutdown must not wait out a long-running command
732
441
  const onAbort = () => {
733
442
  killReason = `ABORTED (harness shutdown) — ${describeKill(killGroup())}`;
734
443
  };
@@ -763,14 +472,11 @@ explicitTimeout = false) {
763
472
  ok: code === 0,
764
473
  result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
765
474
  clip(out.trim() || "(no output)", ctx.maxOutputBytes) +
766
- // the model, not just the operator, needs to know a command was slow —
767
- // otherwise it happily re-issues something that takes 40 minutes
768
475
  elapsed(t0),
769
476
  });
770
477
  });
771
478
  });
772
479
  }
773
- /** 1-based line number of each occurrence of needle in text. */
774
480
  function matchLines(text, needle) {
775
481
  const out = [];
776
482
  let idx = text.indexOf(needle);
@@ -780,10 +486,6 @@ function matchLines(text, needle) {
780
486
  }
781
487
  return out;
782
488
  }
783
- /**
784
- * Fuzzy-but-safe locator: find windows of lines equal to the pattern after
785
- * trimming trailing whitespace on each side. Returns 0-based start line hits.
786
- */
787
489
  function trailingWsMatches(srcLines, patLines) {
788
490
  const hits = [];
789
491
  for (let i = 0; i + patLines.length <= srcLines.length; i++) {
@@ -799,64 +501,20 @@ function trailingWsMatches(srcLines, patLines) {
799
501
  }
800
502
  return hits;
801
503
  }
802
- /**
803
- * Did `anchor` resolve to a span far larger than it asked for? Returns the refusal, or null.
804
- *
805
- * ## Why this is a separate function
806
- *
807
- * The first version was inlined and measured `text.indexOf(anchor)` — which by definition finds
808
- * exactly `anchor.length` characters, so it could NEVER fire, and its test passed with the guard
809
- * disabled. The overshoot does not come from the exact match: it comes from the TOLERANT path,
810
- * where a model that omits indentation or trailing spaces hands over a short anchor and the
811
- * per-line comparison then accepts a much longer region.
812
- *
813
- * So the question is: how many file lines does the anchor's FIRST line's text actually cover
814
- * once tolerance is applied? If that region is more than twice the anchor's own line count, the
815
- * anchor is not specific enough and the edit would rewrite something unintended.
816
- *
817
- * cases are exactly the ones where tolerance decides what matched. Being wrong there is silent.
818
- */
819
504
  export function disproportionateSpan(text, anchor) {
820
505
  const anchorLines = anchor.split("\n");
821
506
  const askedLines = anchorLines.length;
822
507
  if (askedLines < 2)
823
- return null; // a single line cannot span disproportionately
824
- /**
825
- * Do NOT require an exact substring match.
826
- *
827
- * The whole point is the near-miss case: the anchor does not appear verbatim, which is why the
828
- * tolerant path is consulted at all and why it can overshoot. Requiring `indexOf` here made
829
- * the guard unreachable exactly when it matters.
830
- */
508
+ return null;
831
509
  const anchorFirst = anchorLines[0].trimEnd();
832
510
  const allSrcLines = text.split("\n");
833
- /** every place the anchor's first line appears — the BEST one decides, not the first */
834
511
  const starts = [];
835
512
  for (let i = 0; i < allSrcLines.length; i++)
836
513
  if (allSrcLines[i].trimEnd() === anchorFirst)
837
514
  starts.push(i);
838
515
  if (!starts.length)
839
- return null; // its first line is not in the file at all: not this guard's case
840
- /**
841
- * How many file lines does the anchor COVER, allowing the same trailing-whitespace tolerance
842
- * the tolerant matcher uses?
843
- *
844
- * This walks the file and the anchor together, and counts file lines consumed. If matching
845
- * the anchor requires consuming more than twice its own line count, the anchor is too loose:
846
- * applying it would replace a region the model did not point at.
847
- */
516
+ return null;
848
517
  const srcLines = allSrcLines;
849
- /**
850
- * EVERY candidate start is considered, and the guard only fires when ALL of them overshoot.
851
- *
852
- * Probing just the FIRST occurrence was wrong whenever the anchor's opening line is not unique —
853
- * `\tif (pwrseq->skip_power_cycle) {` appears more than once in that file, and the first one led
854
- * the walk into a distant match, so a legitimate 7-line anchor was refused for "covering 58
855
- * lines". MEASURED in tyb2-9-8155867c: the model then abandoned `edit_file` for that region.
856
- *
857
- * The guard's question is "could this anchor be read as pointing somewhere else?" — if ANY
858
- * plausible reading fits inside the tolerance, it cannot.
859
- */
860
518
  let best = Number.POSITIVE_INFINITY;
861
519
  for (const start of starts) {
862
520
  let fileAt = start;
@@ -882,7 +540,7 @@ export function disproportionateSpan(text, anchor) {
882
540
  best = Math.min(best, consumed);
883
541
  }
884
542
  if (!Number.isFinite(best))
885
- return null; // did not resolve under tolerance; not this guard's business
543
+ return null;
886
544
  if (best > askedLines * 2) {
887
545
  return (`old_text spans ${askedLines} line(s) but the nearest reading of it covers ${best} ` +
888
546
  `lines of the file — refusing a disproportionate match. An anchor this loose would ` +
@@ -890,62 +548,21 @@ export function disproportionateSpan(text, anchor) {
890
548
  }
891
549
  return null;
892
550
  }
893
- /**
894
- * How long a `bash` command may run when `timeout_ms` is not given.
895
- *
896
- * The UI imports this (via `frontend/lib/bash-timeout.ts`) so the row shows the SAME cap the server
897
- * enforces. They drifted once: the server applied 120s while the row drew nothing, because the row
898
- * only looked at a `timeout_ms` the model had omitted.
899
- */
900
551
  export const BASH_DEFAULT_TIMEOUT_MS = 120_000;
901
- /** the hard ceiling, applied after any `timeout_ms` the model passes */
902
552
  export const BASH_MAX_TIMEOUT_MS = 600_000;
903
553
  export const DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_MS;
904
- /* ---------- read_url cache ---------- */
905
554
  const URL_CACHE_TTL_MS = 3_600_000;
906
555
  const urlCache = new Map();
907
556
  function clipText(s, max) {
908
557
  const n = Math.max(1000, Math.min(max, 80_000));
909
558
  return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
910
559
  }
911
- /**
912
- * Patterns that backtrack catastrophically.
913
- *
914
- * A nested quantifier — a quantified group whose body is itself quantified, or
915
- * alternation that can match the same text more than one way — makes the engine
916
- * explore exponentially many splits. Measured on this codebase: `(a+)+b` against
917
- * 28 characters of "a" took 3.8s, roughly DOUBLING per character added, and a
918
- * 40-character line would run for days.
919
- *
920
- * There is no timeout on this path: `ctx.defaultTimeoutMs` guards `bash` only,
921
- * so the scan is a synchronous `re.test(line)` with nothing able to interrupt it.
922
- * The agent process blocks and takes the UI and every other agent with it.
923
- *
924
- * A model is poorly placed to reason about backtracking cost and nothing tells it
925
- * not to try, so the pattern is checked rather than trusted. This rejects the
926
- * SHAPE rather than trying to bound the runtime, because a synchronous regex
927
- * cannot be abandoned once `test` starts — the only reliable protection is to
928
- * never compile one.
929
- */
930
560
  const BACKTRACKING_PATTERNS = [
931
- // (a+)+ (a*)* (a+)* (a?)* … — a quantifier wrapping another quantifier
932
561
  { re: /\([^()]*[+*}][^()]*\)\s*[+*{]/, why: "a quantifier nested inside another quantifier" },
933
- // (a|a)+ — alternation whose branches can match the SAME text. Distinct
934
- // branches like (foo|bar)+ are fine and common, so this only fires when two
935
- // branches are literally identical, which is the shape that explodes.
936
562
  { re: /\(([^()|]*)\|\1\)\s*[+*{]/, why: "an alternation with identical branches inside a quantifier" },
937
- // (.*)* (.+)+ — a wildcard quantified inside a quantifier
938
563
  { re: /\(\s*[.][*+]\s*\)\s*[+*{]/, why: "a wildcard quantified inside a quantifier" },
939
564
  ];
940
- /** Above this, the O(lines x pattern) scan itself becomes the cost. */
941
565
  const MAX_PATTERN_LENGTH = 1000;
942
- /**
943
- * Compile a model-supplied regex, refusing ones that can backtrack
944
- * catastrophically.
945
- *
946
- * Returns an error STRING rather than a RegExp so every existing call site —
947
- * which already handles that shape for an invalid pattern — needs no change.
948
- */
949
566
  function compileRegex(pattern, ignoreCase) {
950
567
  if (pattern.length > MAX_PATTERN_LENGTH)
951
568
  return `pattern too long (${pattern.length} chars, max ${MAX_PATTERN_LENGTH}) — narrow it, or grep for a literal substring instead`;
@@ -960,7 +577,6 @@ function compileRegex(pattern, ignoreCase) {
960
577
  return `invalid regex: ${e.message}`;
961
578
  }
962
579
  }
963
- /** Codex seek_sequence: find pattern lines at/after `start`, loosening match rules stepwise. */
964
580
  function seekSequence(lines, pattern, start, eof) {
965
581
  if (pattern.length === 0)
966
582
  return start;
@@ -970,7 +586,6 @@ function seekSequence(lines, pattern, start, eof) {
970
586
  const eqExact = (a, b) => a === b;
971
587
  const eqRstrip = (a, b) => a.trimEnd() === b.trimEnd();
972
588
  const eqTrim = (a, b) => a.trim() === b.trim();
973
- // typographic dashes/quotes/spaces → ASCII, mirroring codex's final pass
974
589
  const normalise = (s) => s
975
590
  .trim()
976
591
  .replace(/[\u2010-\u2015\u2212]/g, "-")
@@ -995,7 +610,6 @@ function seekSequence(lines, pattern, start, eof) {
995
610
  }
996
611
  function parsePatch(patch) {
997
612
  let text = patch.trim();
998
- // lenient: strip a heredoc wrapper (<<EOF … EOF), as models sometimes emit one
999
613
  const lines0 = text.split("\n");
1000
614
  if (lines0.length >= 4 &&
1001
615
  ["<<EOF", "<<'EOF'", '<<"EOF"'].includes(lines0[0].trim()) &&
@@ -1005,12 +619,6 @@ function parsePatch(patch) {
1005
619
  const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
1006
620
  if (lines[0]?.trim() !== "*** Begin Patch")
1007
621
  return `invalid patch: The first line must be '*** Begin Patch'`;
1008
- // Backslash-corruption tripwire: models occasionally emit "\u0000" where a
1009
- // literal backslash belonged (a provider-side JSON escaping failure, seen
1010
- // live as 90× "\u0000sqrt{12}" in a LaTeX-heavy patch). Applying that
1011
- // verbatim writes broken source; the model then burns turns repairing the
1012
- // file (or discards its own work). Reject with an actionable message —
1013
- // retrying the SAME patch won't help, so say what will.
1014
622
  if (text.includes("\\u0000")) {
1015
623
  const count = (text.match(/\\u0000/g) ?? []).length;
1016
624
  return (`invalid patch: contains ${count} × "\\u0000" — this is mangled backslash output, not real content. ` +
@@ -1074,7 +682,7 @@ function parsePatch(patch) {
1074
682
  continue;
1075
683
  }
1076
684
  if (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) {
1077
- cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false }; // implicit first chunk
685
+ cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false };
1078
686
  if (l.startsWith("+"))
1079
687
  cur.newLines.push(l.slice(1));
1080
688
  else if (l.startsWith("-"))
@@ -1095,7 +703,7 @@ function parsePatch(patch) {
1095
703
  }
1096
704
  if (!l.trim()) {
1097
705
  i++;
1098
- continue; // blank between hunks
706
+ continue;
1099
707
  }
1100
708
  return `invalid patch: bad line in Update File '${filePath}': "${l.slice(0, 60)}" (expected ' ', '-', '+' or '@@')`;
1101
709
  }
@@ -1109,7 +717,6 @@ function parsePatch(patch) {
1109
717
  }
1110
718
  return ops;
1111
719
  }
1112
- /** Compute the updated content of one file (no I/O writes). Error string on failure. */
1113
720
  async function deriveUpdate(p, displayPath, chunks) {
1114
721
  let raw;
1115
722
  try {
@@ -1121,7 +728,7 @@ async function deriveUpdate(p, displayPath, chunks) {
1121
728
  const hadCrlf = raw.includes("\r\n");
1122
729
  const originalLines = raw.split("\n");
1123
730
  if (originalLines.at(-1) === "")
1124
- originalLines.pop(); // trailing newline → diff-standard line list
731
+ originalLines.pop();
1125
732
  const replacements = [];
1126
733
  let lineIndex = 0;
1127
734
  for (const ch of chunks) {
@@ -1134,13 +741,11 @@ async function deriveUpdate(p, displayPath, chunks) {
1134
741
  let pattern = ch.oldLines;
1135
742
  let newSlice = ch.newLines;
1136
743
  if (pattern.length === 0) {
1137
- // codex semantics: a chunk with no context/removed lines appends at end of file
1138
744
  replacements.push([originalLines.length, 0, newSlice]);
1139
745
  continue;
1140
746
  }
1141
747
  let found = seekSequence(originalLines, pattern, lineIndex, ch.isEndOfFile);
1142
748
  if (found == null && pattern.at(-1) === "") {
1143
- // trailing "" usually represents the file's final newline sentinel
1144
749
  const p2 = pattern.slice(0, -1);
1145
750
  const n2 = newSlice.at(-1) === "" ? newSlice.slice(0, -1) : newSlice;
1146
751
  found = seekSequence(originalLines, p2, lineIndex, ch.isEndOfFile);
@@ -1155,7 +760,7 @@ async function deriveUpdate(p, displayPath, chunks) {
1155
760
  replacements.push([found, pattern.length, newSlice]);
1156
761
  lineIndex = found + pattern.length;
1157
762
  }
1158
- replacements.sort((a, b) => b[0] - a[0]); // descending so earlier edits keep indices valid
763
+ replacements.sort((a, b) => b[0] - a[0]);
1159
764
  const out = originalLines.slice();
1160
765
  for (const [startIdx, oldLen, seg] of replacements)
1161
766
  out.splice(startIdx, oldLen, ...seg);
@@ -1169,7 +774,6 @@ async function applyPatch(patch, ctx) {
1169
774
  return { ok: false, result: ops };
1170
775
  if (ops.length === 0)
1171
776
  return { ok: false, result: "patch contains no file operations" };
1172
- // resolve every path up front (workspace confinement + duplicate guard)
1173
777
  const seen = new Set();
1174
778
  const resolved = [];
1175
779
  try {
@@ -1191,7 +795,6 @@ async function applyPatch(patch, ctx) {
1191
795
  catch (e) {
1192
796
  return { ok: false, result: e.message };
1193
797
  }
1194
- // phase 1 — validate everything, write nothing
1195
798
  const writes = [];
1196
799
  const deletes = [];
1197
800
  const summary = [];
@@ -1226,20 +829,6 @@ async function applyPatch(patch, ctx) {
1226
829
  catch (e) {
1227
830
  return { ok: false, result: `patch validation failed: ${e.message}` };
1228
831
  }
1229
- // phase 2 — commit.
1230
- //
1231
- // Phase 1 validates every hunk in memory, so a CONTENT mismatch never
1232
- // lands halfway. An I/O failure still could: the old loop wrote straight
1233
- // to the final paths, so a failure on write k of n left writes 1..k-1
1234
- // already on disk while the tool reported "patch failed". apply_patch is
1235
- // documented as atomic, so make the commit phase live up to it:
1236
- // 1. back up the current bytes of every path we are about to change
1237
- // 2. write each new file to a sibling temp file, then rename() over the
1238
- // target (rename within a directory is atomic, so a reader never
1239
- // observes a half-written file)
1240
- // 3. on ANY failure, restore the backups and remove temp files
1241
- // Deletes are only performed once every write has landed, and a failed
1242
- // delete is now reported instead of silently swallowed.
1243
832
  const backups = new Map();
1244
833
  const temps = [];
1245
834
  try {
@@ -1261,9 +850,6 @@ async function applyPatch(patch, ctx) {
1261
850
  "re-read the files and regenerate the patch",
1262
851
  };
1263
852
  }
1264
- // writes are in — now the deletes. A delete that fails is a real, visible
1265
- // inconsistency (the old content moved/updated but the source survived),
1266
- // so roll the whole patch back rather than report a false success.
1267
853
  const removed = [];
1268
854
  try {
1269
855
  for (const d of deletes) {
@@ -1285,11 +871,6 @@ async function applyPatch(patch, ctx) {
1285
871
  }
1286
872
  return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
1287
873
  }
1288
- /**
1289
- * Undo a partially committed patch: restore each target's original bytes
1290
- * (null = it did not exist before, so remove the file we created) and clean
1291
- * up any temp file that never made it through its rename.
1292
- */
1293
874
  async function rollbackWrites(backups, temps) {
1294
875
  for (const t of temps)
1295
876
  await fs.rm(t, { force: true }).catch(() => { });
@@ -1300,35 +881,8 @@ async function rollbackWrites(backups, temps) {
1300
881
  await fs.writeFile(abs, orig, "utf8").catch(() => { });
1301
882
  }
1302
883
  }
1303
- /* ---------- hashline: stable, verifiable line anchors (#7 / #4) ----------
1304
- *
1305
- * The default `N| ` gutter is a trap: read_file prints it, but edit_file's
1306
- * old_text must NOT contain it — so the model strips the gutter by hand on
1307
- * every read, and the only mitigation is a sentence in the tool description.
1308
- *
1309
- * Format follows the established "hashline" convention used across AI coding
1310
- * harnesses (quangdang46/hashline, kebbbnnn/hashline, opencode-hashline,
1311
- * pi-hashline-edit-pro, …) so anchors read the same everywhere:
1312
- *
1313
- * 12:a3f1| function calculateTotal(items) {
1314
- *
1315
- * i.e. `LINE:HASH| content` — the line number is KEPT (humans and models both
1316
- * navigate by line) and the content hash is ADDED as an integrity anchor.
1317
- *
1318
- * Design points taken from those implementations rather than invented:
1319
- * - the hash covers the STRIPPED content, so re-indenting a line does not
1320
- * invalidate its anchor;
1321
- * - blank/whitespace-only lines get a fixed reserved hash;
1322
- * - when a hash occurs more than once, the anchor's line number disambiguates
1323
- * via PROXIMITY search, instead of silently taking the first match.
1324
- */
1325
- const HASH_BLANK = " "; // 4 spaces: reserved, cannot collide with a hex hash
1326
- // A real hash is 4 HEX chars, but the shape also accepts other 4-8 char
1327
- // alphanumerics: a model that mistypes or invents an anchor is common enough
1328
- // that we should still recognise the SHAPE and say "that anchor is malformed"
1329
- // rather than falling through to "no similar text found".
884
+ const HASH_BLANK = " ";
1330
885
  const ANCHOR_RE = /^\s*(\d+):([0-9a-z]{4,8}| {4})\|/;
1331
- /** 4-char content hash (FNV-1a 32-bit → hex), of the STRIPPED line */
1332
886
  export function hashLine(content) {
1333
887
  const stripped = content.trim();
1334
888
  if (!stripped)
@@ -1340,60 +894,28 @@ export function hashLine(content) {
1340
894
  }
1341
895
  return (h >>> 0).toString(16).padStart(8, "0").slice(-4);
1342
896
  }
1343
- /** `LINE:HASH| content` for each line, 1-indexed */
1344
897
  export function renderHashlines(lines, startLine = 1) {
1345
- // same trailing-newline rule as renderGutter: a file ending in "\n"
1346
- // splits into a final "" that is not a line, and an anchor for it is a lie
1347
- // the model can copy into an edit that then never verifies
1348
898
  const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
1349
899
  return body
1350
900
  .map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
1351
901
  .join("\n");
1352
902
  }
1353
- /**
1354
- * Width of the `N| ` gutter for a page, so every line in it lines up.
1355
- *
1356
- * the prefix had no padding, so the gutter silently widened at line 1000
1357
- * and the whole block jumped sideways mid-file — the exact "unfriendly line
1358
- * numbers" complaint. Padding to the widest number IN THE PAGE is enough: a
1359
- * page is a bounded window, and aligning to the file's total length would
1360
- * push every line of a 20-line read of a 500k-line file 6 characters right.
1361
- */
1362
903
  export function gutterWidth(startLine, count) {
1363
904
  return String(Math.max(0, startLine - 1) + count).length;
1364
905
  }
1365
- /**
1366
- * The default `N| ` display, with a stable gutter width and no phantom line.
1367
- *
1368
- * #7, second half: the prefix was added AFTER `text.split("\n")`, so a file
1369
- * ending in a newline produced a final numbered row for a line that does not
1370
- * exist. A model that copies the tail verbatim then hands `edit_file` an
1371
- * old_text with a phantom line in it, and the edit fails for no visible reason.
1372
- * A trailing empty element is dropped, which is what the reader means.
1373
- */
1374
906
  export function renderGutter(lines, startLine = 1) {
1375
- // a trailing newline splits into a final "" — not a line of the file
1376
907
  const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
1377
908
  if (!body.length)
1378
909
  return "";
1379
910
  const w = gutterWidth(startLine, body.length);
1380
911
  return body.map((l, i) => `${String(startLine + i).padStart(w)}| ${l}`).join("\n");
1381
912
  }
1382
- /** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
1383
913
  export function parseAnchor(anchor) {
1384
914
  const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
1385
915
  if (!m)
1386
916
  return null;
1387
917
  return { line: Number(m[1]), hash: m[2] };
1388
918
  }
1389
- /**
1390
- * Locate the line an anchor refers to.
1391
- *
1392
- * The hash is authoritative; the line number only disambiguates when the same
1393
- * content appears twice (proximity search). Falling back to "nearest line" for
1394
- * an unknown hash is deliberately NOT done — that would silently edit the wrong
1395
- * line, which is the entire failure mode hashline exists to prevent.
1396
- */
1397
919
  export function resolveAnchor(lines, anchor) {
1398
920
  const hits = [];
1399
921
  for (let i = 0; i < lines.length; i++)
@@ -1403,25 +925,11 @@ export function resolveAnchor(lines, anchor) {
1403
925
  return {
1404
926
  error: /^[0-9a-f]{4}$/.test(anchor.hash) || anchor.hash === HASH_BLANK
1405
927
  ? `no line matches hash ${anchor.hash} — the file changed since you read it (re-read it)`
1406
- : // distinguish "you invented/mistyped this anchor" from "the file moved",
1407
- // which sends the agent to fix the right thing
928
+ :
1408
929
  `hash ${JSON.stringify(anchor.hash)} is not a valid anchor (expected 4 hex chars) — re-read the file and copy the anchor verbatim`,
1409
930
  };
1410
931
  if (hits.length === 1)
1411
932
  return { index: hits[0], how: "unique" };
1412
- /**
1413
- * Several lines share this hash — because the hash is DERIVED FROM CONTENT.
1414
- *
1415
- * That is the structural weakness of this format compared with the anchor-table implementations
1416
- * (`pi-hashline-edit-pro` allocates an anchor per line "never derived from content, so
1417
- * byte-identical lines never share an anchor"). Here two identical lines are one anchor, so the
1418
- * line number has to break the tie by proximity — a guess, and a guess that can be wrong in a file
1419
- * with repeated blocks.
1420
- *
1421
- * The guess is kept (refusing outright would make editing any file with repeated lines
1422
- * impossible), but it is now REPORTED instead of being a silent choice: the caller appends it to
1423
- * the tool result, so a model that landed on the wrong copy can see why.
1424
- */
1425
933
  const target = anchor.line - 1;
1426
934
  const best = hits.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a));
1427
935
  return {
@@ -1430,19 +938,12 @@ export function resolveAnchor(lines, anchor) {
1430
938
  `resolved by proximity, VERIFY you edited the copy you meant`,
1431
939
  };
1432
940
  }
1433
- /** Is this whole block a hashline block (every line carries an anchor)? */
1434
941
  export function isHashlineBlock(block) {
1435
942
  const rows = block.split("\n").filter((l) => l !== "");
1436
943
  if (!rows.length)
1437
944
  return false;
1438
945
  return rows.every((l) => ANCHOR_RE.test(l));
1439
946
  }
1440
- /**
1441
- * Turn a copied hashline block back into file content.
1442
- *
1443
- * The anchors are verified against the CURRENT file, so a stale read is
1444
- * refused instead of editing the wrong bytes — the whole point of the format.
1445
- */
1446
947
  export function resolveHashlineBlock(block, lines) {
1447
948
  const rows = block.split("\n").filter((l) => l !== "");
1448
949
  const picked = [];
@@ -1468,7 +969,6 @@ export function resolveHashlineBlock(block, lines) {
1468
969
  return { error: "empty hashline block" };
1469
970
  return { text: content.join("\n"), indices: picked, notes };
1470
971
  }
1471
- /** normalize the read_file line-reference argument */
1472
972
  export function lineIdMode(args) {
1473
973
  const v = args.line_ids ?? args.hashline;
1474
974
  if (v === true)
@@ -1519,13 +1019,6 @@ export const TOOLS = [
1519
1019
  required: ["path"],
1520
1020
  },
1521
1021
  async run(args, ctx) {
1522
- // MULTI-FILE and FUZZY reads.
1523
- //
1524
- // Both are opt-in and handled HERE rather than by restructuring this
1525
- // tool: `paths` re-enters executeTool once per file, and `fuzzy` locates
1526
- // a file then re-enters it. Nothing about the single-file path below
1527
- // changes, so its output stays byte-identical — which #7's hashline
1528
- // anchors and the existing tests both depend on.
1529
1022
  if (Array.isArray(args.paths) && args.paths.length) {
1530
1023
  const wanted = args.paths
1531
1024
  .map((v) => str(v).trim())
@@ -1541,7 +1034,6 @@ export const TOOLS = [
1541
1034
  const chunks = [];
1542
1035
  for (const rel of wanted) {
1543
1036
  const r = await executeTool("read_file", JSON.stringify({ ...args, path: rel, paths: undefined }), ctx);
1544
- // one bad file must not sink the others: report it inline
1545
1037
  chunks.push(`--- ${rel} ---\n${r.ok ? r.result : `ERROR: ${r.result}`}`);
1546
1038
  }
1547
1039
  return { ok: true, result: chunks.join("\n\n") };
@@ -1551,15 +1043,6 @@ export const TOOLS = [
1551
1043
  const p = safeJoin(ctx.cwd, str(args.path));
1552
1044
  let text;
1553
1045
  try {
1554
- /**
1555
- * Refuse a BINARY file instead of decoding it as UTF-8.
1556
- *
1557
- * `readText` has no notion of encoding, so `read_file` on an image returned the raw bytes as
1558
- * a lossy string — reported as "画像をread_fileしてバイナリが帰ってきてる". The model then pays
1559
- * for mojibake it cannot interpret and has no way to tell "this is data" from "this is
1560
- * source". Naming the likely type (PNG/JPEG/PDF/ELF/zip) is what lets it choose the right
1561
- * next step instead of retrying the same read.
1562
- */
1563
1046
  const probe = await looksBinary(p);
1564
1047
  if (probe.binary) {
1565
1048
  return {
@@ -1572,9 +1055,6 @@ export const TOOLS = [
1572
1055
  text = await readText(p);
1573
1056
  }
1574
1057
  catch (err) {
1575
- // name the file, not just the errno: a multi-file read reports each bad
1576
- // path inline, and "ENOENT … /tmp/xyz/nope.ts" leaves the model to work
1577
- // out which of the paths it asked for
1578
1058
  const code = err.code;
1579
1059
  return {
1580
1060
  ok: false,
@@ -1582,15 +1062,8 @@ export const TOOLS = [
1582
1062
  };
1583
1063
  }
1584
1064
  ctx.onFileRead?.(str(args.path));
1585
- // split on ALL THREE line endings. A CR-only (old-Mac) file used to
1586
- // come back as a single line, so the model could not address lines 2..n at
1587
- // all — and a CRLF file left a trailing "\r" on every line, which then
1588
- // had to be stripped again by every consumer.
1589
1065
  const lines = splitEolLines(text);
1590
- // #7/#4: opt-in hashline output. Default stays the historical `N| ` so
1591
- // nothing that depends on it changes until the mode is chosen.
1592
1066
  const idMode = lineIdMode(args);
1593
- // grep mode
1594
1067
  if (typeof args.pattern === "string" && args.pattern !== "") {
1595
1068
  const re = compileRegex(args.pattern, args.ignore_case === true);
1596
1069
  if (typeof re === "string")
@@ -1624,31 +1097,18 @@ export const TOOLS = [
1624
1097
  result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
1625
1098
  return { ok: true, result };
1626
1099
  }
1627
- // plain mode
1628
1100
  let off = num(args.offset, 1);
1629
1101
  off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
1630
1102
  const lim = num(args.limit, 2000);
1631
- // A LINE cap alone does not bound the output: a single 5MB minified line is one
1632
- // "line", and 2000 lines of 2000 chars is 4MB. The three caps below each kill a
1633
- // different shape of oversized file (#17 item 3):
1634
- // lines → a 3000-line source file
1635
- // bytes → a big log, whatever its line structure
1636
- // per-line → minified.js / a one-line JSON blob
1637
1103
  const pageLines = lines.slice(off, off + lim);
1638
- // PER-LINE cap first: clipping lines before joining means one giant line cannot
1639
- // blow the byte budget on its own, and the marker names how much was cut.
1640
1104
  const cappedLines = pageLines.map((l) => l.length > MAX_READ_LINE_CHARS
1641
1105
  ? l.slice(0, MAX_READ_LINE_CHARS) +
1642
1106
  ` …[line clipped: ${l.length - MAX_READ_LINE_CHARS} more chars]`
1643
1107
  : l);
1644
1108
  const rendered = idMode === "hash" ? renderHashlines(cappedLines, off + 1) : renderGutter(cappedLines, off + 1);
1645
- // the phantom trailing line is dropped for the count too, or a file that
1646
- // ends in a newline always reports one line more than it has
1647
1109
  const total = lines.length && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
1648
1110
  const more = off + lim < total ? `\n... (${total - off - lim} more lines)` : "";
1649
1111
  const out = rendered + more;
1650
- // BYTE cap last, with the remaining line count in the marker so the model knows
1651
- // how to continue rather than assuming it saw everything.
1652
1112
  if (out.length > MAX_READ_BYTES) {
1653
1113
  const head = out.slice(0, MAX_READ_BYTES);
1654
1114
  const cutAt = head.lastIndexOf("\n");
@@ -1656,9 +1116,6 @@ export const TOOLS = [
1656
1116
  ok: true,
1657
1117
  result: (cutAt > 0 ? head.slice(0, cutAt) : head) +
1658
1118
  `\n... [output clipped at ${MAX_READ_BYTES} bytes of ${out.length}; ` +
1659
- // `head` holds N rendered lines separated by N-1 newlines, and rendering
1660
- // started at 1-indexed line `off + 1`, so the next unread line is
1661
- // off + 1 + N. Counting newlines alone gives N-1 and would re-read one line.
1662
1119
  `continue with offset=${off + 1 + countLines(cutAt > 0 ? head.slice(0, cutAt) : head) + 1}]`,
1663
1120
  };
1664
1121
  }
@@ -1684,9 +1141,6 @@ export const TOOLS = [
1684
1141
  required: ["path", "content"],
1685
1142
  },
1686
1143
  async run(args, ctx) {
1687
- // content must be a real string: a non-string (object/array from bad
1688
- // model output) coerced to "[object Object]" or silently truncated —
1689
- // worse, an undefined slipped past required and WIPED the file
1690
1144
  if (typeof args.content !== "string") {
1691
1145
  return {
1692
1146
  ok: false,
@@ -1700,22 +1154,6 @@ export const TOOLS = [
1700
1154
  if (stale)
1701
1155
  return stale;
1702
1156
  await fs.mkdir(path.dirname(p), { recursive: true });
1703
- // a file's line endings belong to the FILE. Writing the model's
1704
- // content verbatim silently converted a CRLF (or CR) file to LF whenever
1705
- // the model rewrote it — the model never emits \r — so one logical edit
1706
- // produced a whole-file diff, and any tool that normalises on EOL (git,
1707
- // prettier) would rewrite it afterwards.
1708
- //
1709
- // So an EXISTING file's dominant EOL is re-applied. detectEol returns null
1710
- // for a mixed file, which is left untouched on purpose: choosing a winner
1711
- // there would itself be the silent whole-file rewrite this prevents.
1712
- //
1713
- // when the file CANNOT answer — it is new, or its EOL is mixed — ask
1714
- // `.editorconfig` before falling back to LF. The order is deliberate: the
1715
- // file's own bytes outrank configuration, which outranks the default. A
1716
- // `end_of_line = crlf` in a repo whose files are all LF must not convert
1717
- // every file the agent touches, so config is never consulted for a file that
1718
- // already has an unambiguous ending.
1719
1157
  const before = await currentOrNull(p);
1720
1158
  const detected = before === null ? null : detectEol(before);
1721
1159
  if (detected !== null)
@@ -1759,12 +1197,6 @@ export const TOOLS = [
1759
1197
  return stale;
1760
1198
  let oldText = str(args.old_text);
1761
1199
  let newText = str(args.new_text);
1762
- // the single most common edit_file failure in the real logs (22
1763
- // occurrences) said only "old_text is required", while the tool's own
1764
- // schema already documents the better form. Name it and say what to do —
1765
- // the pattern master.ts uses for "not your sub-agent" (its
1766
- // `messageChild`),
1767
- // which turned a confusing refusal into a re-addressable one.
1768
1200
  if (!oldText)
1769
1201
  return {
1770
1202
  ok: false,
@@ -1773,31 +1205,9 @@ export const TOOLS = [
1773
1205
  "Do NOT copy read_file's `N| ` line-number prefixes.",
1774
1206
  };
1775
1207
  const replaceAll = args.replace_all === true;
1776
- // #7/#4: accept a block copied straight out of read_file's hashline
1777
- // output. When the anchors do not verify we FAIL with the specific reason
1778
- // rather than silently falling back to literal matching — a literal match
1779
- // that happened to succeed would edit the wrong bytes, which is the exact
1780
- // failure mode hashline exists to prevent.
1781
- //
1782
- // On success we edit by RESOLVED LINE INDEX rather than by searching for
1783
- // the text: the anchors already pinpointed the lines, so a block whose
1784
- // content legitimately appears twice (the case proximity search exists
1785
- // for) must not then fail the uniqueness check on the resolved text.
1786
1208
  let hashNote = "";
1787
1209
  let hashNotes = [];
1788
1210
  let hashSpan = null;
1789
- /**
1790
- * A hashline-formatted `new_text` is STRIPPED, not pasted.
1791
- *
1792
- * MEASURED in tyb2-9-8155867c: `old_text` and `new_text` were IDENTICAL, both carrying anchors
1793
- * (`155c| …`, `59d3| …`) copied from the read. The model meant "these lines, rewritten" but
1794
- * pasted the anchor gutter on both sides. The result was refused as "old_text not found",
1795
- * which names the wrong argument — the anchors had already been resolved away, so the literal
1796
- * search could not find them, and the reply never mentioned the real mistake.
1797
- *
1798
- * The anchors belong to the READ, not to the file. Stripping them from `new_text` is what the
1799
- * model meant, and writing them into the source would be corruption.
1800
- */
1801
1211
  const newTextHasAnchors = isHashlineBlock(newText);
1802
1212
  if (isHashlineBlock(oldText)) {
1803
1213
  const srcLines = text.replace(/\r\n/g, "\n").split("\n");
@@ -1810,13 +1220,6 @@ export const TOOLS = [
1810
1220
  hashSpan = { start: r.indices[0], end: r.indices[r.indices.length - 1] + 1 };
1811
1221
  }
1812
1222
  }
1813
- /**
1814
- * Drop anchors from `new_text` — they are the READ's numbering, not file content.
1815
- *
1816
- * A block whose anchors cannot be resolved is still stripped (the content is what was meant);
1817
- * writing `155c| ` into a source file would be corruption, so the safe direction is to remove
1818
- * them and let the rest of the edit be judged on its text.
1819
- */
1820
1223
  if (newTextHasAnchors) {
1821
1224
  const stripped = newText
1822
1225
  .split("\n")
@@ -1831,27 +1234,7 @@ export const TOOLS = [
1831
1234
  }
1832
1235
  }
1833
1236
  let count = hashNote ? 0 : text.split(oldText).length - 1;
1834
- /**
1835
- * A MINIMUM ANCHOR LENGTH, from Gemini's `edit.ts`.
1836
- *
1837
- * Below this, an anchor cannot identify anything: " }" or "\n\n" appear everywhere, and
1838
- * any tolerant matching on them either fails ambiguously or picks a wrong span. MEASURED
1839
- * across every session: `old_text not found` is 117 of 168 edit failures, and a
1840
- * large share of those are anchors too short to be unique, sent by a model that believed
1841
- * them specific.
1842
- *
1843
- * Short anchors are still ACCEPTED when they match exactly once — a one-character edit is
1844
- * legitimate. What is refused is trying to make a short anchor work by relaxing the match.
1845
- */
1846
1237
  const MIN_FUZZY_ANCHOR = 10;
1847
- /**
1848
- * A MULTI-LINE anchor is specific by construction: two matching lines pin both a start
1849
- * and a continuation, which is far stronger evidence than 10 characters on one line.
1850
- * Gemini's floor is about single-line anchors, where " }" and "foo();" genuinely cannot
1851
- * identify anything. Applying it by raw character count rejected `foo\nbar` — a perfectly
1852
- * unambiguous two-line anchor — which is the guard making normal editing harder, the
1853
- * exact failure mode #157 warns about.
1854
- */
1855
1238
  const anchorLines = oldText.split("\n").length;
1856
1239
  const anchorTooShort = anchorLines < 2 && oldText.trim().length < MIN_FUZZY_ANCHOR;
1857
1240
  let normalized = false;
@@ -1864,17 +1247,11 @@ export const TOOLS = [
1864
1247
  normalized = true;
1865
1248
  }
1866
1249
  }
1867
- // tolerate patterns whose only difference is trailing whitespace per line
1868
- // (the most common near-miss) — applied only when it resolves uniquely
1869
1250
  if (count === 0 && !anchorTooShort) {
1870
1251
  const srcLines = text.replace(/\r\n/g, "\n").split("\n");
1871
1252
  const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
1872
1253
  const hits = trailingWsMatches(srcLines, patLines);
1873
1254
  if (hits.length === 1 && patLines.length > 0) {
1874
- // same detection as the hashline path — CR-only files are detected
1875
- // too, and a MIXED file yields null and is left as-is rather than
1876
- // homogenised. The old `includes("\r\n")` test called a CR-only file
1877
- // "\n", so an edit to one silently converted its line endings.
1878
1255
  const rebuilt = [
1879
1256
  ...srcLines.slice(0, hits[0]),
1880
1257
  ...splitEolLines(newText),
@@ -1893,17 +1270,12 @@ export const TOOLS = [
1893
1270
  };
1894
1271
  }
1895
1272
  if (count === 0) {
1896
- // actionable miss: point the model at recovery instead of pushing it
1897
- // toward bash-based editing
1898
1273
  const lines = matchLines(text, oldText.trim());
1899
1274
  const hint = lines.length
1900
1275
  ? `A trimmed variant appears at line(s) ${lines.slice(0, 5).join(", ")}.`
1901
1276
  : `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
1902
1277
  return {
1903
1278
  ok: false,
1904
- // a hashline block that did not resolve gets ITS reason — the generic
1905
- // "not found" would send the model hunting for a whitespace problem
1906
- // when the real issue is a stale read
1907
1279
  result: hashNote
1908
1280
  ? `old_text is a hashline block but did not resolve: ${hashNote}.`
1909
1281
  : `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
@@ -1916,35 +1288,11 @@ export const TOOLS = [
1916
1288
  result: `old_text matched ${count} times (lines ${at.slice(0, 5).join(", ")}) and must be unique — add surrounding lines to old_text, or pass replace_all=true`,
1917
1289
  };
1918
1290
  }
1919
- /**
1920
- * DISPROPORTIONATE MATCH guard, from OpenCode's `isDisproportionateMatch`.
1921
- *
1922
- * The dangerous shape is not "no match" — that is loud and recoverable — but a match far
1923
- * LARGER than the anchor, where asking to change one call site silently rewrites a whole
1924
- * function. A confident wrong edit is worse than a failed one.
1925
- */
1926
1291
  if (!hashSpan) {
1927
1292
  const over = disproportionateSpan(text, oldText);
1928
1293
  if (over)
1929
1294
  return { ok: false, result: over };
1930
1295
  }
1931
- // A verified hashline block is addressed BY LINE INDEX: the anchors
1932
- // already proved which lines it means, so a block whose content occurs
1933
- // twice must still edit the anchored occurrence rather than fail the
1934
- // uniqueness check.
1935
- // The hashline path splits on "\n" to index by resolved line number, which
1936
- // leaves a trailing "\r" on every line of a CRLF file. Rejoining with
1937
- // "\n" then DROPS all of them, so a single edit silently rewrote a
1938
- // Windows file's line endings: the replaced line arrived with the model's
1939
- // "\n" while every untouched line kept its "\r\n". The file still parsed
1940
- // and the edit "succeeded", so nothing reported it — but the next
1941
- // checkout diff shows the whole file changed, and tools that normalise on
1942
- // EOL (git, prettier, formatters) now rewrite it.
1943
- //
1944
- // So the EOL is detected ONCE from the untouched text and re-applied to
1945
- // the replacement, which also keeps a CRLF file CRLF when the model sends
1946
- // LF-separated new_text (the overwhelmingly common case — models never
1947
- // emit \r).
1948
1296
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
1949
1297
  const out = hashSpan
1950
1298
  ? splitEolLines(text)
@@ -2077,13 +1425,10 @@ export const TOOLS = [
2077
1425
  `jobs are per-workspace and forgotten on harness restart`,
2078
1426
  };
2079
1427
  if (str(args.action) === "kill") {
2080
- // same swallowed-kill bug — this reported "killed" and dropped the
2081
- // job from the map while the process kept running, now untrackable.
2082
1428
  const note = sh.exited ? "(already exited)" : describeKill(killTree(sh.child));
2083
1429
  map.delete(sh.id);
2084
1430
  return { ok: true, result: `job ${sh.id} ${note}. Output so far:\n${clip(sh.out, 4000)}` };
2085
1431
  }
2086
- // action === read (default): hand back NEW bytes and remember the cursor
2087
1432
  const fresh = sh.out.slice(bgReadCursors.get(sh.id) ?? 0);
2088
1433
  bgReadCursors.set(sh.id, sh.out.length);
2089
1434
  const status = sh.exited
@@ -2092,7 +1437,6 @@ export const TOOLS = [
2092
1437
  const body = (fresh.length ? fresh : "(no new output)") +
2093
1438
  (sh.truncated ? "\n… (output truncated at 200KB)" : "") +
2094
1439
  status;
2095
- // finished jobs are removed once fully drained
2096
1440
  if (sh.exited && bgReadCursors.get(sh.id) >= sh.out.length) {
2097
1441
  map.delete(sh.id);
2098
1442
  bgReadCursors.delete(sh.id);
@@ -2138,7 +1482,6 @@ export const TOOLS = [
2138
1482
  const html = await res.text();
2139
1483
  if (!html.trim())
2140
1484
  return { ok: false, result: `HTTP ${res.status} with an empty body` };
2141
- // heavy DOM deps are loaded lazily so the master's idle startup stays lean
2142
1485
  const { Browser } = await import("happy-dom");
2143
1486
  const { Readability } = await import("@mozilla/readability");
2144
1487
  const browser = new Browser();
@@ -2183,18 +1526,6 @@ export const TOOLS = [
2183
1526
  task: { type: "string", description: "self-contained instructions for the sub-agent" },
2184
1527
  context: { type: "string", enum: ["none", "fork"], description: "default none" },
2185
1528
  name: { type: "string", description: "optional short name fragment for the id" },
2186
- // reasoning effort for THIS sub-agent, overriding both the
2187
- // configured subagent default and the parent's inherited value.
2188
- //
2189
- // Precedence mirrors OpenAI Codex (codex-rs/core/src/agent/child_config.rs):
2190
- // spawn argument -> configured default -> parent's effort. Codex
2191
- // deliberately does NOT inherit by default — its own test sets a parent at
2192
- // XHigh and asserts a child spawned with Low runs at Low, because
2193
- // sub-agents are usually narrow and cheap and inheriting a parent's high
2194
- // effort across many trivial scouts multiplies cost for no gain.
2195
- //
2196
- // Naming it here is what stops the silent loss: previously a spawn could
2197
- // not express an effort at all, so the parent's value simply vanished.
2198
1529
  reasoning_effort: {
2199
1530
  type: "string",
2200
1531
  enum: ["minimal", "low", "medium", "high", "xhigh"],
@@ -2213,8 +1544,6 @@ export const TOOLS = [
2213
1544
  return { ok: false, result: "task required" };
2214
1545
  const context = args.context === "fork" ? "fork" : "none";
2215
1546
  try {
2216
- // validate like the per-agent setter does, so a typo cannot reach
2217
- // the provider as an unrecognised effort value.
2218
1547
  const effort = str(args.reasoning_effort).trim();
2219
1548
  if (effort && !isReasoningEffort(effort))
2220
1549
  return {
@@ -2298,12 +1627,6 @@ export const TOOLS = [
2298
1627
  return { ok: false, result: "sub-agents are not available here" };
2299
1628
  const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
2300
1629
  const ms = Math.min(Math.max(num(args.timeout_ms, 300_000), 1_000), 3_600_000);
2301
- // a prompt ALREADY in the queue must be consumed FIRST. The
2302
- // enqueue-time wake only fires for prompts arriving DURING the park —
2303
- // one queued before the park is invisible to it, and parking over it
2304
- // strands the operator's message ("idle" display, nothing processed)
2305
- // until a child settles. Stand down and let the next turn boundary
2306
- // drain it; the model can call wait_children again afterwards.
2307
1630
  if (ctx.hasPendingPrompts?.()) {
2308
1631
  ctx.onProgressGateReset?.();
2309
1632
  return {
@@ -2311,24 +1634,19 @@ export const TOOLS = [
2311
1634
  result: "A prompt is already queued for you — handle it first, then call wait_children again if you still need to wait.",
2312
1635
  };
2313
1636
  }
2314
- // park VISIBLY: the UI shows idle ("waiting on sub-agents") instead of a
2315
- // running spinner, and any user prompt / stop wakes us instantly
2316
1637
  const parked = ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
2317
1638
  if (parked === false) {
2318
- // the agent refused the park (a prompt raced in) — same answer as above
2319
1639
  ctx.onProgressGateReset?.();
2320
1640
  return {
2321
1641
  ok: true,
2322
1642
  result: "A prompt arrived while you were about to wait — handle it first, then call wait_children again if you still need to wait.",
2323
1643
  };
2324
1644
  }
2325
- // the child's report is about to land in our mailbox — a progress report
2326
- // right after it would be pure noise, so re-arm the gate here
2327
1645
  ctx.onProgressGateReset?.();
2328
1646
  try {
2329
1647
  const r = await sa.wait(ids, ms);
2330
1648
  ctx.onIdleUnpark?.();
2331
- ctx.onProgressGateReset?.(); // also after: waiting itself isn't "activity"
1649
+ ctx.onProgressGateReset?.();
2332
1650
  return { ok: true, result: r.note };
2333
1651
  }
2334
1652
  catch (e) {
@@ -2372,9 +1690,6 @@ export const TOOLS = [
2372
1690
  name: "load_skill",
2373
1691
  description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
2374
1692
  "matches your current task; follow the loaded playbook. " +
2375
- // the instruction used to need a SECOND, separately-queued message that
2376
- // only arrived at the next turn boundary. Passing `instructions` delivers it
2377
- // in the SAME turn as the playbook, so "load X and do Y" costs one round-trip.
2378
1693
  "Pass `instructions` with what you want done with it to receive both at once.",
2379
1694
  parameters: {
2380
1695
  type: "object",
@@ -2400,8 +1715,6 @@ export const TOOLS = [
2400
1715
  }
2401
1716
  let result = await readSkillFile(def);
2402
1717
  if (def.files.length) {
2403
- // surface bundled scripts as runnable paths: workspace-relative when
2404
- // possible (bash runs in the workspace), absolute for global skills
2405
1718
  const dirAbs = path.dirname(def.filePath);
2406
1719
  const rootDir = roots
2407
1720
  .map((r) => r.dir)
@@ -2415,8 +1728,6 @@ export const TOOLS = [
2415
1728
  listed.map((f) => `- ${f}`).join("\n") +
2416
1729
  `\nRun scripts with bash (chmod +x first if needed).`;
2417
1730
  }
2418
- // deliver the caller's instructions in THIS result, so the model does
2419
- // not have to queue a second message that only lands next turn.
2420
1731
  const instructions = str(args.instructions).trim();
2421
1732
  if (instructions)
2422
1733
  result +=
@@ -2465,12 +1776,6 @@ export const TOOLS = [
2465
1776
  const content = str(args.content);
2466
1777
  if (!content.trim())
2467
1778
  return { ok: false, result: "content required" };
2468
- // save GLOBALLY (config dir) so skills survive workspace switches and
2469
- // are shared by every agent. Same-name handling is safe:
2470
- // · overwriting the global copy replaces it atomically (writeFile)
2471
- // · a same-name skill in a HIGHER-priority root (workspace) still
2472
- // shadows this one at load time — that's the intended override,
2473
- // so tell the model when its save would be invisible
2474
1779
  const globalRoot = roots.find((r) => r.source === "global") ?? roots[0];
2475
1780
  const filePath = await saveSkill(globalRoot.dir, name, description, content.slice(0, 64_000));
2476
1781
  let note = "";
@@ -2482,10 +1787,9 @@ export const TOOLS = [
2482
1787
  await fs.access(path.join(r.dir, name, SKILL_FILE));
2483
1788
  note = ` (note: a workspace skill with the same name "${name}" takes precedence at load time)`;
2484
1789
  }
2485
- catch { /* no clash in this root */ }
1790
+ catch { }
2486
1791
  }
2487
1792
  }
2488
- // bundled scripts/files next to SKILL.md
2489
1793
  const written = [];
2490
1794
  const rawFiles = Array.isArray(args.files) ? args.files : [];
2491
1795
  for (const f of rawFiles.slice(0, 10)) {
@@ -2509,24 +1813,9 @@ export const TOOLS = [
2509
1813
  },
2510
1814
  },
2511
1815
  ];
2512
- /** #16: how many files one read_file call may cover. Each costs context, so an
2513
- * unbounded array would just be a way to overflow the window in one shot. */
2514
1816
  const MAX_MULTI_READ = 10;
2515
- /**
2516
- * The three read caps (#17 item 3). A line cap alone is not a bound: one 5MB minified
2517
- * line is a single "line", and 2000 lines of 2000 chars is 4MB. Each cap kills a
2518
- * different shape of oversized file:
2519
- *
2520
- * 2000 lines → a 3000-line source file (the `limit` default, unchanged)
2521
- * 256 KB → a 5MB log, whatever its lines
2522
- * 2000 chars → minified.js / a one-line JSON blob
2523
- *
2524
- * The byte cap is a SHARE of what a tool result can be, not the whole budget: the model
2525
- * still needs room to reason about what it read.
2526
- */
2527
1817
  const MAX_READ_BYTES = 256 * 1024;
2528
1818
  const MAX_READ_LINE_CHARS = 2_000;
2529
- /** how many lines a string contains — used to say where to resume a clipped read */
2530
1819
  function countLines(s) {
2531
1820
  let n = 0;
2532
1821
  for (let i = 0; i < s.length; i++)
@@ -2534,15 +1823,6 @@ function countLines(s) {
2534
1823
  n++;
2535
1824
  return n;
2536
1825
  }
2537
- /**
2538
- * Subsequence match: every character of `needle` appears in `hay`, in order,
2539
- * gaps allowed (#16 "fuzzy").
2540
- *
2541
- * Deliberately not a ranked edit-distance search. This answers "is this the
2542
- * file I meant", and it can only say yes or no — it cannot rank a plausible
2543
- * WRONG file above the right one, which is the failure mode that actually costs
2544
- * an agent time. Searching file CONTENT is what bash/grep are for.
2545
- */
2546
1826
  export function fuzzyPathMatch(needle, hay) {
2547
1827
  const n = needle.toLowerCase().replace(/[\s._/-]+/g, "");
2548
1828
  if (!n)
@@ -2557,14 +1837,6 @@ export function fuzzyPathMatch(needle, hay) {
2557
1837
  }
2558
1838
  return false;
2559
1839
  }
2560
- /**
2561
- * Locate a file by a loose name and read it.
2562
- *
2563
- * Walks the workspace (skipping .git, node_modules and build noise) and ranks
2564
- * matches: exact path, exact basename, ends-with, then subsequence. When
2565
- * several match it LISTS them rather than picking one — guessing here would have
2566
- * the agent edit the wrong file, which is the expensive mistake.
2567
- */
2568
1840
  async function fuzzyRead(needle, ctx, args) {
2569
1841
  const q = needle.trim();
2570
1842
  if (!q)
@@ -2614,12 +1886,7 @@ async function fuzzyRead(needle, ctx, args) {
2614
1886
  }
2615
1887
  found.sort((a, b) => a.rank - b.rank || a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
2616
1888
  const best = found[0];
2617
- // Only a confident match is read directly. If the query is vague enough that
2618
- // several files match, LIST them: guessing would hand the agent the wrong
2619
- // file, and the cost of that mistake is far higher than one extra call.
2620
- // An exact path or basename is unambiguous, so those still read straight away.
2621
1889
  if (best.rank <= 1 || found.length === 1) {
2622
- // re-enter the tool so a unique match behaves exactly like a normal read
2623
1890
  return executeTool("read_file", JSON.stringify({ ...args, path: best.rel, fuzzy: undefined }), ctx);
2624
1891
  }
2625
1892
  return {
@@ -2628,7 +1895,6 @@ async function fuzzyRead(needle, ctx, args) {
2628
1895
  found.slice(0, 8).map((f) => ` ${f.rel}`).join("\n"),
2629
1896
  };
2630
1897
  }
2631
- /** Current skills across the configured roots (for the system prompt listing). */
2632
1898
  export async function currentSkills(ctx) {
2633
1899
  return discoverSkills(ctx.skillRoots ?? []);
2634
1900
  }
@@ -2638,9 +1904,7 @@ export function toolSpecs() {
2638
1904
  function: { name: t.name, description: t.description, parameters: t.parameters },
2639
1905
  }));
2640
1906
  }
2641
- /** tools that mutate the workspace — blocked for read-only personas */
2642
1907
  const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
2643
- /** per-tool aliases: the common wrong key -> the key the schema declares. */
2644
1908
  const ARG_ALIASES = {
2645
1909
  bash: { cmd: "command", script: "command", shell: "command", input: "command" },
2646
1910
  read_file: { file_path: "path", filepath: "path", file: "path", name: "path" },
@@ -2651,11 +1915,6 @@ const ARG_ALIASES = {
2651
1915
  list_children: { file_path: "path", dir: "path" },
2652
1916
  read_url: { uri: "url", link: "url" },
2653
1917
  };
2654
- /**
2655
- * The one argument each tool's whole call usually boils down to. Used to
2656
- * salvage a bare word ("ls") that was never wrapped in JSON at all — but ONLY
2657
- * for tools where that is unambiguous, so "12" is never mistaken for a path.
2658
- */
2659
1918
  const PRIMARY_STRING_ARG = {
2660
1919
  bash: "command",
2661
1920
  read_file: "path",
@@ -2666,7 +1925,6 @@ const PRIMARY_STRING_ARG = {
2666
1925
  load_skill: "name",
2667
1926
  save_skill: "content",
2668
1927
  };
2669
- /** keys whose schema type is a number / boolean, per tool. */
2670
1928
  const NUMERIC_ARGS = {
2671
1929
  read_file: ["offset", "limit", "context"],
2672
1930
  bash: ["timeout_ms"],
@@ -2680,12 +1938,10 @@ const BOOL_ARGS = {
2680
1938
  write_file: [],
2681
1939
  list_dir: [],
2682
1940
  };
2683
- /** markdown/backtick framing models like to wrap a path in */
2684
1941
  function unframe(v) {
2685
1942
  const m = /^[`*_\s]+(.+?)[`*_\s]*$/.exec(v);
2686
1943
  return m ? m[1] : v;
2687
1944
  }
2688
- /** coerce "100" -> 100, "true" -> true; returns undefined when not coercible */
2689
1945
  function coerceScalar(v) {
2690
1946
  if (typeof v !== "string")
2691
1947
  return v;
@@ -2698,7 +1954,6 @@ function coerceScalar(v) {
2698
1954
  return Number(t);
2699
1955
  return v;
2700
1956
  }
2701
- /** Turn a value into an array, tolerating a bare scalar or a JSON array. */
2702
1957
  function toArray(v) {
2703
1958
  if (Array.isArray(v))
2704
1959
  return v;
@@ -2711,7 +1966,6 @@ function toArray(v) {
2711
1966
  return p;
2712
1967
  }
2713
1968
  catch {
2714
- /* fall through — treat as a single element */
2715
1969
  }
2716
1970
  }
2717
1971
  return [v];
@@ -2727,12 +1981,8 @@ export function repairToolInput(name, rawArgs) {
2727
1981
  parsed = rawArgs?.trim() ? JSON.parse(rawArgs) : {};
2728
1982
  }
2729
1983
  catch {
2730
- // not JSON at all: a whole call was stringified, or the model emitted a
2731
- // bare word. Only salvageable when the tool takes a single obvious string.
2732
1984
  const bare = rawArgs?.trim().replace(/^[`*\s]+|[`*\s]+$/g, "");
2733
1985
  const primary = PRIMARY_STRING_ARG[name];
2734
- // a bare number/boolean is never a path or a command — refusing is far
2735
- // better than inventing one
2736
1986
  const isScalarish = bare !== "" && /^(true|false|null|-?\d+(\.\d+)?)$/i.test(bare);
2737
1987
  if (bare && primary && !isScalarish && !bare.startsWith("{") && !bare.startsWith("[")) {
2738
1988
  notes.push(`wrapped a bare string as {"${primary}": …}`);
@@ -2740,23 +1990,17 @@ export function repairToolInput(name, rawArgs) {
2740
1990
  }
2741
1991
  return { args: {}, notes, error: `arguments were not valid JSON — send a JSON object, e.g. {"…"}` };
2742
1992
  }
2743
- // a JSON-stringified object arrives as a string
2744
1993
  if (typeof parsed === "string" && /[{[]/.test(parsed.trim().slice(0, 2))) {
2745
1994
  try {
2746
1995
  parsed = JSON.parse(parsed);
2747
1996
  notes.push("unwrapped a JSON-stringified argument object");
2748
1997
  }
2749
1998
  catch {
2750
- /* leave as-is; it may genuinely be a string argument */
2751
1999
  }
2752
2000
  }
2753
- // a bare scalar where an object is required
2754
2001
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2755
2002
  if (parsed === null || parsed === undefined)
2756
2003
  return { args: {}, notes, error: "arguments were empty" };
2757
- // An ARRAY is a legitimate value for a path-ish argument, but only for the
2758
- // tools that actually take one — inventing {path:[…]} for apply_patch would
2759
- // hand the tool a key it has never heard of.
2760
2004
  if (Array.isArray(parsed) && name === "read_file") {
2761
2005
  notes.push("kept the array as a paths list");
2762
2006
  return { args: { paths: parsed }, notes };
@@ -2774,7 +2018,6 @@ export function repairToolInput(name, rawArgs) {
2774
2018
  };
2775
2019
  }
2776
2020
  const args = { ...parsed };
2777
- // alias normalisation
2778
2021
  for (const [from, to] of Object.entries(ARG_ALIASES[name] ?? {})) {
2779
2022
  if (!(from in args))
2780
2023
  continue;
@@ -2783,13 +2026,10 @@ export function repairToolInput(name, rawArgs) {
2783
2026
  notes.push(`renamed "${from}" -> "${to}"`);
2784
2027
  }
2785
2028
  else {
2786
- // both present: the schema key wins and the stray alias is DROPPED, not
2787
- // left behind as an unknown argument the tool would ignore
2788
2029
  notes.push(`dropped "${from}" — "${to}" was also given and wins`);
2789
2030
  }
2790
2031
  delete args[from];
2791
2032
  }
2792
- // string -> number / boolean for the keys whose schema says so
2793
2033
  for (const k of NUMERIC_ARGS[name] ?? []) {
2794
2034
  if (k in args) {
2795
2035
  const c = coerceScalar(args[k]);
@@ -2808,18 +2048,12 @@ export function repairToolInput(name, rawArgs) {
2808
2048
  }
2809
2049
  }
2810
2050
  }
2811
- // an array-typed arg arriving as a scalar
2812
2051
  if (name === "read_file" && "paths" in args) {
2813
2052
  const before = args.paths;
2814
2053
  args.paths = toArray(before);
2815
2054
  if (!Array.isArray(before))
2816
2055
  notes.push('coerced "paths" to an array');
2817
2056
  }
2818
- // a JSON-stringified object hiding inside a single argument, e.g.
2819
- // {"path": "{\"path\": \"a.ts\"}"} — unwrap it and merge its keys
2820
- // Only PATH-like keys: `content`/`old_text`/`new_text` legitimately hold JSON
2821
- // (writing a .json file!), and unwrapping those would REPLACE the file's
2822
- // actual data with an inner field — silent data loss, not repair.
2823
2057
  for (const k of ["path", "command"]) {
2824
2058
  const v = args[k];
2825
2059
  if (typeof v !== "string")
@@ -2837,10 +2071,8 @@ export function repairToolInput(name, rawArgs) {
2837
2071
  }
2838
2072
  }
2839
2073
  catch {
2840
- /* genuinely not JSON — leave it alone */
2841
2074
  }
2842
2075
  }
2843
- // markdown framing on a path
2844
2076
  for (const k of ["path", "file_path"]) {
2845
2077
  if (typeof args[k] === "string") {
2846
2078
  const f = unframe(args[k]);
@@ -2853,9 +2085,6 @@ export function repairToolInput(name, rawArgs) {
2853
2085
  return { args, notes };
2854
2086
  }
2855
2087
  export async function executeTool(name, rawArgs, ctx) {
2856
- // MCP tools are namespaced (`mcp__server__tool`) so they can never reach this point
2857
- // by shadowing a built-in. Dispatched BEFORE the built-in lookup because there is no
2858
- // static TOOLS entry to find — the tool list belongs to the server.
2859
2088
  if (name.startsWith(MCP_PREFIX)) {
2860
2089
  if (ctx.signal?.aborted)
2861
2090
  return { ok: false, result: "aborted (harness shutdown)" };
@@ -2876,12 +2105,8 @@ export async function executeTool(name, rawArgs, ctx) {
2876
2105
  return { ok: false, result: `unknown tool: ${name}` };
2877
2106
  if (ctx.signal?.aborted)
2878
2107
  return { ok: false, result: "aborted (harness shutdown)" };
2879
- // read-only personas (researcher/reviewer) are enforced, not just asked
2880
2108
  if (ctx.readOnly && MUTATING_TOOLS.has(name) && !ctx.allowMutatingForReview?.includes(name))
2881
2109
  return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
2882
- // repair the call before handing it to the tool. A model that sends
2883
- // {"cmd":"ls"} or {"limit":"100"} should get the work done, not a generic parse
2884
- // failure and a wasted turn. Unrepairable calls say what to send instead.
2885
2110
  const repaired = repairToolInput(name, rawArgs);
2886
2111
  if (repaired.error)
2887
2112
  return { ok: false, result: `${name}: ${repaired.error}` };
@@ -2895,24 +2120,6 @@ export async function executeTool(name, rawArgs, ctx) {
2895
2120
  return { ok: false, result: `tool error: ${err.message}` };
2896
2121
  }
2897
2122
  }
2898
- /**
2899
- * The gateway's cap on the REQUEST BODY'S STRUCTURE, not on its token count.
2900
- *
2901
- * Command Code's `/alpha` gateway rejects with
2902
- * `400 Invalid request JSON: JSON node count cannot exceed 32768`, and the distinction matters
2903
- * because every usual recovery is wrong for it:
2904
- *
2905
- * - COMPACTING does not reliably help. It does reduce message and content-part counts, but a
2906
- * session heavy in tool calls and images stays above the cap — measured upstream at 22.6% of a
2907
- * 1,000,000-token window with 1,436 messages and 61 image parts. Retrying after a compaction
2908
- * therefore burns a full-price summarisation to be refused again.
2909
- * - RESENDING the same structure cannot succeed at all, so this is not a transient error.
2910
- *
2911
- * So it is detected SEPARATELY from `isContextOverflow`, and the remedy is the honest one: a new
2912
- * session, or fewer messages / tool calls / images in this one.
2913
- *
2914
- * Reference: Mars-Sea/dsh-commandcode-provider issue #96 and its `JSON_NODE_LIMIT_REJECTION`.
2915
- */
2916
2123
  export function isRequestNodeLimit(err) {
2917
2124
  const msg = String(err?.message ?? "");
2918
2125
  const m = /JSON node count cannot exceed\s+(\d+)/i.exec(msg);