thincoder 0.12.60 → 0.12.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +27 -122
  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 +6 -1
  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 +21 -6
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +26 -9
  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/batch-segment.mjs +195 -0
  30. package/src/agent-tools/consult.mjs +23 -10
  31. package/src/agent-tools/design-token.mjs +14 -1
  32. package/src/agent-tools/digest-budget.mjs +76 -0
  33. package/src/agent-tools/eng.mjs +3 -3
  34. package/src/agent-tools/escalate-async.mjs +16 -13
  35. package/src/agent-tools/read-history.mjs +13 -3
  36. package/src/agent-tools/review-streak.mjs +93 -0
  37. package/src/agent-tools/settings.mjs +130 -17
  38. package/src/agent-tools/subagent-actions.mjs +15 -6
  39. package/src/agent-tools/subagent-async.mjs +66 -14
  40. package/src/agent-tools/subagent-panel.mjs +22 -15
  41. package/src/agent-tools/subagent-run.mjs +9 -6
  42. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  43. package/src/agent-tools/subagent-spawn.mjs +63 -16
  44. package/src/agent-tools/subagent.mjs +175 -49
  45. package/src/agent-tools/verify.mjs +13 -34
  46. package/src/agent-tools.mjs +1 -0
  47. package/src/agent.mjs +38 -21
  48. package/src/cli/distill-command.mjs +2 -2
  49. package/src/cli/make-agent.mjs +23 -7
  50. package/src/cli/memory-command.mjs +2 -2
  51. package/src/cli/setup-wizard.mjs +29 -9
  52. package/src/completions.mjs +114 -0
  53. package/src/config-migrate.mjs +70 -0
  54. package/src/config.mjs +132 -63
  55. package/src/conventions.mjs +223 -0
  56. package/src/crash-reports.mjs +7 -2
  57. package/src/expand-home.mjs +16 -0
  58. package/src/generate-title.mjs +1 -1
  59. package/src/hooks.mjs +7 -3
  60. package/src/memory/code-index.mjs +9 -3
  61. package/src/memory/code-sync.mjs +70 -31
  62. package/src/memory/delete.mjs +2 -0
  63. package/src/memory/docs.mjs +10 -6
  64. package/src/memory/file-walk.mjs +109 -0
  65. package/src/memory/schema.mjs +15 -3
  66. package/src/model-ref.mjs +66 -0
  67. package/src/model-specs.mjs +42 -8
  68. package/src/prompt-overlays.mjs +73 -16
  69. package/src/prompts/advisor-design.md +18 -8
  70. package/src/prompts/advisor-round1.md +7 -1
  71. package/src/prompts/advisor-round2.md +13 -2
  72. package/src/prompts/advisor-round3.md +13 -2
  73. package/src/prompts/common.md +115 -0
  74. package/src/prompts/consult-base.md +2 -0
  75. package/src/prompts/discipline-engineering.md +217 -0
  76. package/src/prompts/discipline-normal.md +179 -0
  77. package/src/prompts/persona-coder.md +21 -0
  78. package/src/prompts/persona-eng-coder.md +37 -0
  79. package/src/prompts/persona-eng-designer.md +55 -0
  80. package/src/prompts/persona-engineering.md +54 -0
  81. package/src/prompts/persona-explore.md +15 -0
  82. package/src/prompts/persona-normal.md +27 -0
  83. package/src/prompts/persona-plan.md +26 -0
  84. package/src/provider/anthropic.mjs +4 -4
  85. package/src/provider/core.mjs +13 -32
  86. package/src/provider/errors.mjs +26 -1
  87. package/src/provider/google.mjs +5 -6
  88. package/src/provider/index.mjs +2 -1
  89. package/src/provider/list-models.mjs +93 -0
  90. package/src/provider/rate.mjs +2 -1
  91. package/src/provider/responses.mjs +5 -3
  92. package/src/provider/sse.mjs +3 -4
  93. package/src/proxy.mjs +9 -14
  94. package/src/session-slots.mjs +7 -2
  95. package/src/session.mjs +59 -38
  96. package/src/token-ttl.mjs +2 -1
  97. package/src/tools/{system.mjs → bash.mjs} +6 -243
  98. package/src/tools/file.mjs +30 -10
  99. package/src/tools/git.md +1 -1
  100. package/src/tools/git.mjs +15 -34
  101. package/src/tools/index.mjs +4 -2
  102. package/src/tools/ops.mjs +20 -7
  103. package/src/tools/question.md +1 -0
  104. package/src/tools/question.mjs +26 -0
  105. package/src/tools/read.md +1 -1
  106. package/src/tools/read_image.md +1 -1
  107. package/src/tools/search.mjs +236 -0
  108. package/src/tui/agent-turn.mjs +32 -13
  109. package/src/tui/ansi.mjs +2 -0
  110. package/src/tui/clipboard.mjs +7 -1
  111. package/src/tui/cmd-advisor.mjs +3 -2
  112. package/src/tui/cmd-config.mjs +108 -37
  113. package/src/tui/cmd-eng.mjs +11 -27
  114. package/src/tui/cmd-exit.mjs +6 -8
  115. package/src/tui/cmd-model.mjs +14 -12
  116. package/src/tui/cmd-reindex.mjs +7 -0
  117. package/src/tui/cmd-submodel.mjs +8 -5
  118. package/src/tui/cmd-undo.mjs +4 -3
  119. package/src/tui/index.mjs +38 -37
  120. package/src/tui/key-handler.mjs +61 -17
  121. package/src/tui/key-modes.mjs +86 -8
  122. package/src/tui/layout.mjs +18 -10
  123. package/src/tui/model-catalog.mjs +89 -0
  124. package/src/tui/model-picker.mjs +498 -0
  125. package/src/tui/mouse.mjs +47 -10
  126. package/src/tui/pickers.mjs +28 -410
  127. package/src/tui/render-frame.mjs +32 -16
  128. package/src/tui/render-loop.mjs +2 -0
  129. package/src/tui/render-segments.mjs +5 -19
  130. package/src/tui/render.mjs +37 -5
  131. package/src/tui/slash-commands.mjs +2 -2
  132. package/src/tui/subagent-blocks.mjs +69 -90
  133. package/src/tui/subagent-children.mjs +50 -64
  134. package/src/tui/subagent-freeze.mjs +40 -43
  135. package/src/tui/subagent-panel.mjs +21 -66
  136. package/src/tui/suspension-drive.mjs +28 -82
  137. package/src/tui/tool-args.mjs +4 -2
  138. package/src/tui/tool-display.mjs +4 -3
  139. package/src/tui/tool-events.mjs +23 -16
  140. package/src/tui/tui-lifecycle.mjs +18 -5
  141. package/src/tui/wizard.mjs +52 -18
  142. package/src/tui/wrapped-spawn.mjs +38 -0
  143. package/src/prompts/coder.md +0 -13
  144. package/src/prompts/discipline.md +0 -84
  145. package/src/prompts/eng-coder.md +0 -19
  146. package/src/prompts/engineering-sub.md +0 -14
  147. package/src/prompts/engineering.md +0 -87
  148. package/src/prompts/explore.md +0 -12
  149. package/src/prompts/main.md +0 -34
  150. package/src/prompts/methodology-template.md +0 -38
  151. package/src/prompts/plan.md +0 -9
  152. package/src/prompts/system.md +0 -44
package/src/session.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { readFileSync, renameSync, existsSync, statSync } from "node:fs"
17
17
  import { basename } from "node:path"
18
+ import { resolveCompactThreshold } from "./config.mjs"
18
19
  import {
19
20
  slotPath, writeSessionFile, loadManifest, saveManifest, slotDigest,
20
21
  activeSlot, getSessionId, isProcessAlive,
@@ -123,8 +124,10 @@ export function saveSession(agent) {
123
124
  version: 2,
124
125
  cwd: agent.cwd,
125
126
  title: agent.title ?? "",
126
- activeProvider: agent.activeProvider ?? agent.provider?.name,
127
- activeModel: agent.activeModel ?? null,
127
+ // MODEL-MERGE-SESSION 槽双字段恒非空(裁定 a):有 provider 即携带具体复合值——
128
+ // 恢复不看 config(F-4)——无渠道默认可回落 ""/null(全新未配态——读侧容忍)
129
+ activeProvider: agent.activeProvider ?? agent.provider?.name ?? "",
130
+ activeModel: agent.activeModel ?? agent.provider?.model ?? null,
128
131
  updatedAt: Date.now(),
129
132
  history,
130
133
  contextHistory,
@@ -147,9 +150,6 @@ export function saveSession(agent) {
147
150
  const p = slotPath(agent.cwd, slot)
148
151
  // 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
149
152
  // 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
150
- // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-slots.mjs——DESIGN-TOKEN-
151
- // SETTLEMENT D1)——saveSession 与 token-ttl persistEngTokens(settle 当场落盘)
152
- // 共用同一份(检查按 mtime 缓存 _slotMtime:自写未变跳过全量解析)。
153
153
  // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-guard.mjs——DESIGN-TOKEN-
154
154
  // SETTLEMENT D1 二次拆分:先入 session-slots、再因 500 行硬限迁 session-guard)——
155
155
  // saveSession 与 token-ttl persistEngTokens(settle 当场落盘)共用同一份(检查按
@@ -255,22 +255,38 @@ function stripTruncatedToolArgs(m) {
255
255
  return changed ? { ...m, tool_calls } : m
256
256
  }
257
257
 
258
- /** Apply loaded session data onto an agent object; returns true if provider was switched.
259
- * 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领(F1b 回归:
260
- * switchToSlot 后保存必须落新槽而非旧槽)。 */
258
+ /** Apply loaded session data onto an agent object; returns true if the runtime composite
259
+ * was switched. 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领。
260
+ *
261
+ * MODEL-MERGE-SESSION(F-4)applySession 重写为两支 + 删旧支(评审 #7/#9 措辞——防
262
+ * "三支"误读):
263
+ * ① 槽 provider 存在 → provider/model 按槽值设(双字段恒非空——模型取 data.activeModel,
264
+ * legacy 槽 null/空串回该渠道默认模型 `providers[].model`(单值——2026-09-10 MODEL-SELECTION
265
+ * v2;老 models[] 首候选形态已退场))+ 重算
266
+ * compactThreshold(auto 时——按恢复后模型)——不看 config(defaultModel 只是新会话起点);
267
+ * ② 槽 provider 没了 → D-S3 保留——静默保持现状(config 有效则有效——两方都无效由调用侧
268
+ * 复验 validateProvider 弹重选)。
269
+ * 删旧支:"activeModel==null 清 stale override 回渠道默认"——前提 = 渠道默认字段——已随
270
+ * 三旧层删除消失——双字段恒非空故不可能触发(评审 #7)。 */
261
271
  export function applySession(agent, data) {
262
- // data.history is the FULL never-compacted record (human line); data.contextHistory is the
263
- // (possibly compacted) machine line. Restore each line from its own source — the machine
264
- // context keeps its compaction savings across resume. Legacy files without contextHistory
265
- // fall back to seeding the machine line from the full history (it re-compacts when needed).
266
- // 2026-08-31 会诊 deepseek 🟡:机读线必须从 contextHistory 恢复而非从完整 history 重建——
267
- // 后者会把已压缩的中间过程塞回上下文(实测 prompt 膨胀到 283%)。compactThresholdAuto 时
268
- // 按恢复后的模型重新推导阈值(bin/thincoder.mjs)。v1 老文件(无 contextHistory)回退
269
- // 播种时剥离被 slimForDisplay 截断的 tool_calls.arguments(以 … 结尾 → 置 {};会诊 F6——
270
- // 截断可劈断 \\uXXXX 产生 400 毒载荷)。2026-09-01 会诊三家:length > 0 才当机读线
271
- // (contextHistory: [] 是"无机读线"而非空机器线——空机器线会静默丢全部上下文)。
272
+ // 机读线语义(历史注释保留):data.history = FULL 未压缩记录(人读线);data.contextHistory =
273
+ // 压缩后机器线——恢复各从其源(保留压缩收益——从完整 history 重建会塞回中间过程——实测
274
+ // prompt 膨胀 283%)。v1 老文件(无 contextHistory)回退播种,剥离被 slimForDisplay 截断的
275
+ // tool_calls.arguments(… 结尾 {}——截断可劈断 \\uXXXX 产生 400 毒载荷)。length > 0
276
+ // 当机读线(contextHistory: [] 是"无机读线"而非空机器线——空机器线静默丢全部上下文)。
272
277
  agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
273
278
  const full = Array.isArray(data.history) ? data.history : []
279
+ // SESSION.md §11.2(2026-09-08——F2/F3):恢复事件按当前 data 重定——载入历史非空 = 会话
280
+ // 恢复 → 下个 depth-0 回合 env-state resumed:yes 一次(pushEnvStateReminder 读即清——每次恢复
281
+ // 一次)。/new 与空历史槽切换不武装(显式清 false)——无恢复事件恒 no(F3 伪触发消除——不再靠
282
+ // _sessionStart != null 推断;已武装 agent 切到空历史槽也不残留上次恢复事件——评审 round2 🟡)。
283
+ // _processRestartPending 不在此清——由启动路径(bin/thincoder.mjs)设、prepareRun 发句清
284
+ // (N6——双信号独立:切槽不报进程重启)。
285
+ // 跨端异名互指(结构债批 5 N7——双端同语义各自独立实现、命名不统一是刻意——SESSION.md
286
+ // §11.2):VSC 端同机制载体异名 = agent._resumedPending(src/agent/setup.mjs hydrateRun——
287
+ // restore:true 工厂 + fullHistory 非空时武装——每槽恢复一次)+ 模块级 restartDetectionDone
288
+ // 一次性闸(src/agent/setup-reminders.mjs——process restarted 句的进程级信号)。
289
+ agent._envResumed = full.length > 0
274
290
  const ch = data.contextHistory
275
291
  const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
276
292
  agent._fullHistory = [...full]
@@ -300,27 +316,29 @@ export function applySession(agent, data) {
300
316
  agent._verifyPassed = false
301
317
  agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
302
318
  agent._slotMtime = null
303
- if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
304
- const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
305
- if (p) {
306
- agent.provider = { ...p }
307
- agent.activeProvider = p.name
308
- agent.activeModel = data.activeModel ?? null
309
- if (agent.activeModel) agent.provider.model = agent.activeModel
310
- return true
311
- }
312
- } else if (data.activeModel != null) {
313
- // Same provider, different model
314
- agent.activeModel = data.activeModel
315
- if (agent.activeModel && agent.provider) agent.provider.model = agent.activeModel
316
- } else if (data.activeProvider && data.activeProvider === agent.activeProvider) {
317
- // Same provider, session has no activeModel → clear stale override
318
- agent.activeModel = null
319
- if (agent.provider) {
320
- const p = agent.providers?.find((pr) => pr.name === agent.activeProvider)
321
- if (p) agent.provider.model = p.model
319
+ // ── MODEL-MERGE-SESSION 恢复(F-4——两支)──
320
+ const slotProvider = data.activeProvider ? agent.providers?.find((pr) => pr.name === data.activeProvider) : null
321
+ if (slotProvider) {
322
+ // ① 槽 provider 存在 按槽值设(不看 config):双字段恒非空——legacy 槽 activeModel
323
+ // null/空串 = 无 override——回该渠道默认模型(`providers[].model` 单值——M3)。
324
+ // F-2d(MODEL-400-FIX):`||` `??`——空串也兜(activeModel="" 的槽会让 `??` 不落链 →
325
+ // 空槽 model 恒有值,provider.model 键不缺失)
326
+ const prevName = agent.activeProvider
327
+ const prevModel = agent.activeModel ?? null
328
+ const slotModel = data.activeModel || slotProvider.model
329
+ const switched = prevName !== slotProvider.name || prevModel !== slotModel
330
+ agent.activeProvider = slotProvider.name
331
+ agent.activeModel = slotModel
332
+ agent.provider = { ...slotProvider }
333
+ if (slotModel) agent.provider.model = slotModel
334
+ // 重算 compactThreshold(auto 时——阈值跟模型走;原在 bin 的 switched 分支——收拢本处)
335
+ if (switched && agent.config?.agent?.compactThresholdAuto && agent.provider.model) {
336
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
322
337
  }
338
+ return switched
323
339
  }
340
+ // ② 槽 provider 没了 → D-S3 保留——静默保持现状(不报错不纠正——config 有效则静默用
341
+ // config/defaultModel 运行时;两方都无效由调用侧复验 validateProvider 弹重选)
324
342
  return false
325
343
  }
326
344
 
@@ -413,7 +431,10 @@ export function resetSessionState(agent) {
413
431
  agent._slot = null
414
432
  agent._slotMtime = null
415
433
  agent._osReminderInjected = false
416
- agent._restartReminderInjected = false
434
+ // §11.2(2026-09-08):一次性注入标志换名——_restartReminderInjected 推断退役(F3——
435
+ // _sessionStart != null 伪触发);/new 清零新标记——恢复武装/启动句标志不跨会话复活
436
+ agent._envResumed = false
437
+ agent._processRestartPending = false
417
438
  agent._lastEngState = false
418
439
  }
419
440
 
package/src/token-ttl.mjs CHANGED
@@ -237,7 +237,8 @@ export function persistEngTokens(agent) {
237
237
  cwd: agent.cwd,
238
238
  title: agent.title ?? "",
239
239
  activeProvider: agent.activeProvider ?? agent.provider?.name,
240
- activeModel: agent.activeModel ?? null,
240
+ // MODEL-MERGE-SESSION 恒非空形态:随 saveSession 同款回落链(无渠道仍容忍 null
241
+ activeModel: agent.activeModel ?? agent.provider?.model ?? null,
241
242
  history: [],
242
243
  contextHistory: [],
243
244
  tasks: agent.tasks ?? [],
@@ -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.