teapot-coding-agent 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { promises as fs } from "node:fs";
6
+ import { existsSync, promises as fs } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, } from "./skills.js";
9
9
  const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
@@ -36,7 +36,7 @@ function runShell(cmd, ctx, timeoutMs) {
36
36
  });
37
37
  let out = "";
38
38
  let done = false;
39
- let timedOut = false;
39
+ let killReason = null;
40
40
  const collect = (chunk) => {
41
41
  if (out.length < ctx.maxOutputBytes)
42
42
  out += chunk.toString("utf8");
@@ -53,11 +53,23 @@ function runShell(cmd, ctx, timeoutMs) {
53
53
  }
54
54
  };
55
55
  const timer = setTimeout(() => {
56
- timedOut = true;
56
+ killReason = `TIMEOUT after ${timeoutMs}ms`;
57
57
  killGroup();
58
58
  }, timeoutMs);
59
+ // harness shutdown must not wait out a long-running command
60
+ const onAbort = () => {
61
+ killReason = "ABORTED (harness shutdown)";
62
+ killGroup();
63
+ };
64
+ if (ctx.signal) {
65
+ if (ctx.signal.aborted)
66
+ onAbort();
67
+ else
68
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
69
+ }
59
70
  child.on("error", (err) => {
60
71
  clearTimeout(timer);
72
+ ctx.signal?.removeEventListener("abort", onAbort);
61
73
  if (!done) {
62
74
  done = true;
63
75
  resolve({ ok: false, result: `spawn error: ${err.message}` });
@@ -65,13 +77,14 @@ function runShell(cmd, ctx, timeoutMs) {
65
77
  });
66
78
  child.on("close", (code, signal) => {
67
79
  clearTimeout(timer);
80
+ ctx.signal?.removeEventListener("abort", onAbort);
68
81
  if (done)
69
82
  return;
70
83
  done = true;
71
- if (timedOut) {
84
+ if (killReason) {
72
85
  resolve({
73
86
  ok: false,
74
- result: `TIMEOUT after ${timeoutMs}ms. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
87
+ result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
75
88
  });
76
89
  return;
77
90
  }
@@ -83,17 +96,328 @@ function runShell(cmd, ctx, timeoutMs) {
83
96
  });
84
97
  });
85
98
  }
99
+ /** 1-based line number of each occurrence of needle in text. */
100
+ function matchLines(text, needle) {
101
+ const out = [];
102
+ let idx = text.indexOf(needle);
103
+ while (idx !== -1) {
104
+ out.push(text.slice(0, idx).split("\n").length);
105
+ idx = text.indexOf(needle, idx + Math.max(needle.length, 1));
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * Fuzzy-but-safe locator: find windows of lines equal to the pattern after
111
+ * trimming trailing whitespace on each side. Returns 0-based start line hits.
112
+ */
113
+ function trailingWsMatches(srcLines, patLines) {
114
+ const hits = [];
115
+ for (let i = 0; i + patLines.length <= srcLines.length; i++) {
116
+ let ok = true;
117
+ for (let j = 0; j < patLines.length; j++) {
118
+ if (srcLines[i + j].trimEnd() !== patLines[j].trimEnd()) {
119
+ ok = false;
120
+ break;
121
+ }
122
+ }
123
+ if (ok)
124
+ hits.push(i);
125
+ }
126
+ return hits;
127
+ }
86
128
  export const DEFAULT_TIMEOUT_MS = 120_000;
129
+ /* ---------- read_url cache ---------- */
130
+ const URL_CACHE_TTL_MS = 3_600_000;
131
+ const urlCache = new Map();
132
+ function clipText(s, max) {
133
+ const n = Math.max(1000, Math.min(max, 80_000));
134
+ return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
135
+ }
136
+ function compileRegex(pattern, ignoreCase) {
137
+ try {
138
+ return new RegExp(pattern, ignoreCase ? "i" : "");
139
+ }
140
+ catch (e) {
141
+ return `invalid regex: ${e.message}`;
142
+ }
143
+ }
144
+ /** Codex seek_sequence: find pattern lines at/after `start`, loosening match rules stepwise. */
145
+ function seekSequence(lines, pattern, start, eof) {
146
+ if (pattern.length === 0)
147
+ return start;
148
+ if (pattern.length > lines.length)
149
+ return null;
150
+ const searchStart = eof && lines.length >= pattern.length ? Math.max(start, lines.length - pattern.length) : start;
151
+ const eqExact = (a, b) => a === b;
152
+ const eqRstrip = (a, b) => a.trimEnd() === b.trimEnd();
153
+ const eqTrim = (a, b) => a.trim() === b.trim();
154
+ // typographic dashes/quotes/spaces → ASCII, mirroring codex's final pass
155
+ const normalise = (s) => s
156
+ .trim()
157
+ .replace(/[\u2010-\u2015\u2212]/g, "-")
158
+ .replace(/[\u2018-\u201B]/g, "'")
159
+ .replace(/[\u201C-\u201F]/g, '"')
160
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
161
+ const eqNorm = (a, b) => normalise(a) === normalise(b);
162
+ for (const eq of [eqExact, eqRstrip, eqTrim, eqNorm]) {
163
+ for (let i = searchStart; i + pattern.length <= lines.length; i++) {
164
+ let ok = true;
165
+ for (let j = 0; j < pattern.length; j++) {
166
+ if (!eq(lines[i + j], pattern[j])) {
167
+ ok = false;
168
+ break;
169
+ }
170
+ }
171
+ if (ok)
172
+ return i;
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+ function parsePatch(patch) {
178
+ let text = patch.trim();
179
+ // lenient: strip a heredoc wrapper (<<EOF … EOF), as models sometimes emit one
180
+ const lines0 = text.split("\n");
181
+ if (lines0.length >= 4 &&
182
+ ["<<EOF", "<<'EOF'", '<<"EOF"'].includes(lines0[0].trim()) &&
183
+ lines0[lines0.length - 1].trimEnd().endsWith("EOF")) {
184
+ text = lines0.slice(1, -1).join("\n").trim();
185
+ }
186
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
187
+ if (lines[0]?.trim() !== "*** Begin Patch")
188
+ return `invalid patch: The first line must be '*** Begin Patch'`;
189
+ if (lines[lines.length - 1]?.trim() !== "*** End Patch")
190
+ return `invalid patch: The last line must be '*** End Patch'`;
191
+ const ops = [];
192
+ let i = 1;
193
+ while (i < lines.length) {
194
+ const line = lines[i];
195
+ const t = line.trim();
196
+ if (t === "*** End Patch")
197
+ break;
198
+ if (!t || t.startsWith("*** Environment ID:")) {
199
+ i++;
200
+ continue;
201
+ }
202
+ let m = t.match(/^\*\*\* Add File: (.+)$/);
203
+ if (m) {
204
+ const body = [];
205
+ i++;
206
+ while (i < lines.length && lines[i].startsWith("+"))
207
+ body.push(lines[i++].slice(1));
208
+ if (body.length === 0)
209
+ return `invalid patch: Add File hunk for '${m[1].trim()}' has no + lines`;
210
+ ops.push({ kind: "add", path: m[1].trim(), contents: body.join("\n") + "\n" });
211
+ continue;
212
+ }
213
+ m = t.match(/^\*\*\* Delete File: (.+)$/);
214
+ if (m) {
215
+ ops.push({ kind: "delete", path: m[1].trim() });
216
+ i++;
217
+ continue;
218
+ }
219
+ m = t.match(/^\*\*\* Update File: (.+)$/);
220
+ if (m) {
221
+ const filePath = m[1].trim();
222
+ let movePath = null;
223
+ i++;
224
+ const mv = lines[i]?.trim().match(/^\*\*\* Move to: (.+)$/);
225
+ if (mv) {
226
+ movePath = mv[1].trim();
227
+ i++;
228
+ }
229
+ const chunks = [];
230
+ let cur = null;
231
+ const flush = () => {
232
+ if (cur)
233
+ chunks.push(cur);
234
+ cur = null;
235
+ };
236
+ while (i < lines.length && !lines[i].trim().startsWith("*** ")) {
237
+ const l = lines[i];
238
+ if (l.startsWith("@@")) {
239
+ flush();
240
+ cur = { changeContext: l.slice(2).trim() || null, oldLines: [], newLines: [], isEndOfFile: false };
241
+ i++;
242
+ continue;
243
+ }
244
+ if (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) {
245
+ cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false }; // implicit first chunk
246
+ if (l.startsWith("+"))
247
+ cur.newLines.push(l.slice(1));
248
+ else if (l.startsWith("-"))
249
+ cur.oldLines.push(l.slice(1));
250
+ else {
251
+ cur.oldLines.push(l.slice(1));
252
+ cur.newLines.push(l.slice(1));
253
+ }
254
+ i++;
255
+ continue;
256
+ }
257
+ if (l.trim() === "*** End of File") {
258
+ if (!cur)
259
+ return `invalid patch: *** End of File outside a hunk in '${filePath}'`;
260
+ cur.isEndOfFile = true;
261
+ i++;
262
+ continue;
263
+ }
264
+ if (!l.trim()) {
265
+ i++;
266
+ continue; // blank between hunks
267
+ }
268
+ return `invalid patch: bad line in Update File '${filePath}': "${l.slice(0, 60)}" (expected ' ', '-', '+' or '@@')`;
269
+ }
270
+ flush();
271
+ if (chunks.length === 0)
272
+ return `invalid patch: Update File hunk for path '${filePath}' is empty`;
273
+ ops.push({ kind: "update", path: filePath, movePath, chunks });
274
+ continue;
275
+ }
276
+ return `invalid patch: unrecognized directive "${t.slice(0, 60)}"`;
277
+ }
278
+ return ops;
279
+ }
280
+ /** Compute the updated content of one file (no I/O writes). Error string on failure. */
281
+ async function deriveUpdate(p, displayPath, chunks) {
282
+ let raw;
283
+ try {
284
+ raw = await fs.readFile(p, "utf8");
285
+ }
286
+ catch {
287
+ return `Failed to read file to update ${displayPath}`;
288
+ }
289
+ const hadCrlf = raw.includes("\r\n");
290
+ const originalLines = raw.split("\n");
291
+ if (originalLines.at(-1) === "")
292
+ originalLines.pop(); // trailing newline → diff-standard line list
293
+ const replacements = [];
294
+ let lineIndex = 0;
295
+ for (const ch of chunks) {
296
+ if (ch.changeContext != null) {
297
+ const idx = seekSequence(originalLines, [ch.changeContext], lineIndex, false);
298
+ if (idx == null)
299
+ return `Failed to find context '${ch.changeContext}' in ${displayPath}`;
300
+ lineIndex = idx + 1;
301
+ }
302
+ let pattern = ch.oldLines;
303
+ let newSlice = ch.newLines;
304
+ if (pattern.length === 0) {
305
+ // codex semantics: a chunk with no context/removed lines appends at end of file
306
+ replacements.push([originalLines.length, 0, newSlice]);
307
+ continue;
308
+ }
309
+ let found = seekSequence(originalLines, pattern, lineIndex, ch.isEndOfFile);
310
+ if (found == null && pattern.at(-1) === "") {
311
+ // trailing "" usually represents the file's final newline sentinel
312
+ const p2 = pattern.slice(0, -1);
313
+ const n2 = newSlice.at(-1) === "" ? newSlice.slice(0, -1) : newSlice;
314
+ found = seekSequence(originalLines, p2, lineIndex, ch.isEndOfFile);
315
+ if (found != null) {
316
+ pattern = p2;
317
+ newSlice = n2;
318
+ }
319
+ }
320
+ if (found == null)
321
+ return (`Failed to find expected lines in ${displayPath}:\n${pattern.join("\n")}\n` +
322
+ `(re-read the file and regenerate the patch)`);
323
+ replacements.push([found, pattern.length, newSlice]);
324
+ lineIndex = found + pattern.length;
325
+ }
326
+ replacements.sort((a, b) => b[0] - a[0]); // descending so earlier edits keep indices valid
327
+ const out = originalLines.slice();
328
+ for (const [startIdx, oldLen, seg] of replacements)
329
+ out.splice(startIdx, oldLen, ...seg);
330
+ if (out.at(-1) !== "")
331
+ out.push("");
332
+ return { content: out.join("\n"), note: hadCrlf ? " (CRLF→LF)" : "" };
333
+ }
334
+ async function applyPatch(patch, ctx) {
335
+ const ops = parsePatch(patch);
336
+ if (typeof ops === "string")
337
+ return { ok: false, result: ops };
338
+ if (ops.length === 0)
339
+ return { ok: false, result: "patch contains no file operations" };
340
+ // resolve every path up front (workspace confinement + duplicate guard)
341
+ const seen = new Set();
342
+ const resolved = [];
343
+ try {
344
+ for (const op of ops) {
345
+ const abs = safeJoin(ctx.cwd, op.path);
346
+ if (seen.has(abs))
347
+ return { ok: false, result: `path touched twice in one patch: ${op.path}` };
348
+ seen.add(abs);
349
+ const r = { op, abs };
350
+ if (op.kind === "update" && op.movePath) {
351
+ r.absMove = safeJoin(ctx.cwd, op.movePath);
352
+ if (r.absMove === abs)
353
+ return { ok: false, result: `Move to: destination equals source (${op.path})` };
354
+ seen.add(r.absMove);
355
+ }
356
+ resolved.push(r);
357
+ }
358
+ }
359
+ catch (e) {
360
+ return { ok: false, result: e.message };
361
+ }
362
+ // phase 1 — validate everything, write nothing
363
+ const writes = [];
364
+ const deletes = [];
365
+ const summary = [];
366
+ try {
367
+ for (const { op, abs, absMove } of resolved) {
368
+ if (op.kind === "add") {
369
+ if (existsSync(abs))
370
+ return { ok: false, result: `Add File: ${op.path} already exists` };
371
+ writes.push({ abs, content: op.contents });
372
+ summary.push(`A ${op.path} (+${op.contents.split("\n").length - 1})`);
373
+ }
374
+ else if (op.kind === "delete") {
375
+ if (!existsSync(abs))
376
+ return { ok: false, result: `Delete File: ${op.path} not found` };
377
+ deletes.push(abs);
378
+ summary.push(`D ${op.path}`);
379
+ }
380
+ else {
381
+ const r = await deriveUpdate(abs, op.path, op.chunks);
382
+ if (typeof r === "string")
383
+ return { ok: false, result: r };
384
+ const dest = absMove ?? abs;
385
+ if (absMove && existsSync(absMove))
386
+ return { ok: false, result: `Move to: destination already exists (${op.movePath})` };
387
+ writes.push({ abs: dest, content: r.content });
388
+ if (absMove)
389
+ deletes.push(abs);
390
+ summary.push(`${absMove ? "R" : "U"} ${op.path}${absMove ? ` → ${op.movePath}` : ""} (${op.chunks.length} hunk${op.chunks.length > 1 ? "s" : ""})${r.note}`);
391
+ }
392
+ }
393
+ }
394
+ catch (e) {
395
+ return { ok: false, result: `patch validation failed: ${e.message}` };
396
+ }
397
+ // phase 2 — commit
398
+ for (const w of writes) {
399
+ await fs.mkdir(path.dirname(w.abs), { recursive: true });
400
+ await fs.writeFile(w.abs, w.content, "utf8");
401
+ }
402
+ for (const d of deletes)
403
+ await fs.rm(d).catch(() => { });
404
+ return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
405
+ }
87
406
  export const TOOLS = [
88
407
  {
89
408
  name: "read_file",
90
- description: "Read a text file from the workspace. Supports offset/limit for large files. Returns numbered lines.",
409
+ description: "Read a text file from the workspace. Returns numbered lines (`N| ` prefixes are display-only — never copy them into edit_file). " +
410
+ "With `pattern`, acts like grep: only matching lines (JS regex, optional `ignore_case`) plus `context` surrounding lines are returned. " +
411
+ "A negative `offset` counts from the end (-30 → last 30 lines, or last 30 matches in pattern mode).",
91
412
  parameters: {
92
413
  type: "object",
93
414
  properties: {
94
415
  path: { type: "string", description: "Path relative to workspace root" },
95
- offset: { type: "number", description: "1-indexed start line" },
96
- limit: { type: "number", description: "Max lines to return" },
416
+ offset: { type: "number", description: "1-indexed start line (negative = from end)" },
417
+ limit: { type: "number", description: "Max lines (or max matches in pattern mode)" },
418
+ pattern: { type: "string", description: "JS regex — return only matching lines (+context) instead of the whole file" },
419
+ context: { type: "number", description: "context lines around each pattern match (max 5)" },
420
+ ignore_case: { type: "boolean", description: "case-insensitive pattern matching" },
97
421
  },
98
422
  required: ["path"],
99
423
  },
@@ -101,7 +425,41 @@ export const TOOLS = [
101
425
  const p = safeJoin(ctx.cwd, str(args.path));
102
426
  const text = await readText(p);
103
427
  const lines = text.split("\n");
104
- const off = Math.max(0, num(args.offset, 1) - 1);
428
+ // grep mode
429
+ if (typeof args.pattern === "string" && args.pattern !== "") {
430
+ const re = compileRegex(args.pattern, args.ignore_case === true);
431
+ if (typeof re === "string")
432
+ return { ok: false, result: re };
433
+ const idxs = [];
434
+ for (let i = 0; i < lines.length; i++)
435
+ if (re.test(lines[i]))
436
+ idxs.push(i);
437
+ if (idxs.length === 0)
438
+ return { ok: true, result: `(no matches for /${args.pattern}/)` };
439
+ let off = num(args.offset, 1);
440
+ off = off < 0 ? Math.max(0, idxs.length + off) : Math.max(0, off - 1);
441
+ const lim = Math.min(num(args.limit, 100), 1000);
442
+ const page = idxs.slice(off, off + lim);
443
+ const cN = Math.max(0, Math.min(num(args.context, 0), 5));
444
+ const regions = [];
445
+ for (const m of page) {
446
+ const s = Math.max(0, m - cN);
447
+ const e = Math.min(lines.length - 1, m + cN);
448
+ const last = regions[regions.length - 1];
449
+ if (last && s <= last[1] + 1)
450
+ last[1] = Math.max(last[1], e);
451
+ else
452
+ regions.push([s, e]);
453
+ }
454
+ const parts = regions.map(([s, e]) => lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
455
+ let result = parts.join("\n--\n");
456
+ if (idxs.length > page.length || off > 0)
457
+ result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
458
+ return { ok: true, result };
459
+ }
460
+ // plain mode
461
+ let off = num(args.offset, 1);
462
+ off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
105
463
  const lim = num(args.limit, 2000);
106
464
  const slice = lines.slice(off, off + lim).map((l, i) => `${off + i + 1}| ${l}`);
107
465
  const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
@@ -110,7 +468,9 @@ export const TOOLS = [
110
468
  },
111
469
  {
112
470
  name: "write_file",
113
- description: "Create or overwrite a file with the given content (parent dirs auto-created).",
471
+ description: "Create ONE new file, or replace a file's entire content (parent dirs auto-created). " +
472
+ "Creating files as part of a larger batch of edits → one apply_patch instead. " +
473
+ "Partial changes to an existing file → edit_file.",
114
474
  parameters: {
115
475
  type: "object",
116
476
  properties: {
@@ -128,27 +488,110 @@ export const TOOLS = [
128
488
  },
129
489
  {
130
490
  name: "edit_file",
131
- description: "Replace an exact unique substring in a file. old_text must match exactly and be unique.",
491
+ description: "Make exactly ONE small, unique replacement in one existing file the cheapest tool for a single spot change. " +
492
+ "Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
493
+ "if it matches several places, add surrounding lines or pass replace_all=true. " +
494
+ "Two or more changes (or a rename/delete) → use apply_patch instead.",
132
495
  parameters: {
133
496
  type: "object",
134
497
  properties: {
135
498
  path: { type: "string" },
136
499
  old_text: { type: "string" },
137
500
  new_text: { type: "string" },
501
+ replace_all: { type: "boolean", description: "replace every occurrence instead of requiring uniqueness" },
138
502
  },
139
503
  required: ["path", "old_text", "new_text"],
140
504
  },
141
505
  async run(args, ctx) {
142
506
  const p = safeJoin(ctx.cwd, str(args.path));
143
- const text = await readText(p);
507
+ let text = await readText(p);
144
508
  const oldText = str(args.old_text);
145
- const count = text.split(oldText).length - 1;
146
- if (count === 0)
147
- return { ok: false, result: "old_text not found in file" };
148
- if (count > 1)
149
- return { ok: false, result: `old_text matched ${count} times; must be unique` };
150
- await fs.writeFile(p, text.replace(oldText, str(args.new_text)), "utf8");
151
- return { ok: true, result: "edited" };
509
+ const newText = str(args.new_text);
510
+ if (!oldText)
511
+ return { ok: false, result: "old_text is required" };
512
+ const replaceAll = args.replace_all === true;
513
+ let count = text.split(oldText).length - 1;
514
+ let normalized = false;
515
+ // tolerate LF patterns against CRLF files (convert once, on success)
516
+ if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
517
+ const lf = text.replace(/\r\n/g, "\n");
518
+ const lfCount = lf.split(oldText).length - 1;
519
+ if (lfCount >= 1) {
520
+ text = lf;
521
+ count = lfCount;
522
+ normalized = true;
523
+ }
524
+ }
525
+ // tolerate patterns whose only difference is trailing whitespace per line
526
+ // (the most common near-miss) — applied only when it resolves uniquely
527
+ if (count === 0) {
528
+ const srcLines = text.replace(/\r\n/g, "\n").split("\n");
529
+ const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
530
+ const hits = trailingWsMatches(srcLines, patLines);
531
+ if (hits.length === 1 && patLines.length > 0) {
532
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
533
+ const rebuilt = [
534
+ ...srcLines.slice(0, hits[0]),
535
+ ...newText.replace(/\r\n/g, "\n").split("\n"),
536
+ ...srcLines.slice(hits[0] + patLines.length),
537
+ ].join(eol);
538
+ await fs.writeFile(p, rebuilt, "utf8");
539
+ return {
540
+ ok: true,
541
+ result: `edited (matched ignoring trailing whitespace around ${path.basename(str(args.path))}:${hits[0] + 1})`,
542
+ };
543
+ }
544
+ if (hits.length > 1)
545
+ return {
546
+ ok: false,
547
+ result: `old_text matched ${hits.length} times ignoring trailing whitespace (lines ${hits.map((h) => h + 1).join(", ")}); add surrounding lines to disambiguate`,
548
+ };
549
+ }
550
+ if (count === 0) {
551
+ // actionable miss: point the model at recovery instead of pushing it
552
+ // toward bash-based editing
553
+ const lines = matchLines(text, oldText.trim());
554
+ const hint = lines.length
555
+ ? `A trimmed variant appears at line(s) ${lines.slice(0, 5).join(", ")}.`
556
+ : `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
557
+ return {
558
+ ok: false,
559
+ result: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
560
+ };
561
+ }
562
+ if (count > 1 && !replaceAll) {
563
+ const at = matchLines(text, oldText);
564
+ return {
565
+ ok: false,
566
+ 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`,
567
+ };
568
+ }
569
+ await fs.writeFile(p, replaceAll ? text.split(oldText).join(newText) : text.replace(oldText, newText), "utf8");
570
+ const where = count > 1 ? ` (${count} occurrences)` : "";
571
+ return {
572
+ ok: true,
573
+ result: `${replaceAll ? "replaced all" : "edited"}${where}${normalized ? " (file converted CRLF→LF)" : ""}`,
574
+ };
575
+ },
576
+ },
577
+ {
578
+ name: "apply_patch",
579
+ description: "Apply a Codex-style patch: several edits in one file, changes across MULTIPLE files, renames, deletes — " +
580
+ "all validated first and applied atomically (any failure → nothing is written). Reach for this whenever " +
581
+ "one edit_file call would not cover the change. Every hunk is located with whitespace-tolerant fallbacks. Format:\n" +
582
+ '*** Begin Patch\n*** Add File: rel/new.txt\n+created line\n*** Update File: src/app.py\n@@ def main():\n context line\n-old line\n+new line\n*** Move to: src/main.py\n*** Delete File: obsolete.txt\n*** End Patch\n' +
583
+ "Update hunks: lines prefixed ' ' are context, '-' removed, '+' added. '@@ hint' optionally locates the area first; " +
584
+ "several hunks apply top-to-bottom. A hunk with only + lines appends at end of file; " +
585
+ "'*** End of File' anchors a hunk at the tail. For a single tiny replacement, edit_file is cheaper.",
586
+ parameters: {
587
+ type: "object",
588
+ properties: {
589
+ patch: { type: "string", description: "the full *** Begin Patch … *** End Patch text" },
590
+ },
591
+ required: ["patch"],
592
+ },
593
+ async run(args, ctx) {
594
+ return applyPatch(str(args.patch), ctx);
152
595
  },
153
596
  },
154
597
  {
@@ -169,7 +612,9 @@ export const TOOLS = [
169
612
  },
170
613
  {
171
614
  name: "bash",
172
- description: "Run a bash command inside the workspace (use it for git, builds, tests, etc.). " +
615
+ description: "Run a bash command inside the workspace git, builds, tests, searches and other COMMANDS. " +
616
+ "Also fine for quick shell-style file edits (sed/awk bulk transforms) when that is genuinely the better tool; " +
617
+ "for most changes the file tools below are easier to get right (no quoting, validated before writing). " +
173
618
  "Killed (whole process group) on timeout. stdout+stderr are returned.",
174
619
  parameters: {
175
620
  type: "object",
@@ -183,6 +628,77 @@ export const TOOLS = [
183
628
  return runShell(str(args.command), ctx, Math.min(num(args.timeout_ms, ctx.defaultTimeoutMs), 600_000));
184
629
  },
185
630
  },
631
+ {
632
+ name: "read_url",
633
+ description: "Fetch a web page and return its main readable content (title + plain text, boilerplate stripped via " +
634
+ "Mozilla Readability) — documentation, articles, issue threads. Cached for an hour per URL. " +
635
+ "For raw JSON/API responses or file downloads prefer bash curl.",
636
+ parameters: {
637
+ type: "object",
638
+ properties: {
639
+ url: { type: "string", description: "absolute http(s) URL" },
640
+ limit: { type: "number", description: "max characters returned (default 20000)" },
641
+ },
642
+ required: ["url"],
643
+ },
644
+ async run(args) {
645
+ const raw = str(args.url);
646
+ if (!URL.canParse(raw))
647
+ return { ok: false, result: `invalid url: ${raw.slice(0, 200)}` };
648
+ const u = new URL(raw);
649
+ if (u.protocol !== "http:" && u.protocol !== "https:")
650
+ return { ok: false, result: `unsupported protocol: ${u.protocol}` };
651
+ const key = u.toString();
652
+ const cached = urlCache.get(key);
653
+ if (cached && Date.now() - cached.at < URL_CACHE_TTL_MS)
654
+ return { ok: true, result: clipText(cached.text, num(args.limit, 20_000)) };
655
+ let res;
656
+ try {
657
+ res = await fetch(u, {
658
+ redirect: "follow",
659
+ signal: AbortSignal.timeout(45_000),
660
+ headers: { "user-agent": "Mozilla/5.0 (compatible; teapot-coding-agent)" },
661
+ });
662
+ }
663
+ catch (e) {
664
+ return { ok: false, result: `fetch failed: ${e.message}` };
665
+ }
666
+ const html = await res.text();
667
+ if (!html.trim())
668
+ return { ok: false, result: `HTTP ${res.status} with an empty body` };
669
+ // heavy DOM deps are loaded lazily so the master's idle startup stays lean
670
+ const { Browser } = await import("happy-dom");
671
+ const { Readability } = await import("@mozilla/readability");
672
+ const browser = new Browser();
673
+ let text = "";
674
+ try {
675
+ const page = browser.newPage();
676
+ page.url = key;
677
+ page.content = html;
678
+ const article = new Readability(page.mainFrame.document).parse();
679
+ text =
680
+ [article?.title, article?.byline]
681
+ .filter(Boolean)
682
+ .join(" — ") + `\n(HTTP ${res.status}, ~${(article?.textContent ?? "").length} chars extracted)\n\n` +
683
+ (article?.textContent ?? page.mainFrame.document.body?.textContent ?? "").replace(/\n{3,}/g, "\n\n").trim();
684
+ }
685
+ catch (e) {
686
+ return { ok: false, result: `failed to parse page: ${e.message}` };
687
+ }
688
+ finally {
689
+ await browser.close().catch(() => { });
690
+ }
691
+ if (res.ok && text.trim()) {
692
+ if (urlCache.size >= 40) {
693
+ const oldest = [...urlCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
694
+ if (oldest)
695
+ urlCache.delete(oldest[0]);
696
+ }
697
+ urlCache.set(key, { at: Date.now(), text });
698
+ }
699
+ return { ok: res.ok || text.length > 0, result: clipText(text, num(args.limit, 20_000)) };
700
+ },
701
+ },
186
702
  {
187
703
  name: "load_skill",
188
704
  description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
@@ -253,6 +769,8 @@ export async function executeTool(name, rawArgs, ctx) {
253
769
  const def = TOOLS.find((t) => t.name === name);
254
770
  if (!def)
255
771
  return { ok: false, result: `unknown tool: ${name}` };
772
+ if (ctx.signal?.aborted)
773
+ return { ok: false, result: "aborted (harness shutdown)" };
256
774
  let args;
257
775
  try {
258
776
  args = rawArgs ? JSON.parse(rawArgs) : {};
package/dist/bus.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  /** Tiny process-wide pub/sub used to push updates to SSE clients (no polling). */
3
3
  export const bus = new EventEmitter();
4
- bus.setMaxListeners(100);
4
+ bus.setMaxListeners(1000); // one per connected client (WS + SSE) — headroom for busy LAN setups