thincoder 0.12.60 → 0.12.62

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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -4,25 +4,12 @@ import {
4
4
  truncate,
5
5
  makeDecoder,
6
6
  BASH_TIMEOUT_MS,
7
- IGNORED_DIRS,
8
- resolveInCwd,
9
- globToRegex,
10
- splitGlobPatterns,
11
- compileGlobMatchers,
12
- normalizeEOL,
13
7
  } from "./shared.mjs";
14
8
  import { spawn, execFileSync } from "node:child_process";
15
- import { readFile, readdir, stat, lstat } from "node:fs/promises";
16
- import { join } from "node:path";
17
9
 
18
10
  /** Maximum buffer size per stream (stdout / stderr) before truncation */
19
11
  const MAX_STREAM_BUF = 2_000_000
20
12
 
21
- /** Escape a string for literal regex matching (grep literal=true). */
22
- function escapeRegExp(s) {
23
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
24
- }
25
-
26
13
  /** Keep only output lines matching a regex (bash filter, case-insensitive). */
27
14
  function applyLineFilter(output, filter) {
28
15
  let re
@@ -85,13 +72,6 @@ function killProcessTree(child) {
85
72
  }
86
73
  }
87
74
 
88
- /**
89
- * Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
90
- * enforce timeout and signal abort, return formatted result.
91
- *
92
- * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
93
- */
94
-
95
75
  /**
96
76
  * Detect destructive git commands and auto-snapshot BEFORE execution.
97
77
  * The scenario: the model writes uncommitted code, then (after breaking things)
@@ -129,6 +109,12 @@ async function gitGuardSnapshot(command, cwd) {
129
109
  }
130
110
  }
131
111
 
112
+ /**
113
+ * Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
114
+ * enforce timeout and signal abort, return formatted result.
115
+ *
116
+ * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
117
+ */
132
118
  function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
133
119
  return new Promise((resolve) => {
134
120
  // Windows + default cmd: force UTF-8 code page for this child process (each spawn
@@ -280,226 +266,3 @@ export const bashTool = {
280
266
  return hint ? `${hint}\n${body}` : body
281
267
  },
282
268
  }
283
-
284
- // ---------------------------------------------------------------- glob
285
-
286
- export const globTool = {
287
- name: "glob",
288
- description: DESC("glob"),
289
- parameters: {
290
- type: "object",
291
- properties: {
292
- pattern: { type: "string", description: "Glob pattern — supports **, *, ?, [..], {a,b} braces, and space-separated exclusion (\"**/*.js !test/**\")" },
293
- path: { type: "string", description: "Directory to search in (default cwd)" },
294
- },
295
- required: ["pattern"],
296
- },
297
- readonly: true,
298
- async execute(args, ctx) {
299
- const base = resolveInCwd(ctx, args.path ?? ".")
300
- // §17 调用侧拆分(评审 #5 职责分层):空格分隔 include !exclude 多模式在这里拆;
301
- // globToRegex 只收单个模式(数组由 compileGlobMatchers 收)。
302
- let match
303
- try {
304
- const parts = splitGlobPatterns(args.pattern)
305
- if (parts.length === 0) return "Error: pattern is required"
306
- match = compileGlobMatchers(parts).test
307
- } catch (e) {
308
- return `glob error: invalid pattern "${args.pattern}": ${e.message}`
309
- }
310
- const results = []
311
- for await (const relPath of walkFiles(base)) {
312
- if (match(relPath)) {
313
- results.push(relPath)
314
- if (results.length >= 1000) break
315
- }
316
- }
317
- if (results.length === 0) return "(no matches)"
318
- return truncate(results.sort().join("\n"))
319
- },
320
- }
321
-
322
- /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
323
- async function* walkFiles(dir, rel = "") {
324
- let entries
325
- try {
326
- entries = await readdir(dir, { withFileTypes: true })
327
- } catch {
328
- return
329
- }
330
- for (const e of entries) {
331
- // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
332
- if (e.isSymbolicLink()) continue
333
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
334
- const relPath = rel ? `${rel}/${e.name}` : e.name
335
- if (e.isDirectory()) {
336
- yield* walkFiles(join(dir, e.name), relPath)
337
- } else {
338
- yield relPath
339
- }
340
- }
341
- }
342
-
343
- /** Glob to regex: **\/ matches zero or more directory levels, ** crosses directories, * within segment, ? single char within segment */
344
-
345
- // ---------------------------------------------------------------- grep
346
-
347
- export const grepTool = {
348
- name: "grep",
349
- description: DESC("grep"),
350
- parameters: {
351
- type: "object",
352
- properties: {
353
- pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
354
- path: { type: "string", description: "Directory or file to search (default cwd)" },
355
- glob: { type: "string", description: "Only search files matching this glob — supports **, *, ?, [..], {a,b} braces and space-separated exclusion (\"**/*.js !test/**\")" },
356
- ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
357
- literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
358
- before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
359
- after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
360
- },
361
- required: ["pattern"],
362
- },
363
- readonly: true,
364
- async execute(args, ctx) {
365
- const base = resolveInCwd(ctx, args.path ?? ".")
366
- let regex
367
- try {
368
- const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
369
- regex = new RegExp(pat, args.ignoreCase ? "i" : "")
370
- } catch (e) {
371
- throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
372
- }
373
- // §17 调用侧拆分:glob 参数支持空格分隔 include !exclude 多模式(include/exclude 求交)。
374
- let fileTest = null
375
- if (args.glob) {
376
- try {
377
- const parts = splitGlobPatterns(args.glob)
378
- if (parts.length > 0) fileTest = compileGlobMatchers(parts).test
379
- } catch (e) {
380
- throw new Error(`grep glob /${args.glob}/ is invalid: ${e.message}`, { cause: e })
381
- }
382
- }
383
- const before = Math.max(0, Math.floor(args.before ?? 0))
384
- const after = Math.max(0, Math.floor(args.after ?? 0))
385
- const wantCtx = before > 0 || after > 0
386
- const hits = [] // { file, line(1-based), text }
387
- const fileLines = new Map() // file -> string[] (cached only when context lines requested)
388
-
389
- async function search(file) {
390
- let content
391
- try {
392
- // Large file guard: skip files over 10MB to prevent OOM
393
- const fst = await stat(file)
394
- if (fst.size > 10_000_000) return
395
- content = normalizeEOL(await readFile(file, "utf8"))
396
- } catch {
397
- return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
398
- }
399
- const lines = content.split("\n")
400
- if (wantCtx) fileLines.set(file, lines)
401
- for (let i = 0; i < lines.length; i++) {
402
- if (regex.test(lines[i])) {
403
- hits.push({ file, line: i + 1, text: lines[i] })
404
- if (hits.length >= 200) return
405
- }
406
- }
407
- }
408
-
409
- async function walk(target, rel) {
410
- if (hits.length >= 200) return
411
- // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
412
- let s
413
- try { s = await lstat(target) } catch { return }
414
- if (!s.isDirectory()) {
415
- // rel 为相对搜索基的路径(排除前缀如 !test/** 必须按目录路径作用——不能用裸文件名);
416
- // 单文件目标(path 指向文件)时 rel 为空 → 退化为按文件名过滤(既有语义)。
417
- if (!fileTest || fileTest(rel || target.split(/[\\/]/).pop())) await search(target)
418
- return
419
- }
420
- let entries
421
- try {
422
- entries = await readdir(target, { withFileTypes: true })
423
- } catch {
424
- return
425
- }
426
- for (const e of entries) {
427
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
428
- await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
429
- }
430
- }
431
-
432
- await walk(base, "")
433
- if (hits.length === 0) return "(no matches)"
434
-
435
- // No context: keep original path:line: content format
436
- if (!wantCtx) {
437
- return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
438
- }
439
-
440
- // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
441
- const fileMatched = new Map() // file -> Set<line>
442
- for (const h of hits) {
443
- if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
444
- fileMatched.get(h.file).add(h.line)
445
- }
446
- const out = []
447
- for (const [file, matchedLines] of fileMatched) {
448
- const lines = fileLines.get(file) ?? []
449
- const lineSet = new Set()
450
- for (const ml of matchedLines) {
451
- for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
452
- }
453
- for (const l of [...lineSet].sort((a, b) => a - b)) {
454
- const sep = matchedLines.has(l) ? ":" : "-"
455
- out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
456
- }
457
- }
458
- return truncate(out.join("\n"))
459
- },
460
- }
461
-
462
- // ---------------------------------------------------------------- websearch
463
-
464
- export const lsTool = {
465
- name: "ls",
466
- description: DESC("ls"),
467
- parameters: {
468
- type: "object",
469
- properties: {
470
- path: { type: "string", description: "Directory path (default cwd)" },
471
- filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
472
- },
473
- },
474
- readonly: true,
475
- async execute(args, ctx) {
476
- const abs = resolveInCwd(ctx, args.path ?? ".")
477
- const filterRe = args.filter ? globToRegex(args.filter) : null
478
- let entries
479
- try {
480
- entries = await readdir(abs, { withFileTypes: true })
481
- } catch (e) {
482
- if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`, { cause: e })
483
- throw e
484
- }
485
- const rows = await Promise.all(
486
- entries
487
- .filter((e) => !filterRe || filterRe.test(e.name))
488
- .slice(0, 500)
489
- .map(async (e) => {
490
- const s = await stat(join(abs, e.name)).catch(() => null)
491
- const isDir = e.isDirectory()
492
- return {
493
- dir: isDir,
494
- name: e.name + (isDir ? "/" : ""),
495
- size: s?.size ?? 0,
496
- mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
497
- }
498
- }),
499
- )
500
- rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
501
- if (rows.length === 0) return "(no entries)"
502
- const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
503
- return truncate(out.join("\n"))
504
- },
505
- }
@@ -21,6 +21,10 @@ import { join, relative, dirname } from "node:path";
21
21
 
22
22
  const MAX_FILE_READ_BYTES = 10_000_000
23
23
  const MAX_IMAGE_BYTES = 15_000_000
24
+ // DUAL-END-TRUNCATION (F-1, C 方案——2026-09-09):read 双端尾行数——窗口截断大文件时
25
+ // 返回 头 N 行(N = 请求窗口,默认 MAX_READ_LINES)+ 省略注 + 尾 M 行(本常量)。M 与
26
+ // 窗口合计(2500 行)为"整读上限"——K=0(尾区与窗口相接)时整印剩余、不产生假省略注。
27
+ export const READ_TAIL_LINES = 500
24
28
 
25
29
  // ────────────────────────────────────────
26
30
  // Dirty-file tracking (read-before-insert guard)
@@ -95,17 +99,30 @@ export const readTool = {
95
99
  const lines = content.split("\n")
96
100
  const offset = Math.max(1, args.offset ?? 1)
97
101
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
98
- const slice = lines.slice(offset - 1, offset - 1 + limit)
99
- const numbered = slice.map((l, i) => {
100
- const ln = offset + i
101
- if (args.hashes) {
102
- const h = createHash("sha256").update(l).digest("hex").slice(0, 12)
103
- return `${ln}\t[${h}] ${l}`
104
- }
102
+ const windowEnd = offset - 1 + limit // index just past the requested window
103
+ const render = (slice, startLn) => slice.map((l, i) => {
104
+ const ln = startLn + i
105
+ if (args.hashes) return `${ln}\t[${hashLine(l)}] ${l}`
105
106
  return `${ln}\t${l}`
106
107
  }).join("\n")
107
- const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
108
- return truncate(numbered + suffix)
108
+ // DUAL-END-TRUNCATION (F-1, C 方案——DUAL-END-TRUNCATION.md 2026-09-09):
109
+ // 大文件(> MAX_READ_LINES 行)窗口截断时返回 头(请求窗口)+ 中段省略注 +
110
+ // 文件真实尾部(末 READ_TAIL_LINES 行——现尾注升级为真实尾行内容——offload 产物
111
+ // 的尾端结论可见)。≤ 阈值文件任何窗口走旧头向路径(字节零变化);窗口覆盖全文件
112
+ // 也走旧路径。与 hashes 模式照常交互(头尾行 hash 照算)。
113
+ if (windowEnd >= lines.length || lines.length <= MAX_READ_LINES) {
114
+ const suffix = windowEnd < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
115
+ return truncate(render(lines.slice(offset - 1, windowEnd), offset) + suffix)
116
+ }
117
+ const head = render(lines.slice(offset - 1, windowEnd), offset)
118
+ // 尾区 = 文件末 READ_TAIL_LINES 行;尾区起点落在窗口内(重叠)→ 尾区从窗口后开始
119
+ // —— 任何行不打印两次;剩余全被覆盖(K=0)→ 整印余段且无假省略注。
120
+ const tailStart = Math.max(windowEnd, lines.length - READ_TAIL_LINES)
121
+ const middle = tailStart - windowEnd
122
+ const tail = render(lines.slice(tailStart), tailStart + 1)
123
+ return middle > 0
124
+ ? `${head}\n…(truncated: ${middle} lines in middle, use offset to continue)\n${tail}`
125
+ : `${head}\n${tail}`
109
126
  },
110
127
  }
111
128
 
@@ -145,11 +162,14 @@ export const readImageTool = {
145
162
 
146
163
  // Vision capability gate: injecting an image into a text-only model's history poisons the whole
147
164
  // conversation (every subsequent request 400s on the image part). Refuse before reading the file.
165
+ // F-3(IMAGE-DOWNGRADE-VISION——2026-09-09):软引导句逐字锚(设计档照抄——CLI 无 extension
166
+ // 式自动降级——模型收到错误后自选 spawn 视觉渠道子代理路径)。
148
167
  const model = ctx.agent?.provider?.model
149
168
  if (model && !specForModel(model).multimodal) {
150
169
  throw new Error(
151
170
  `Model "${model}" does not support image input — read_image is unavailable with this provider. ` +
152
- `Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.`
171
+ `Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.\n` +
172
+ `模型不支持图像——可 spawn 一个视觉模型子代理(subagent model 参数指视觉渠道)用 read_image 读图`
153
173
  )
154
174
  }
155
175
  const mime = IMAGE_EXTENSIONS[ext]
package/src/tools/git.md CHANGED
@@ -7,7 +7,7 @@ Run a git command. Only works inside a git repository.
7
7
  - action='log': recent commits. count (default 10), oneline=true compact, path=<file> for one file's history.
8
8
  - action='show': a commit's details (--stat). ref=<ref> (default HEAD).
9
9
  - action='add': stage files — path=<file> (granular) or all changes when path omitted.
10
- - action='commit': stage + commit. message required; path=<file> for granular staging.
10
+ - action='commit': commit. message required; path=<file> → `git commit --only <paths>` — commits those files' working-tree content only (other staged batches are NOT mixed in — atomic); without path → add -A + full commit. New (untracked) files aren't committable by path alone — `git add` them first (staging extra is safe: --only still commits only the listed files).
11
11
  - action='rm': untrack a file/dir (git rm --cached, kept on disk). path required.
12
12
  - action='push'/'fetch'/'pull': sync with remote. remote=<origin>, ref=<branch or tag> (space-separated for multiple), tags=true for --tags.
13
13
  - action='tag': manage tags. tagAction=list (optional filter) / create (name, optional ref) / delete (name; snapshots first).
package/src/tools/git.mjs CHANGED
@@ -149,14 +149,22 @@ export const gitTool = {
149
149
  }
150
150
  case "commit": {
151
151
  if (!args.message) return "Error: commit requires message"
152
- // Granular staging when path given (only stage these); otherwise stage all (add -A).
153
- // 多路径:空格分隔(ref 先例——2026-09-05 发版痛点)
154
- const staged = args.path ? args.path.split(/\s+/).filter(Boolean) : null
155
- const add = runGitStrict(ctx.cwd, staged?.length ? ["add", "--", ...staged] : ["add", "-A"])
156
- if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
157
- const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
152
+ // F-3 (QUICKFIX-BATCH-2):path 给定(含空串——空/空白 trim 后空一律明确错误,绝不
153
+ // 回落 add -A)→ git commit --only <paths>——从工作树取列文件提交(忽略索引他批——
154
+ // 原子——不再先 add)。空格分隔唯一形态(含空格文件名不支持)。无 path(缺省)→ add
155
+ // -A 全量(单代理语义保留)。
158
156
  const parts = []
159
- if (add.out) parts.push(add.out)
157
+ let commit
158
+ if (args.path !== undefined && args.path !== null) {
159
+ const trimmed = args.path.trim()
160
+ if (!trimmed) return "Error: commit path is empty/whitespace — give at least one file path (space-separated)"
161
+ commit = runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
162
+ } else {
163
+ const add = runGitStrict(ctx.cwd, ["add", "-A"])
164
+ if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
165
+ if (add.out) parts.push(add.out)
166
+ commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
167
+ }
160
168
  if (commit.ok) {
161
169
  if (commit.out) parts.push(commit.out)
162
170
  // F6: commit = new safety baseline — clear this project's checkpoints
@@ -346,30 +354,3 @@ export const gitTool = {
346
354
  },
347
355
  }
348
356
 
349
- // ---------------------------------------------------------------- question
350
-
351
- export const questionTool = {
352
- name: "question",
353
- description: DESC("question"),
354
- parameters: {
355
- type: "object",
356
- properties: {
357
- question: { type: "string", description: "The question to ask the user" },
358
- options: {
359
- type: "array",
360
- items: { type: "string" },
361
- description: "Single-choice options for the user to pick from (optional)",
362
- },
363
- },
364
- required: ["question"],
365
- },
366
- readonly: true,
367
- async execute(args, ctx) {
368
- if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
369
- // §22 D-Q3 机械限制(2026-09-06 用户裁定:100 字符 / 4 条——超限拒绝回模型,不弹卡、不调 onQuestion)
370
- if (args.question.length > 100) return "(error: question too long (>100 chars) — ask ONE short question; background belongs in your normal reply text)"
371
- if (Array.isArray(args.options) && args.options.length > 4) return "(error: too many options (>4) — offer at most 4 plain-string options)"
372
- return ctx.onQuestion(args.question, args.options ?? [])
373
- },
374
- }
375
-
@@ -3,9 +3,11 @@ export { toOpenAISchema } from "./shared.mjs";
3
3
 
4
4
  import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
5
5
  import { applyPatchTool, deleteTool } from "./patch.mjs";
6
- import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
6
+ import { bashTool } from "./bash.mjs";
7
+ import { globTool, grepTool, lsTool } from "./search.mjs";
7
8
  import { websearchTool, fetchTool } from "./web.mjs";
8
- import { gitTool, questionTool } from "./git.mjs";
9
+ import { gitTool } from "./git.mjs";
10
+ import { questionTool } from "./question.mjs";
9
11
  import { checklistTool } from "./checklist.mjs";
10
12
  import { lintTool } from "./linter.mjs";
11
13
  import { lspTool } from "./lsp.mjs";
package/src/tools/ops.mjs CHANGED
@@ -176,14 +176,27 @@ function asyncEntryDone(agent, id) {
176
176
  return entry.done === true || entry.status === "done"
177
177
  }
178
178
 
179
- /** Any async entry matching pred that is still running/queued (not done). */
180
- function hasRunningAsync(agent, pred) {
181
- for (const e of asyncPool(agent).values()) {
179
+ /** 评审池双载体(第 10 批 §18.3 #2):agent ∪ history——未初始化的载体不进集合。 */
180
+ function advisorPools(agent) {
181
+ return [agent?._asyncAdvisors, agent?.history?._asyncAdvisors].filter((m) => m instanceof Map)
182
+ }
183
+
184
+ /** Any entry matching pred that is still running/queued (not done) — in the given pool. */
185
+ function hasRunningIn(pool, pred) {
186
+ for (const e of pool?.values() ?? []) {
182
187
  if (pred(e) && !(e.done === true || e.status === "done")) return true
183
188
  }
184
189
  return false
185
190
  }
186
191
 
192
+ /** "advisor settled" 真判据(第 10 批 §18.3 #2 修正):**评审池真实态**——双载体
193
+ * (agent._asyncAdvisors ∪ history._asyncAdvisors)无 running/queued 条目(与 §11.2 的
194
+ * 未决评审判定 advisorReviewPending 同源语义)。修前读**子代理池**的 role==="advisor"
195
+ * 条目——评审条目只在 _asyncAdvisors → 恒无命中 → 恒真 0ms 秒过(用户实证)。 */
196
+ function advisorSettled(agent) {
197
+ return !advisorPools(agent).some((pool) => hasRunningIn(pool, (e) => e.status === "running" || e.status === "queued"))
198
+ }
199
+
187
200
  /** "consult done" — every consult session has drained (pending 0) or was
188
201
  * explicitly stopped (its children were aborted). No sessions → true (vacuously). */
189
202
  function consultAllDone(agent) {
@@ -221,10 +234,10 @@ export async function evaluateWaitForCondition(condition, ctx) {
221
234
  const agent = ctx?.agent
222
235
  switch (parsed.kind) {
223
236
  case "advisor":
224
- // Advisor reviews run as blocking tool calls (runAdvisorReview) or as
225
- // advisor-role async children — "settled" = no advisor-role entry still
226
- // running/queued in the async pool.
227
- return !hasRunningAsync(agent, (e) => e.role === "advisor")
237
+ // 第 10 批修正(§18.3 #2——修前恒真 0ms 秒过):判据 = 评审池真实态(双载体)——
238
+ // 不再读子代理池的 role==="advisor" 条目(该池永无此类条目);语义与 §11.2 未决
239
+ // 评审判定同源(advisorReviewPending)。
240
+ return advisorSettled(agent)
228
241
  case "subagent":
229
242
  return asyncEntryDone(agent, parsed.arg)
230
243
  case "consult":
@@ -8,6 +8,7 @@ Notes:
8
8
  - The agent loop pauses until the user answers
9
9
  - The answer is injected as the next user message
10
10
  - Returns the user's answer — the chosen option or free text — as the next message; the loop resumes when it arrives.
11
+ - Availability: this tool needs an interactive UI — in contexts without one (headless runs, subagent children) it returns an error instead of asking; put the question in your reply text instead.
11
12
  - Use sparingly — prefer making reasonable decisions when possible
12
13
  - Ask ONE question per call — never bundle multiple sub-questions into one question string; ask the next one after the answer arrives.
13
14
  - Keep the question text short — one or two sentences. Background, context, and analysis belong in your normal reply text, NOT in the question.
@@ -0,0 +1,26 @@
1
+ import { DESC } from "./shared.mjs";
2
+
3
+ export const questionTool = {
4
+ name: "question",
5
+ description: DESC("question"),
6
+ parameters: {
7
+ type: "object",
8
+ properties: {
9
+ question: { type: "string", description: "The question to ask the user" },
10
+ options: {
11
+ type: "array",
12
+ items: { type: "string" },
13
+ description: "Single-choice options for the user to pick from (optional)",
14
+ },
15
+ },
16
+ required: ["question"],
17
+ },
18
+ readonly: true,
19
+ async execute(args, ctx) {
20
+ if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
21
+ // §22 D-Q3 机械限制(2026-09-06 用户裁定:100 字符 / 4 条——超限拒绝回模型,不弹卡、不调 onQuestion)
22
+ if (args.question.length > 100) return "(error: question too long (>100 chars) — ask ONE short question; background belongs in your normal reply text)"
23
+ if (Array.isArray(args.options) && args.options.length > 4) return "(error: too many options (>4) — offer at most 4 plain-string options)"
24
+ return ctx.onQuestion(args.question, args.options ?? [])
25
+ },
26
+ }
package/src/tools/read.md CHANGED
@@ -11,7 +11,7 @@ Read a text file. Returns numbered lines. Use offset/limit to page large files.
11
11
  Parameters:
12
12
  - path (required): File path, relative to cwd or absolute (alias: filePath)
13
13
  - offset: 1-based line number to start reading from
14
- - limit: Max lines to return (default 2000)
14
+ - limit: Max lines to return (default 2000) — windows over files beyond that return head + `…(truncated: K lines in middle, use offset to continue)` + the file's real tail lines, so the file end is never hidden
15
15
  - hashes: Include SHA256 content hashes per line (for hashline_edit). Set true before using hashline_edit.
16
16
 
17
17
  Notes:
@@ -5,4 +5,4 @@ Parameters:
5
5
 
6
6
  Notes:
7
7
  - Raster formats only (png/jpg/gif/webp): no mainstream vision API (Kimi, Anthropic, OpenAI, Gemini) accepts svg or bmp, and an unsupported image part in history makes every subsequent request fail with 400. svg files are returned as text source instead (readable by any model); bmp is rejected — convert to PNG first.
8
- - This tool only works with models that support vision/image input (Kimi K3, Qwen3.8, MiniMax M3, GLM-5.3-Flash). Pure text models (DeepSeek V4, GLM-5) will receive an error — except svg, which needs no vision support since it is read as text.
8
+ - This tool only works with models that support vision/image input (capability is spec-driven — the model spec decides, not a hardcoded list). Models without vision support will receive an error — except svg, which needs no vision support since it is read as text.