s-app-scoder-agent 0.8.21 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -3,12 +3,13 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { resolve, dirname, basename, join, relative, isAbsolute, sep } from "node:path";
4
4
  import { unifiedDiff } from "./diff.js";
5
5
  import { detectImageBytes } from "./tui/image.js";
6
- import { discoverSkills, findSkill } from "./skills.js";
6
+ import { discoverSkills, findSkill, loadSkillSection } from "./skills.js";
7
7
  import { redactSecrets } from "./llm.js";
8
8
  import { loadRcFile } from "./config.js";
9
9
  import { resolveSandbox } from "./sandbox.js";
10
10
  import * as ui from "./ui.js";
11
11
  import { toolWebFetch } from "./webfetch.js";
12
+ import { buildRepoMap, renderRepoMap } from "./repo-map.js";
12
13
  /**
13
14
  * G7: max chars tool output — default 20_000, runtime override qua
14
15
  * setMaxOutputChars (agent constructor gọi với config.limits).
@@ -56,7 +57,7 @@ function safePath(workdir, p) {
56
57
  for (;;) {
57
58
  try {
58
59
  const real = realpathSync(probe);
59
- const reconstructed = real + missing.map((s) => `/${s}`).join("");
60
+ const reconstructed = real + [...missing].reverse().map((s) => `/${s}`).join("");
60
61
  const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
61
62
  if (rel.startsWith("..") || isAbsolute(rel)) {
62
63
  throw new Error(`Path escapes working directory: ${p}`);
@@ -97,12 +98,22 @@ export function isProtectedEnvFile(relPath) {
97
98
  return "ask";
98
99
  return null;
99
100
  }
101
+ /** Coerce a schema-provided numeric-ish arg to a positive int, else fallback. */
102
+ function toPosInt(v, fallback) {
103
+ const n = typeof v === "number" ? v : Number(v);
104
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
105
+ }
100
106
  function toolReadFile(workdir, args) {
101
107
  return toResult(() => {
102
108
  const abs = safePath(workdir, args.path);
103
109
  // H-04: .env read policy — ask trước khi trả nội dung (secrets hygiene).
104
110
  const rel = relative(workdir, abs);
105
- const envPolicy = isProtectedEnvFile(rel);
111
+ let policyPath = rel;
112
+ try {
113
+ policyPath = relative(workdir, realpathSync(abs));
114
+ }
115
+ catch { /* handled by safePath */ }
116
+ const envPolicy = isProtectedEnvFile(policyPath.toLowerCase());
106
117
  if (envPolicy === "ask") {
107
118
  return {
108
119
  ok: false,
@@ -135,10 +146,20 @@ function toolReadFile(workdir, args) {
135
146
  if (image) {
136
147
  return { ok: true, output: `[image: ${args.path}]`, image };
137
148
  }
149
+ if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
150
+ return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB edit limit.` };
151
+ }
138
152
  const content = readFileSync(abs, "utf8");
139
153
  const lines = content.split("\n");
140
- const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
141
- return { ok: true, output: clamp(numbered) };
154
+ const total = lines.length;
155
+ // v2: line-range reads — offset (1-based, default 1) + limit (default all).
156
+ const offset = Math.min(toPosInt(args.offset, 1), total);
157
+ const limit = Math.min(toPosInt(args.limit, total - offset + 1), 2000);
158
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
159
+ const numbered = slice.map((l, i) => `${offset + i}: ${l}`).join("\n");
160
+ const end = offset + slice.length - 1;
161
+ const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
162
+ return { ok: true, output: clamp(header + numbered) };
142
163
  });
143
164
  }
144
165
  function toToolDiff(path, diff) {
@@ -206,6 +227,47 @@ async function toolWriteFile(ctx, args) {
206
227
  diff: toToolDiff(args.path, diff),
207
228
  };
208
229
  }
230
+ /** v2: whitespace-insensitive regex for edit_file fuzzy fallback. */
231
+ function wsInsensitiveRegex(s) {
232
+ let rx = "";
233
+ for (const ch of s) {
234
+ if (/\s/.test(ch))
235
+ rx += "\\s+";
236
+ else
237
+ rx += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ }
239
+ return new RegExp(rx);
240
+ }
241
+ /** Apply one exact (or whitespace-tolerant) replacement to in-memory content. */
242
+ function applyOneEdit(content, path, oldString, newString) {
243
+ if (typeof oldString !== "string" || oldString.length === 0) {
244
+ return { ok: false, error: "old_string must be a non-empty string." };
245
+ }
246
+ if (content.includes(oldString)) {
247
+ const count = content.split(oldString).length - 1;
248
+ if (count > 1) {
249
+ return { ok: false, error: `old_string matches ${count} locations in ${path}; include more surrounding context to make it unique.` };
250
+ }
251
+ // Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
252
+ // which would corrupt literal text like "$&" in the new content.
253
+ return { ok: true, content: content.replace(oldString, () => newString), fuzzy: false };
254
+ }
255
+ // v2 fuzzy fallback: exact string missing → retry with whitespace-tolerant
256
+ // matching (indent/trailing-space drift between model and file). Reports
257
+ // ambiguity instead of guessing when multiple regions match.
258
+ const all = [...content.matchAll(new RegExp(wsInsensitiveRegex(oldString).source, "g"))];
259
+ if (all.length === 0) {
260
+ return { ok: false, error: `old_string not found in ${path}. Read the file again and retry with exact text.` };
261
+ }
262
+ if (all.length > 1) {
263
+ return { ok: false, error: `old_string not found exactly and ${all.length} whitespace-tolerant matches exist in ${path}; include more surrounding context to disambiguate.` };
264
+ }
265
+ return {
266
+ ok: true,
267
+ content: content.slice(0, all[0].index) + newString + content.slice(all[0].index + all[0][0].length),
268
+ fuzzy: true,
269
+ };
270
+ }
209
271
  async function toolEditFile(ctx, args) {
210
272
  let abs;
211
273
  try {
@@ -215,16 +277,10 @@ async function toolEditFile(ctx, args) {
215
277
  return { ok: false, output: `ERROR: ${err.message}` };
216
278
  }
217
279
  const content = readFileSync(abs, "utf8");
218
- if (!content.includes(args.old_string)) {
219
- return { ok: false, output: `old_string not found in ${args.path}. Read the file again and retry with exact text.` };
220
- }
221
- const count = content.split(args.old_string).length - 1;
222
- if (count > 1) {
223
- return { ok: false, output: `old_string matches ${count} locations in ${args.path}; include more surrounding context to make it unique.` };
224
- }
225
- // Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
226
- // which would corrupt literal text like "$&" in the new content.
227
- const newContent = content.replace(args.old_string, () => args.new_string);
280
+ const outcome = applyOneEdit(content, args.path, args.old_string, args.new_string);
281
+ if (!outcome.ok)
282
+ return { ok: false, output: outcome.error };
283
+ const newContent = outcome.content;
228
284
  const diff = unifiedDiff(args.path, content, newContent);
229
285
  const gate = await gateWrite(ctx, args.path, diff);
230
286
  if (gate)
@@ -233,7 +289,65 @@ async function toolEditFile(ctx, args) {
233
289
  const fmtNote = formatFile(ctx, abs);
234
290
  return {
235
291
  ok: true,
236
- output: `Edited ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
292
+ output: `Edited ${args.path}${outcome.fuzzy ? " (fuzzy whitespace match)" : ""}${fmtNote ? `\n${fmtNote}` : ""}`,
293
+ diff: toToolDiff(args.path, diff),
294
+ };
295
+ }
296
+ async function toolApplyPatch(ctx, args) {
297
+ let abs;
298
+ try {
299
+ abs = safePath(ctx.workdir, args.path);
300
+ }
301
+ catch (err) {
302
+ return { ok: false, output: `ERROR: ${err.message}` };
303
+ }
304
+ if (!Array.isArray(args.edits) || args.edits.length === 0) {
305
+ return { ok: false, output: "ERROR: edits must be a non-empty array." };
306
+ }
307
+ if (args.edits.length > 50) {
308
+ return { ok: false, output: `ERROR: too many edits (${args.edits.length}); split into chunks of at most 50.` };
309
+ }
310
+ let content;
311
+ try {
312
+ if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
313
+ return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB patch limit.` };
314
+ }
315
+ content = readFileSync(abs, "utf8");
316
+ }
317
+ catch (err) {
318
+ return { ok: false, output: `ERROR: cannot read ${args.path}: ${err.message}` };
319
+ }
320
+ const original = content;
321
+ let fuzzy = 0;
322
+ for (let i = 0; i < args.edits.length; i++) {
323
+ const e = args.edits[i];
324
+ if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
325
+ return { ok: false, output: `ERROR: edit #${i + 1} must have string old_string and new_string. No changes written.` };
326
+ }
327
+ const outcome = applyOneEdit(content, args.path, e.old_string, e.new_string);
328
+ if (!outcome.ok) {
329
+ return {
330
+ ok: false,
331
+ output: `ERROR: edit #${i + 1}/${args.edits.length} failed — ${outcome.error} No changes written.`,
332
+ };
333
+ }
334
+ content = outcome.content;
335
+ if (outcome.fuzzy)
336
+ fuzzy++;
337
+ }
338
+ if (content === original) {
339
+ return { ok: true, output: `No changes to ${args.path} (all edits were no-ops).` };
340
+ }
341
+ const diff = unifiedDiff(args.path, original, content);
342
+ const gate = await gateWrite(ctx, args.path, diff);
343
+ if (gate)
344
+ return gate;
345
+ writeFileSync(abs, content, "utf8");
346
+ const fmtNote = formatFile(ctx, abs);
347
+ const fuzzyNote = fuzzy > 0 ? ` (${fuzzy} fuzzy whitespace match${fuzzy === 1 ? "" : "es"})` : "";
348
+ return {
349
+ ok: true,
350
+ output: `Applied ${args.edits.length} edit${args.edits.length === 1 ? "" : "s"} to ${args.path}${fuzzyNote}${fmtNote ? `\n${fmtNote}` : ""}`,
237
351
  diff: toToolDiff(args.path, diff),
238
352
  };
239
353
  }
@@ -241,9 +355,17 @@ function toolListFiles(workdir, args) {
241
355
  return toResult(() => {
242
356
  const root = safePath(workdir, args.path ?? ".");
243
357
  const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
358
+ // v2: configurable depth (default 4, cap 8), hidden-dotfiles opt-in, exclude globs.
359
+ const maxDepth = Math.min(toPosInt(args.depth, 4), 8);
360
+ const includeHidden = args.include_hidden === true || args.include_hidden === "true";
361
+ const excludeRx = args.exclude
362
+ ? new RegExp(args.exclude.split(/[,\s]+/).filter(Boolean)
363
+ .map((g) => globToRegExp(g).source)
364
+ .join("|"))
365
+ : null;
244
366
  const out = [];
245
367
  const walk = (dir, depth) => {
246
- if (depth > 4 || out.length > 500)
368
+ if (depth > maxDepth || out.length > 500)
247
369
  return;
248
370
  let entries;
249
371
  try {
@@ -255,15 +377,20 @@ function toolListFiles(workdir, args) {
255
377
  for (const e of entries) {
256
378
  if (skip.has(e))
257
379
  continue;
380
+ if (!includeHidden && e.startsWith(".") && e !== ".github")
381
+ continue;
382
+ if (excludeRx?.test(e))
383
+ continue;
258
384
  const full = join(dir, e);
259
385
  let st;
260
386
  try {
387
+ safePath(workdir, relative(workdir, full));
261
388
  st = statSync(full);
262
389
  }
263
390
  catch {
264
391
  continue;
265
392
  }
266
- const rel = relative(workdir, full);
393
+ const rel = relative(workdir, full).split(sep).join("/");
267
394
  if (st.isDirectory()) {
268
395
  out.push(`${rel}/`);
269
396
  walk(full, depth + 1);
@@ -314,6 +441,10 @@ export function toolGrep(workdir, args) {
314
441
  if (!pattern)
315
442
  return { ok: false, output: "ERROR: pattern is required." };
316
443
  const root = safePath(workdir, args.path ?? ".");
444
+ // v2: context lines + hit cap — before/after look like ripgrep -B/-A.
445
+ const ctxBefore = Math.min(toPosInt(args.context_before, 0), 20);
446
+ const ctxAfter = Math.min(toPosInt(args.context_after, 0), 20);
447
+ const maxResults = Math.min(toPosInt(args.max_results, MAX_SEARCH_HITS), 100);
317
448
  // Fast path: ripgrep (respects .gitignore, fast, JSON output). Skip
318
449
  // common noise dirs even when the project has no .gitignore.
319
450
  const rgArgs = [
@@ -324,22 +455,42 @@ export function toolGrep(workdir, args) {
324
455
  "--glob", "!build", "--glob", "!.next", "--glob", "!__pycache__",
325
456
  "--glob", "!.venv",
326
457
  ];
458
+ if (ctxBefore > 0)
459
+ rgArgs.push("-B", String(ctxBefore));
460
+ if (ctxAfter > 0)
461
+ rgArgs.push("-A", String(ctxAfter));
327
462
  if (args.include)
328
463
  rgArgs.push("-g", args.include);
464
+ if (args.type)
465
+ rgArgs.push("--type", String(args.type));
329
466
  const rg = spawnSync("rg", rgArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
330
467
  const lines = [];
331
468
  let count = 0;
332
469
  if ((rg.status ?? 2) === 0 || rg.stdout) {
470
+ let prevLine = -1;
471
+ let prevRel = "";
333
472
  for (const line of rg.stdout.split("\n")) {
334
- if (!line || count >= MAX_SEARCH_HITS)
473
+ if (!line)
474
+ continue;
475
+ if (count >= maxResults)
335
476
  break;
336
477
  try {
337
478
  const j = JSON.parse(line);
338
- if (j.type !== "match" || !j.data)
479
+ if (!j.data || (j.type !== "match" && j.type !== "context"))
339
480
  continue;
340
481
  const rel = relative(workdir, j.data.path?.text ?? "").split(sep).join("/");
341
- lines.push(`${rel}:${j.data.line_number}: ${(j.data.lines?.text ?? "").trimEnd().slice(0, 200)}`);
342
- count++;
482
+ const ln = j.data.line_number ?? 0;
483
+ const text = (j.data.lines?.text ?? "").trimEnd().slice(0, 200);
484
+ // GNU-style: match lines keep ':' separators, context lines use '-'.
485
+ if (rel !== prevRel || (prevLine >= 0 && ln > prevLine + 1)) {
486
+ if (lines.length > 0)
487
+ lines.push("--");
488
+ }
489
+ lines.push(`${rel}${j.type === "match" ? ":" : "-"}${ln}${j.type === "match" ? ":" : "-"} ${text}`);
490
+ if (j.type === "match")
491
+ count++;
492
+ prevLine = ln;
493
+ prevRel = rel;
343
494
  }
344
495
  catch { /* skip malformed */ }
345
496
  }
@@ -349,14 +500,30 @@ export function toolGrep(workdir, args) {
349
500
  // status >= 2 → rg error; fall through to grep fallback.
350
501
  }
351
502
  // Fallback: grep -rn (portable) — output already path:line:text.
352
- const g = spawnSync("grep", ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern, root], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
503
+ const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
504
+ if (ctxBefore > 0)
505
+ gArgs.push("-B", String(ctxBefore));
506
+ if (ctxAfter > 0)
507
+ gArgs.push("-A", String(ctxAfter));
508
+ gArgs.push(root);
509
+ const g = spawnSync("grep", gArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
353
510
  if ((g.status ?? 2) <= 1) {
354
- const hits = (g.stdout ?? "").split("\n").filter(Boolean).slice(0, MAX_SEARCH_HITS)
355
- .map((l) => l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
356
- return { ok: true, output: hits.length > 0 ? clamp(hits.join("\n")) : "(no matches)" };
511
+ const hits = (g.stdout ?? "").split("\n").filter(Boolean);
512
+ const out = [];
513
+ let matches = 0;
514
+ for (const l of hits) {
515
+ // Context lines GNU-separated with '-'; matches with ':'.
516
+ const isMatch = /^[^:]*:\d+:/.test(l);
517
+ if (isMatch && matches >= maxResults)
518
+ continue;
519
+ if (isMatch)
520
+ matches++;
521
+ out.push(l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
522
+ }
523
+ return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no matches)" };
357
524
  }
358
525
  // rg vắng mặt + grep vắng mặt/lỗi → scan JS thuần (luôn chạy được, không cần binary).
359
- return jsGrepFallback(workdir, root, pattern, args.include);
526
+ return jsGrepFallback(workdir, root, pattern, args.include, ctxBefore, ctxAfter, maxResults);
360
527
  });
361
528
  }
362
529
  /**
@@ -365,7 +532,7 @@ export function toolGrep(workdir, args) {
365
532
  * — quan trọng trên Windows máy chưa cài ripgrep. Pattern là regex JS; regex
366
533
  * không hợp lệ → coi như literal substring.
367
534
  */
368
- function jsGrepFallback(workdir, root, pattern, include) {
535
+ function jsGrepFallback(workdir, root, pattern, include, ctxBefore = 0, ctxAfter = 0, maxResults = MAX_SEARCH_HITS) {
369
536
  let rx;
370
537
  try {
371
538
  rx = new RegExp(pattern);
@@ -377,8 +544,9 @@ function jsGrepFallback(workdir, root, pattern, include) {
377
544
  ? globToRegExp(include.includes("/") ? include : `**/${include}`)
378
545
  : null;
379
546
  const hits = [];
547
+ let matches = 0;
380
548
  walkFiles(root, (_name, abs) => {
381
- if (hits.length >= MAX_SEARCH_HITS)
549
+ if (matches >= maxResults)
382
550
  return;
383
551
  const rel = relative(workdir, abs).split(sep).join("/");
384
552
  if (includeRx && !includeRx.test(rel))
@@ -391,11 +559,34 @@ function jsGrepFallback(workdir, root, pattern, include) {
391
559
  if (buf.includes(0))
392
560
  return; // skip binary (NUL byte)
393
561
  const lines = buf.toString("utf8").split("\n");
394
- let perFile = 0;
395
- for (let i = 0; i < lines.length && perFile < 5 && hits.length < MAX_SEARCH_HITS; i++) {
396
- if (rx.test(lines[i])) {
397
- hits.push(`${rel}:${i + 1}: ${lines[i].trimEnd().slice(0, 200)}`);
398
- perFile++;
562
+ // Collect matching line numbers, then emit merged windows with context.
563
+ const matchIdx = [];
564
+ for (let i = 0; i < lines.length && matchIdx.length < 5; i++) {
565
+ if (rx.test(lines[i]))
566
+ matchIdx.push(i);
567
+ }
568
+ if (matchIdx.length === 0)
569
+ return;
570
+ // Merge overlapping windows.
571
+ const windows = [];
572
+ for (const i of matchIdx) {
573
+ const lo = Math.max(0, i - ctxBefore), hi = Math.min(lines.length - 1, i + ctxAfter);
574
+ const last = windows[windows.length - 1];
575
+ if (last && lo <= last[1] + 1)
576
+ last[1] = Math.max(last[1], hi);
577
+ else
578
+ windows.push([lo, hi]);
579
+ }
580
+ for (const [lo, hi] of windows) {
581
+ if (hits.length > 0 && hits[hits.length - 1] !== "--")
582
+ hits.push("--");
583
+ for (let i = lo; i <= hi; i++) {
584
+ const isMatch = matchIdx.includes(i);
585
+ if (isMatch && matches >= maxResults)
586
+ break;
587
+ hits.push(`${rel}${isMatch ? ":" : "-"}${i + 1}${isMatch ? ":" : "-"} ${lines[i].trimEnd().slice(0, 200)}`);
588
+ if (isMatch)
589
+ matches++;
399
590
  }
400
591
  }
401
592
  }
@@ -645,12 +836,14 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox) {
645
836
  signal?.addEventListener("abort", onAbort, { once: true });
646
837
  child.stdout?.on("data", (c) => {
647
838
  const s = c.toString("utf8");
648
- stdout += s;
839
+ if (stdout.length < MAX_OUTPUT_CHARS)
840
+ stdout += s.slice(0, MAX_OUTPUT_CHARS - stdout.length);
649
841
  onOutput?.("stdout", redactSecrets(s)); // G3
650
842
  });
651
843
  child.stderr?.on("data", (c) => {
652
844
  const s = c.toString("utf8");
653
- stderr += s;
845
+ if (stderr.length < MAX_OUTPUT_CHARS)
846
+ stderr += s.slice(0, MAX_OUTPUT_CHARS - stderr.length);
654
847
  onOutput?.("stderr", redactSecrets(s)); // G3
655
848
  });
656
849
  child.on("error", (err) => {
@@ -694,6 +887,7 @@ const obj = (properties, required) => ({
694
887
  additionalProperties: false,
695
888
  });
696
889
  const str = (description) => ({ type: "string", description });
890
+ const num = (description) => ({ type: "number", description });
697
891
  export const AGENT_TOOLS = [
698
892
  {
699
893
  spec: {
@@ -701,7 +895,10 @@ export const AGENT_TOOLS = [
701
895
  function: {
702
896
  name: "load_skill",
703
897
  description: "Load the full instructions of a named skill from the AVAILABLE SKILLS index (project + global). Call this at the start of a task when it matches a skill, then follow the returned instructions exactly.",
704
- parameters: obj({ name: str("Skill name from the AVAILABLE SKILLS index, e.g. 'prompt-gate-workflow'") }, ["name"]),
898
+ parameters: obj({
899
+ name: str("Skill name from the AVAILABLE SKILLS index, e.g. 'prompt-gate-workflow'"),
900
+ section: str("Optional section heading to load (progressive disclosure). Omit for full body."),
901
+ }, ["name"]),
705
902
  },
706
903
  },
707
904
  run: (ctx, args) => {
@@ -713,7 +910,10 @@ export const AGENT_TOOLS = [
713
910
  const names = discoverSkills(ctx.workdir).map((s) => s.name).join(", ");
714
911
  return { ok: false, output: `ERROR: skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered"}.` };
715
912
  }
716
- return { ok: true, output: `# skill: ${skill.name} (${skill.scope})\n\n${skill.body}` };
913
+ const section = args.section;
914
+ const body = section ? loadSkillSection(skill, String(section)) : skill.body;
915
+ const refs = skill.references?.length ? `\n\nReferences: ${skill.references.join(", ")}` : "";
916
+ return { ok: true, output: `# skill: ${skill.name} (${skill.scope})${skill.version ? ` v${skill.version}` : ""}\n\n${body}${refs}` };
717
917
  },
718
918
  },
719
919
  {
@@ -721,7 +921,8 @@ export const AGENT_TOOLS = [
721
921
  type: "function",
722
922
  function: {
723
923
  name: "write_todos",
724
- description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps. The user sees this list live.",
924
+ description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps. " +
925
+ "v2: each item may carry verify (how you will know it's done), depends_on (earlier step ids), files (expected paths) and acceptance (criteria). The user sees this list live.",
725
926
  parameters: obj({
726
927
  todos: {
727
928
  type: "array",
@@ -729,8 +930,13 @@ export const AGENT_TOOLS = [
729
930
  items: {
730
931
  type: "object",
731
932
  properties: {
933
+ id: { type: "string", description: "Stable step id (e.g. 't1'). Auto-assigned when omitted." },
732
934
  content: { type: "string", description: "Imperative step, e.g. 'Fix null check in UserService'" },
733
935
  status: { type: "string", enum: ["pending", "in_progress", "completed"] },
936
+ verify: { type: "string", description: "How to verify the step — command or observable behavior" },
937
+ depends_on: { type: "array", items: { type: "string" }, description: "Ids of steps that must complete first" },
938
+ files: { type: "array", items: { type: "string" }, description: "Files this step is expected to touch" },
939
+ acceptance: { type: "string", description: "Criteria for the step to count as done" },
734
940
  },
735
941
  required: ["content", "status"],
736
942
  additionalProperties: false,
@@ -744,18 +950,45 @@ export const AGENT_TOOLS = [
744
950
  if (!Array.isArray(todos))
745
951
  return { ok: false, output: "ERROR: todos must be an array of {content, status}." };
746
952
  const clean = [];
747
- for (const t of todos) {
953
+ for (const [i, t] of todos.entries()) {
748
954
  if (typeof t?.content !== "string" || !t.content.trim()) {
749
955
  return { ok: false, output: "ERROR: every todo needs a non-empty content string." };
750
956
  }
751
957
  if (!["pending", "in_progress", "completed"].includes(t.status)) {
752
958
  return { ok: false, output: `ERROR: invalid status "${String(t.status)}" (pending|in_progress|completed).` };
753
959
  }
754
- clean.push({ content: t.content.trim().slice(0, 200), status: t.status });
960
+ const item = {
961
+ id: typeof t.id === "string" && t.id.trim() ? t.id.trim().slice(0, 32) : `t${i + 1}`,
962
+ content: t.content.trim().slice(0, 200),
963
+ status: t.status,
964
+ };
965
+ if (typeof t.verify === "string" && t.verify.trim())
966
+ item.verify = t.verify.trim().slice(0, 300);
967
+ if (typeof t.acceptance === "string" && t.acceptance.trim())
968
+ item.acceptance = t.acceptance.trim().slice(0, 300);
969
+ if (Array.isArray(t.depends_on))
970
+ item.depends_on = t.depends_on.filter((x) => typeof x === "string").slice(0, 10);
971
+ if (Array.isArray(t.files))
972
+ item.files = t.files.filter((x) => typeof x === "string").slice(0, 20);
973
+ clean.push(item);
974
+ }
975
+ // v2 rule: exactly one in_progress at a time.
976
+ const inProgress = clean.filter((t) => t.status === "in_progress");
977
+ if (inProgress.length > 1) {
978
+ return { ok: false, output: `ERROR: exactly one in_progress step allowed (got ${inProgress.length}: ${inProgress.map((t) => t.content).join(" | ")}). Mark the others pending or completed.` };
979
+ }
980
+ // v2 rule: depends_on must reference earlier ids in the SAME plan.
981
+ const ids = new Set(clean.map((t) => t.id));
982
+ for (const t of clean) {
983
+ for (const dep of t.depends_on ?? []) {
984
+ if (!ids.has(dep))
985
+ return { ok: false, output: `ERROR: step "${t.content}" depends_on unknown id "${dep}" — ids in this plan: ${[...ids].join(", ")}` };
986
+ }
755
987
  }
756
988
  ctx.plan?.set(clean);
757
989
  const done = clean.filter((t) => t.status === "completed").length;
758
- return { ok: true, output: `Plan updated: ${done}/${clean.length} completed.` };
990
+ const next = clean.find((t) => t.status === "in_progress");
991
+ return { ok: true, output: `Plan updated: ${done}/${clean.length} completed.${next ? ` In progress: ${next.content}` : " (none in progress)"}` };
759
992
  },
760
993
  },
761
994
  {
@@ -763,8 +996,13 @@ export const AGENT_TOOLS = [
763
996
  type: "function",
764
997
  function: {
765
998
  name: "list_files",
766
- description: "List files and directories recursively under a path in the working directory.",
767
- parameters: obj({ path: str("Relative path, defaults to '.'") }, []),
999
+ description: "List files and directories recursively under a path in the working directory. Hidden dotfiles are excluded unless include_hidden=true; pass depth / exclude to shape the output.",
1000
+ parameters: obj({
1001
+ path: str("Relative path, defaults to '.'"),
1002
+ depth: num("Max directory depth to recurse (default 4, cap 8)"),
1003
+ exclude: str("Comma/space-separated globs to exclude, e.g. '*.log,coverage'"),
1004
+ include_hidden: str("'true' to include dotfiles (default: excluded)"),
1005
+ }, []),
768
1006
  },
769
1007
  },
770
1008
  run: (ctx, args) => toolListFiles(ctx.workdir, args),
@@ -774,8 +1012,12 @@ export const AGENT_TOOLS = [
774
1012
  type: "function",
775
1013
  function: {
776
1014
  name: "read_file",
777
- description: "Read a text file. Returns content with 1-based line numbers. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
778
- parameters: obj({ path: str("Relative file path") }, ["path"]),
1015
+ description: "Read a text file. Returns content with 1-based line numbers. Pass offset (1-based start line) and limit to read a slice instead of the whole file. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
1016
+ parameters: obj({
1017
+ path: str("Relative file path"),
1018
+ offset: num("1-based line to start from (default: 1)"),
1019
+ limit: num("Max lines to read (default: whole file, capped at 2000)"),
1020
+ }, ["path"]),
779
1021
  },
780
1022
  },
781
1023
  run: (ctx, args) => toolReadFile(ctx.workdir, args),
@@ -796,7 +1038,8 @@ export const AGENT_TOOLS = [
796
1038
  type: "function",
797
1039
  function: {
798
1040
  name: "edit_file",
799
- description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context.",
1041
+ description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context. " +
1042
+ "For multiple replacements in the same file, use apply_patch (atomic, one write).",
800
1043
  parameters: obj({
801
1044
  path: str("Relative file path"),
802
1045
  old_string: str("Exact text to replace"),
@@ -806,6 +1049,29 @@ export const AGENT_TOOLS = [
806
1049
  },
807
1050
  run: (ctx, args) => toolEditFile(ctx, args),
808
1051
  },
1052
+ {
1053
+ spec: {
1054
+ type: "function",
1055
+ function: {
1056
+ name: "apply_patch",
1057
+ description: "Apply multiple exact replacements to ONE file atomically. Edits run in order; each old_string must match exactly once " +
1058
+ "in the content produced by the previous edits. If any edit fails, nothing is written. " +
1059
+ "Use this instead of repeated edit_file calls for multi-hunk changes.",
1060
+ parameters: obj({
1061
+ path: str("Relative file path"),
1062
+ edits: {
1063
+ type: "array",
1064
+ description: "Ordered list of replacements applied atomically (1–50).",
1065
+ items: obj({
1066
+ old_string: str("Exact text to replace (must be unique at its turn)"),
1067
+ new_string: str("Replacement text"),
1068
+ }, ["old_string", "new_string"]),
1069
+ },
1070
+ }, ["path", "edits"]),
1071
+ },
1072
+ },
1073
+ run: (ctx, args) => toolApplyPatch(ctx, args),
1074
+ },
809
1075
  {
810
1076
  spec: {
811
1077
  type: "function",
@@ -814,7 +1080,7 @@ export const AGENT_TOOLS = [
814
1080
  description: "Run a shell command in the working directory (sh/bash -c; Windows: Git Bash when installed, else cmd /c). Use for builds, tests, git, installing deps, etc.",
815
1081
  parameters: obj({
816
1082
  command: str("Shell command to run"),
817
- timeout_secs: str("Timeout in seconds (default 120, max 600)"),
1083
+ timeout_secs: num("Timeout in seconds (default 120, max 600)"),
818
1084
  }, ["command"]),
819
1085
  },
820
1086
  },
@@ -846,12 +1112,16 @@ export const AGENT_TOOLS = [
846
1112
  type: "function",
847
1113
  function: {
848
1114
  name: "grep",
849
- description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits, max 50. " +
850
- "Use for finding code by symbol, error string, or pattern. Combine with read_file for context.",
1115
+ description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits (max 50 by default), with optional context lines and a hit cap. " +
1116
+ "Use for finding code by symbol, error string, or pattern. Combine with read_file for full context.",
851
1117
  parameters: obj({
852
1118
  pattern: str("Regular expression to search for"),
853
1119
  path: str("Directory or file to search (default: workdir root)"),
854
1120
  include: str("Glob filter for filenames, e.g. '*.ts'"),
1121
+ context_before: num("Context lines to print before each match (default 0)"),
1122
+ context_after: num("Context lines to print after each match (default 0)"),
1123
+ max_results: num("Max matching lines to return (default 50, cap 100)"),
1124
+ type: str("ripgrep file type filter, e.g. 'ts', 'py' (rg only; ignored by fallbacks)"),
855
1125
  }, ["pattern"]),
856
1126
  },
857
1127
  },
@@ -872,6 +1142,29 @@ export const AGENT_TOOLS = [
872
1142
  },
873
1143
  run: (ctx, args) => toolGlob(ctx.workdir, args),
874
1144
  },
1145
+ {
1146
+ spec: {
1147
+ type: "function",
1148
+ function: {
1149
+ name: "repo_map",
1150
+ description: "Return the repository map — file tree with sizes + exported symbols (regex-extracted, budget-bounded, cached). " +
1151
+ "Use to orient before reading files; cheaper than listing/reading everything.",
1152
+ parameters: obj({ path: str("Sub-directory to map (default: repo root)") }, []),
1153
+ },
1154
+ },
1155
+ run: (ctx, args) => {
1156
+ try {
1157
+ const sub = typeof args.path === "string" ? args.path.trim() : "";
1158
+ const out = sub
1159
+ ? renderRepoMap(ctx.workdir, 4000, sub)
1160
+ : buildRepoMap(ctx.workdir);
1161
+ return { ok: true, output: out };
1162
+ }
1163
+ catch (err) {
1164
+ return { ok: false, output: `ERROR: repo map failed: ${err.message}` };
1165
+ }
1166
+ },
1167
+ },
875
1168
  ];
876
1169
  /** task tool — runner resolved from ctx.taskRunner (per-agent, no global). */
877
1170
  const taskTool = {
@@ -879,22 +1172,46 @@ const taskTool = {
879
1172
  type: "function",
880
1173
  function: {
881
1174
  name: "task",
882
- description: "Delegate a self-contained subtask to a subagent that runs its own agent loop with its own context. " +
1175
+ description: "Delegate self-contained subtasks to subagents that run their own agent loop with their own context. " +
883
1176
  "WHEN TO USE: broad codebase exploration (find where X lives, summarize module Y) so the main " +
884
- "conversation stays lean, or an independent subtask that needs many steps. " +
885
- "The prompt must be self-contained (the subagent sees nothing else); it returns a summary. " +
1177
+ "conversation stays lean, or independent subtasks that need many steps. " +
1178
+ "SINGLE: pass description + prompt. BATCH: pass tasks[] to run up to 8 subtasks concurrently " +
1179
+ "(bounded at 4 in flight) and get one merged result. " +
1180
+ "Every prompt must be self-contained (the subagent sees nothing else); each returns a summary. " +
886
1181
  "Prefer this over reading dozens of files yourself.",
887
1182
  parameters: obj({
888
- description: str("One-line summary of the subtask (shown in the UI)"),
889
- prompt: str("Self-contained instructions for the subagent, including any file paths and the expected output"),
1183
+ description: str("One-line summary of the subtask (single mode; shown in the UI)"),
1184
+ prompt: str("Self-contained instructions for the subagent (single mode)"),
890
1185
  agent: str("Optional agent name from the available agents (default: explore for research, build for changes)"),
891
- }, ["description", "prompt"]),
1186
+ tasks: {
1187
+ type: "array",
1188
+ description: "Batch mode: up to 8 subtasks run concurrently. When set, description/prompt are ignored.",
1189
+ items: obj({
1190
+ description: str("One-line summary of this subtask (shown in the UI)"),
1191
+ prompt: str("Self-contained instructions for this subagent"),
1192
+ agent: str("Optional agent name for this subtask"),
1193
+ }, ["description", "prompt"]),
1194
+ },
1195
+ }, []),
892
1196
  },
893
1197
  },
894
1198
  run: (ctx, args) => {
895
1199
  if (!ctx.taskRunner)
896
1200
  return { ok: false, output: "ERROR: task tool not wired (no runner in context)." };
897
- return ctx.taskRunner(ctx, args);
1201
+ const a = args;
1202
+ if (Array.isArray(a.tasks) && a.tasks.length > 0) {
1203
+ if (a.tasks.length > 8) {
1204
+ return { ok: false, output: `ERROR: too many tasks (${a.tasks.length}); split into batches of at most 8.` };
1205
+ }
1206
+ const invalid = a.tasks.findIndex((t) => !t || typeof t.prompt !== "string" || !t.prompt.trim());
1207
+ if (invalid >= 0)
1208
+ return { ok: false, output: `ERROR: tasks[${invalid}] is missing a non-empty prompt.` };
1209
+ return ctx.taskRunner(ctx, { tasks: a.tasks });
1210
+ }
1211
+ if (typeof a.prompt !== "string" || !a.prompt.trim()) {
1212
+ return { ok: false, output: "ERROR: task requires a non-empty `prompt` (single mode) or a non-empty `tasks` array (batch mode)." };
1213
+ }
1214
+ return ctx.taskRunner(ctx, a);
898
1215
  },
899
1216
  };
900
1217
  export function getTool(name) {