thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
package/src/tools/ops.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
3
- * get_current_time. Each exists so the model reaches for a dedicated tool
4
- * instead of shelling out to `bash` for the same operation (parity with thinworker).
3
+ * get_current_time, wait_for (condition wait). Each exists so the model reaches
4
+ * for a dedicated tool instead of shelling out to `bash` for the same operation
5
+ * (parity with thinworker).
5
6
  */
6
7
  import { DESC, resolveInCwd, truncate } from "./shared.mjs"
7
8
  import { cp, rename, rm } from "node:fs/promises"
9
+ import { existsSync } from "node:fs"
8
10
  import { execFileSync } from "node:child_process"
11
+ import net from "node:net"
9
12
 
10
13
  // ─── file_ops ──────────────────────────────────────────────────
11
14
 
@@ -111,4 +114,173 @@ export const getCurrentTimeTool = {
111
114
  const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
112
115
  return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
113
116
  },
114
- }
117
+ }
118
+
119
+ // ─── wait_for ───────────────────────────────────────────────────
120
+
121
+ /** wait_for bounds (TOOLS.md §16): a BUILT-IN ceiling replaces the unbounded
122
+ * sleep-then-wait — timeout_ms defaults to 30s (config.json agent.waitForTimeoutMs
123
+ * overrides), capped at WAIT_FOR_MAX_TIMEOUT_MS (600s — the execute-tool timeout
124
+ * convention, Math.min(t, 600_000)); interval_ms defaults to 1s with a 100ms
125
+ * floor so polling never busy-spins. */
126
+ export const WAIT_FOR_DEFAULT_TIMEOUT_MS = 30_000
127
+ export const WAIT_FOR_MAX_TIMEOUT_MS = 600_000
128
+ export const WAIT_FOR_DEFAULT_INTERVAL_MS = 1_000
129
+ export const WAIT_FOR_MIN_INTERVAL_MS = 100
130
+
131
+ // Condition-source seam (§16 评审 #4): production uses the real evaluator
132
+ // (evaluateWaitForCondition); tests inject a deterministic fake source via
133
+ // setWaitForConditionSource (default null — production path unchanged).
134
+ let injectedConditionSource = null
135
+ export function setWaitForConditionSource(source) {
136
+ injectedConditionSource = typeof source === "function" ? source : null
137
+ }
138
+
139
+ /** Parse a wait_for condition into { kind, arg }. Unknown conditions are an
140
+ * EXPLICIT error enumerating the supported forms (评审 #2 — never a silent
141
+ * wait on a misspelled condition). */
142
+ export function parseWaitForCondition(condition) {
143
+ const c = String(condition ?? "").trim()
144
+ let m
145
+ if (c === "advisor settled") return { kind: "advisor", arg: null }
146
+ if (c === "consult done") return { kind: "consult", arg: null }
147
+ if ((m = c.match(/^subagent id:\s*(\d+) done$/))) return { kind: "subagent", arg: String(Number(m[1])) }
148
+ if ((m = c.match(/^file exists:\s*(.+)$/))) return { kind: "file", arg: m[1].trim() }
149
+ if ((m = c.match(/^port open:\s*(\d+)$/))) return { kind: "port", arg: Number(m[1]) }
150
+ throw new Error(
151
+ `wait_for: unsupported condition "${c}" — supported: "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"`,
152
+ )
153
+ }
154
+
155
+ /** Async-subagent pool of an agent — ctx.agent 可达性(评审 #1):dispatch 把
156
+ * agent 实例放进工具 ctx(agent/dispatch.mjs toolCtx.agent),池挂在 agent 上
157
+ * (深度 0 的 VS Code 侧 agent._asyncSubagents 与 history._asyncSubagents 同
158
+ * 一 Map——见 agent.mjs;CLI 侧直接是 agent._asyncSubagents)。 */
159
+ function asyncPool(agent) {
160
+ if (!agent) return new Map()
161
+ if (agent._asyncSubagents instanceof Map) return agent._asyncSubagents
162
+ return agent.history?._asyncSubagents instanceof Map ? agent.history._asyncSubagents : new Map()
163
+ }
164
+
165
+ /** "subagent id:N done" — the async entry for N settled (status done / done flag)
166
+ * or already left the pool (moved to pending results / digested / never spawned —
167
+ * nothing is left to wait on). Absent ids are done by vacuity: the model waits on
168
+ * ids its own spawn ack just returned, so an id no longer in the pool means done.
169
+ * Dual key lookup (2026-09-06 advisor #1): VS Code pool keys are the numeric
170
+ * spawn-time id, the CLI keys them as String(id) — accept both. */
171
+ function asyncEntryDone(agent, id) {
172
+ const pool = asyncPool(agent)
173
+ const key = String(id)
174
+ const entry = pool.get(key) ?? pool.get(Number(key)) ?? null
175
+ if (!entry) return true
176
+ return entry.done === true || entry.status === "done"
177
+ }
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()) {
182
+ if (pred(e) && !(e.done === true || e.status === "done")) return true
183
+ }
184
+ return false
185
+ }
186
+
187
+ /** "consult done" — every consult session has drained (pending 0) or was
188
+ * explicitly stopped (its children were aborted). No sessions → true (vacuously). */
189
+ function consultAllDone(agent) {
190
+ const sessions = agent?._consultSessions
191
+ if (!sessions || sessions.size === 0) return true
192
+ for (const s of sessions.values()) {
193
+ if (!s.stopped && (s.pending ?? 0) > 0) return false
194
+ }
195
+ return true
196
+ }
197
+
198
+ /** "port open:N" — 127.0.0.1 probe with a short connect timeout (never hangs a poll). */
199
+ function portOpenProbe(port) {
200
+ return new Promise((resolve) => {
201
+ const socket = net.connect({ host: "127.0.0.1", port, timeout: 400 })
202
+ let settled = false
203
+ const finish = (v) => {
204
+ if (settled) return
205
+ settled = true
206
+ socket.destroy()
207
+ resolve(v)
208
+ }
209
+ socket.once("connect", () => finish(true))
210
+ socket.once("error", () => finish(false))
211
+ socket.once("timeout", () => finish(false))
212
+ })
213
+ }
214
+
215
+ /** Real condition evaluator (production path) — returns a boolean; throws on an
216
+ * unsupported condition string. Polling is async (await setTimeout) — the event
217
+ * loop keeps running so background async work (subagents/consult) can settle
218
+ * between polls (评审 #1 — no blocking spin). */
219
+ export async function evaluateWaitForCondition(condition, ctx) {
220
+ const parsed = parseWaitForCondition(condition)
221
+ const agent = ctx?.agent
222
+ switch (parsed.kind) {
223
+ 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")
228
+ case "subagent":
229
+ return asyncEntryDone(agent, parsed.arg)
230
+ case "consult":
231
+ return consultAllDone(agent)
232
+ case "file":
233
+ return existsSync(resolveInCwd(ctx, parsed.arg))
234
+ case "port":
235
+ return await portOpenProbe(parsed.arg)
236
+ default:
237
+ return false
238
+ }
239
+ }
240
+
241
+ async function evalConditionSource(condition, ctx) {
242
+ if (injectedConditionSource) return injectedConditionSource(condition, ctx)
243
+ return evaluateWaitForCondition(condition, ctx)
244
+ }
245
+
246
+ export const waitForTool = {
247
+ name: "wait_for",
248
+ description: DESC("wait_for"),
249
+ parameters: {
250
+ type: "object",
251
+ properties: {
252
+ condition: { type: "string", description: 'Condition expression — "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"' },
253
+ interval_ms: { type: "integer", description: "Poll interval in ms (default 1000, floor 100)" },
254
+ timeout_ms: { type: "integer", description: "Overall timeout in ms (default 30000, cap 600000; config.json agent.waitForTimeoutMs overrides the default)" },
255
+ },
256
+ required: ["condition"],
257
+ },
258
+ readonly: true,
259
+ async execute(args, ctx = {}) {
260
+ const condition = typeof args?.condition === "string" ? args.condition.trim() : ""
261
+ if (!condition) return "Error: condition is required"
262
+ const intervalMs = Math.max(WAIT_FOR_MIN_INTERVAL_MS, Math.floor(args?.interval_ms ?? WAIT_FOR_DEFAULT_INTERVAL_MS))
263
+ const cfgTimeout = ctx?.agent?.config?.agent?.waitForTimeoutMs
264
+ const base = Number.isFinite(args?.timeout_ms)
265
+ ? args.timeout_ms
266
+ : Number.isFinite(cfgTimeout) && cfgTimeout > 0 ? cfgTimeout : WAIT_FOR_DEFAULT_TIMEOUT_MS
267
+ const timeoutMs = Math.min(Math.max(1, Math.floor(base)), WAIT_FOR_MAX_TIMEOUT_MS)
268
+ const started = Date.now()
269
+ let polls = 0
270
+ for (;;) {
271
+ // Cancel/interrupt-safe exit(T-W7):Ctrl+C / 定向 abort 传播——不吞信号、
272
+ // 不把用户停变成工具错误(dispatch 对 aborted signal 原样再抛)。
273
+ if (ctx.signal?.aborted) throw new Error("wait_for: interrupted by abort signal")
274
+ polls++
275
+ const ok = await evalConditionSource(condition, ctx)
276
+ if (ok === true) {
277
+ return `wait_for: condition satisfied after ${Date.now() - started}ms (${polls} check${polls === 1 ? "" : "s"}): "${condition}"`
278
+ }
279
+ const remaining = started + timeoutMs - Date.now()
280
+ if (remaining <= 0) {
281
+ return `wait_for: timed out after ${timeoutMs}ms waiting for "${condition}" (${polls} checks — condition never became true)`
282
+ }
283
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)))
284
+ }
285
+ },
286
+ }
@@ -15,8 +15,23 @@ import { relative, dirname } from "node:path";
15
15
  /**
16
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
17
  * Consume hunk lines by the line counts in the @@ header — LLMs often strip context blank lines to pure empty lines,
18
- * so we use counts rather than first characters to determine hunk boundaries
18
+ * so we use counts rather than first characters to determine hunk boundaries.
19
+ * APPLY-PATCH.md §3: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
20
+ * ("@@" / "--- " / "+++ " / "diff " / "index "), purely located by its ops.
19
21
  */
22
+ /**
23
+ * P15.10(2026-09-05 用户裁定——「符合模型直觉」):文件头判定——
24
+ * 完整头(`--- x` 后随 `+++ `)任意老路径形态均认(git 规范);
25
+ * 容缺头(`+++ b/<path>` 配对行省略——模型单文件补丁自然形态)仅认 a//b/ 前缀——
26
+ * newPath 推导 = oldPath;`/dev/null` 容缺仍拒(新文件名从 --- 侧不可推导——parsePatch 内特报);
27
+ * 其他 `--- x` = 普通删行内容(行首标记 - + 内容 `-- x`)——不是文件头——hunk 体不得误断。
28
+ */
29
+ function isFileHeader(line, nextLine) {
30
+ if (!line.startsWith("--- ")) return false
31
+ if (nextLine?.startsWith("+++ ")) return true
32
+ return /^[ab]\//.test(line.slice(4).trim())
33
+ }
34
+
20
35
  function parsePatch(patch) {
21
36
  // Patch text often comes from CRLF terminals/model output; trailing \r mixed into hunk content breaks context matching, strip uniformly
22
37
  const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
@@ -29,18 +44,69 @@ function parsePatch(patch) {
29
44
  if (line.startsWith("--- ")) {
30
45
  const oldPath = line.slice(4).trim()
31
46
  const plus = lines[i + 1]
32
- if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
33
- const newPath = plus.slice(4).trim()
34
- if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
35
- cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
36
- files.push(cur)
37
- i += 2
38
- continue
47
+ if (plus?.startsWith("+++ ")) {
48
+ const newPath = plus.slice(4).trim()
49
+ if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
50
+ cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
51
+ files.push(cur)
52
+ i += 2
53
+ continue
54
+ }
55
+ // P15.10:容缺头——`--- a/<path>`(或 b/ 前缀)后直接跟 hunk = 对同路径的修改。
56
+ if (/^[ab]\//.test(oldPath)) {
57
+ cur = { path: stripPrefix(oldPath), isNew: false, hunks: [] }
58
+ files.push(cur)
59
+ i += 1
60
+ continue
61
+ }
62
+ if (oldPath === "/dev/null") {
63
+ throw new Error(`"--- /dev/null" needs a "+++ b/<path>" line naming the new file — the --- side does not carry the file name`)
64
+ }
65
+ throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
39
66
  }
40
67
  if (line.startsWith("@@")) {
41
68
  if (!cur) throw new Error("Malformed patch: hunk header before any file header")
42
69
  const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
43
- if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
70
+ if (!m) {
71
+ // APPLY-PATCH.md §3:坐标裸 "@@" 头——hunk 完全靠操作行定位。
72
+ // 无行数可用:操作行以 " " / "-" / "+" 开头(空行宽容为上下文行),
73
+ // 直到下一个 hunk 头 / 文件头 "@@"/"--- "/"+++ "/"diff "/"index " 为止。
74
+ if (!/^@@(?: @@)?\s*$/.test(line)) {
75
+ throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@ or bare @@)`)
76
+ }
77
+ const hunk = { ops: [] }
78
+ i++
79
+ while (i < lines.length) {
80
+ const hl = lines[i]
81
+ if (hl.startsWith("@") || isFileHeader(hl, lines[i + 1]) || hl.startsWith("+++ ") || hl.startsWith("diff ") || hl.startsWith("index ")) break
82
+ if (hl.startsWith("\\")) { i++; continue } // ""
83
+ // 宽容空行=上下文行——但 patch 文本末尾(或 hunk 之间/文件头之前)的 "" 是
84
+ // 分隔产物而非内容:仅当后继仍是操作行时才当作上下文消费。
85
+ // 复评 #1(2026-09-04):文件头 "--- "/"+++ " 以 -/+ 开头会被 op 前缀判定误收——
86
+ // 文件头前的分隔空行不得吞成幽灵上下文行(会把 - 锚序列尾部拼上 ""——跨文件
87
+ // 零上下文 hunk 因此误报 not-found)。
88
+ const next = lines[i + 1]
89
+ if (hl === "" && (next == null || !/^[ +\-\\]/.test(next) || isFileHeader(next, lines[i + 2]) || next.startsWith("+++ "))) break
90
+ const tag = hl === "" ? " " : hl[0]
91
+ if (tag !== " " && tag !== "-" && tag !== "+") break // metadata / file section end
92
+ hunk.ops.push({ type: tag, text: hl === "" ? "" : hl.slice(1) })
93
+ i++
94
+ }
95
+ if (hunk.ops.length === 0) throw new Error(`Malformed patch: empty coordinate-less hunk "${line.trim()}"`)
96
+ const ctxCount = hunk.ops.filter((o) => o.type === " ").length
97
+ // §3 (APPLY-PATCH.md——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
98
+ // 匹配行序列(空格上下文行 + - 行——按出现序)连续——唯一匹配即应用(applyHunks 既有锚匹配域——
99
+ // 多匹配 / not-found 语义不变)。0 上下文与 1 上下文同待遇(评审 #4a)。
100
+ // 纯 +(无 - 锚)且 context<2 仍拒——插入位置不可判——报错引导加锚(NF15.8c)。
101
+ const removedCount = hunk.ops.filter((o) => o.type === "-").length
102
+ if (ctxCount < 2 && removedCount === 0) {
103
+ throw new Error(
104
+ `Coordinate-less hunk ${cur.hunks.length + 1} in ${cur.path} has ${ctxCount} context line(s) — add more context lines`,
105
+ )
106
+ }
107
+ cur.hunks.push(hunk)
108
+ continue
109
+ }
44
110
  let oldNeed = m[1] == null ? 1 : Number(m[1])
45
111
  let newNeed = m[2] == null ? 1 : Number(m[2])
46
112
  const hunk = { ops: [] }
@@ -62,8 +128,10 @@ function parsePatch(patch) {
62
128
  }
63
129
  i++ // skip diff --git / index / blank lines and other metadata
64
130
  }
65
- if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
66
- return files
131
+ // P15.10:容缺/完整空段头(头后无任何 hunk)过滤——不虚报 touchedPaths、不触发无谓 read+write
132
+ const withHunks = files.filter((f) => f.hunks.length > 0)
133
+ if (withHunks.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
134
+ return withHunks
67
135
  }
68
136
 
69
137
  /** Apply hunks sequentially onto an in-memory line array; any failure throws (caller guarantees nothing is written to disk). Ignores trailing \r when comparing; context lines retain original bytes */
@@ -84,7 +152,10 @@ function applyHunks(fileLines, hunks, eol, path) {
84
152
  const preview = oldSeq.slice(0, 3).join(" ⏎ ")
85
153
  throw new Error(`Hunk ${h + 1} in ${path} does not apply — context not found: "${preview}${oldSeq.length > 3 ? "…" : ""}". Read the file first and regenerate the patch from actual content.`)
86
154
  }
87
- if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
155
+ if (matches.length > 1) {
156
+ const preview = oldSeq.slice(0, 3).join(" ⏎ ")
157
+ throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique. Anchor: "${preview}${oldSeq.length > 3 ? "…" : ""}"`)
158
+ }
88
159
  const pos = matches[0]
89
160
  const out = []
90
161
  let src = pos
@@ -103,7 +174,7 @@ export const applyPatchTool = {
103
174
  parameters: {
104
175
  type: "object",
105
176
  properties: {
106
- patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks." },
177
+ patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks (a bare @@ header is also accepted — coordinate-less hunks are located by their anchor lines: context lines plus the removed (-) lines, matched as a contiguous sequence — a unique match applies; a zero/one-context hunk is accepted only when it removes (-) at least one line and that anchor sequence is unique, while anchor-free pure-+ (insert) hunks need at least 2 context lines). The +++ b/<path> pair may be omitted for existing files — a lone --- a/<path> (or --- b/<path>) header followed directly by hunks applies to that path (new files still need --- /dev/null + +++ b/<path>)." },
107
178
  },
108
179
  required: ["patch"],
109
180
  },
@@ -7,5 +7,9 @@ Parameters:
7
7
  Notes:
8
8
  - The agent loop pauses until the user answers
9
9
  - The answer is injected as the next user message
10
+ - Returns the user's answer — the chosen option or free text — as the next message; the loop resumes when it arrives.
10
11
  - Use sparingly — prefer making reasonable decisions when possible
11
- - After receiving an answer about a design convention, tool preference, or recurring pattern: save it with memory_put. This prevents asking the same question in future sessions — the user shouldn't have to repeat their preferences.
12
+ - Ask ONE question per call never bundle multiple sub-questions into one question string; ask the next one after the answer arrives.
13
+ - Keep the question text short — one or two sentences. Background, context, and analysis belong in your normal reply text, NOT in the question.
14
+ - Routine confirmations (confirm gates) belong in your plain reply text — the user answers in their next message. Use this tool ONLY when you need the user's decision or input to proceed.
15
+ - After receiving an answer about a design convention, tool preference, or recurring pattern: save it with the memory tool (action: put). This prevents asking the same question in future sessions — the user shouldn't have to repeat their preferences.
@@ -297,7 +297,7 @@ export function repoOutlineTool(db, cwd) {
297
297
  return {
298
298
  name: "repo_outline",
299
299
  description:
300
- "Show the project's file dependency outline: which files import/export from which, and what symbols they export. Use when you need to understand the project structure, find where a function is defined, or see what files depend on a module. Pass a path to focus on a single file's relationships.",
300
+ "Show the project's file dependency outline: which files import/export from which, and what symbols they export. Use when you need to understand the project structure, find where a function is defined, or see what files depend on a module. Pass a path to focus on a single file's relationships. Find code by keyword or snippet with code_search.",
301
301
  parameters: {
302
302
  type: "object",
303
303
  properties: {
@@ -296,8 +296,8 @@ export function shellSegments(command) {
296
296
 
297
297
  /**
298
298
  * Blank out quoted regions (single/double/backtick) with spaces, preserving length.
299
- * Lets safety checks ignore shell metacharacters inside quoted script bodies —
300
- * e.g. `node -e "if (a > b) …"` comparisons are not redirections.
299
+ * Lets detectDanger ignore shell metacharacters inside quoted script bodies —
300
+ * e.g. `node -e "if (a > b) …"` comparisons are not danger signals.
301
301
  */
302
302
  function blankQuoted(command) {
303
303
  let out = ""
@@ -308,8 +308,8 @@ function blankQuoted(command) {
308
308
  if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
309
309
  if (ch === quote) { quote = null; out += " "; continue }
310
310
  // Backticks are COMMAND SUBSTITUTION — the content executes, so it must
311
- // stay visible to the redirection check (echo `cat > /tmp/x` writes a
312
- // file). Only ' and " are literal regions.
311
+ // stay visible to detectDanger (echo `cat > /tmp/x` still runs). Only '
312
+ // and " are literal regions.
313
313
  out += quote === "`" ? ch : " "
314
314
  } else if (ch === "'" || ch === '"' || ch === "`") {
315
315
  quote = ch
@@ -321,14 +321,6 @@ function blankQuoted(command) {
321
321
  return out
322
322
  }
323
323
 
324
- /** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions.
325
- * Backtick contents count (command substitution executes); fd-prefixed forms
326
- * (2> file, 1>> file) count too. */
327
- export function hasFileRedirection(command) {
328
- const bare = blankQuoted(command)
329
- return /(^|[\s;&|0-9])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|0-9])<\s*\S/.test(bare)
330
- }
331
-
332
324
  /**
333
325
  * Whether a single command segment is destructive — ALWAYS FALSE (deliberate).
334
326
  *
@@ -371,19 +363,20 @@ export function detectDanger(command) {
371
363
  }
372
364
 
373
365
 
374
- /** Convert glob pattern to regex */
375
- export function globToRegex(pattern) {
376
- // Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
377
- // come from model output or the filesystem) safe as **/ and ** placeholders.
378
- const DS = "\u0001", DP = "\u0002"
379
- const escaped = pattern
380
- .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
381
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
382
- .replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
383
- .replace(new RegExp(DS, "g"), "(?:.+/)?")
384
- .replace(new RegExp(DP, "g"), ".*")
385
- return new RegExp(`^${escaped}$`)
386
- }
366
+ /** Glob dialect (TOOLS.md §17) lives in glob-dialect.mjs single authority for
367
+ * brace expansion / exclusion / explicit syntax errors (extracted 2026-09-06 —
368
+ * advisor #5: shared.mjs exceeded 500 lines). Re-exported so existing importers
369
+ * (system.mjs tools, ls filter, tests) keep their import paths unchanged. */
370
+ export {
371
+ globToRegex,
372
+ splitGlobPatterns,
373
+ compileGlobMatchers,
374
+ GLOB_EXTGLOB_ERROR,
375
+ GLOB_EMPTY_BRACE_ERROR,
376
+ GLOB_UNCLOSED_BRACE_ERROR,
377
+ GLOB_NESTED_BRACE_ERROR,
378
+ } from "./glob-dialect.mjs"
379
+
387
380
 
388
381
  /** Decode a numeric HTML entity to its code point — invalid/out-of-range
389
382
  * values (e.g. &#999999999999;) must not throw RangeError; keep the source
@@ -6,8 +6,9 @@ import {
6
6
  BASH_TIMEOUT_MS,
7
7
  IGNORED_DIRS,
8
8
  resolveInCwd,
9
- hasFileRedirection,
10
9
  globToRegex,
10
+ splitGlobPatterns,
11
+ compileGlobMatchers,
11
12
  normalizeEOL,
12
13
  } from "./shared.mjs";
13
14
  import { spawn, execFileSync } from "node:child_process";
@@ -51,20 +52,6 @@ function posixSyntaxHint(command) {
51
52
  // bash — command execution with safety gates
52
53
  // ====================================================================
53
54
 
54
- /**
55
- * Pre-execution safety checks for bash commands.
56
- * Layers: file redirection (guides toward structured tools, not a security gate).
57
- * Destructive commands (rm -rf, DROP TABLE, ...) are deliberately NOT rejected:
58
- * a determined model bypasses text matching anyway — real security is at the
59
- * tool approval layer plus snapshot backups (gitGuardSnapshot / checkpoint).
60
- * Git destructive ops: snapshot-then-proceed, never block.
61
- */
62
- function checkBashSafety(command, cwd) {
63
- if (hasFileRedirection(command)) {
64
- throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
65
- }
66
- }
67
-
68
55
  /**
69
56
  * Build environment for child process.
70
57
  * Passes through all parent env vars, with non-interactive overrides (EDITOR/PAGER/TERM).
@@ -113,11 +100,12 @@ function killProcessTree(child) {
113
100
  * cannot help (it was taken before the code was written); only a snapshot taken
114
101
  * immediately before the destructive command can.
115
102
  *
116
- * This is layer 1 (defense in depth): a WIDE match that snapshots before the command
117
- * runs. Layer 2 (checkBashSafety) then rejects the command when uncommitted changes
118
- * exist but a rejection alone is not enough: the model may retry a variant that
119
- * slips through the exact matcher (e.g. `git checkout HEAD -- .`), or run git outside
120
- * the bash tool. The snapshot taken here survives all of those paths.
103
+ * This is the defense-in-depth guard: a WIDE match that snapshots before the command
104
+ * runs the command itself is NEVER blocked (a determined model bypasses text
105
+ * matching anyway; the real gate is the approval layer). A snapshot alone is not
106
+ * enough: the model may retry a variant that slips through the exact matcher
107
+ * (e.g. `git checkout HEAD -- .`), or run git outside the bash tool. The snapshot
108
+ * taken here survives all of those paths.
121
109
  *
122
110
  * Matching is intentionally WIDE (false positives are harmless — one extra snapshot;
123
111
  * a missed match is a data-loss disaster).
@@ -159,6 +147,11 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
159
147
  detached: process.platform !== "win32",
160
148
  stdio: ["ignore", "pipe", "pipe"],
161
149
  env: buildBashEnv(),
150
+ // 双保险(2026-09-05——裸 spawn 无 signal 教训:abort 只靠 killTree 手动杀——taskkill
151
+ // best-effort 可能失败/竞态)——Node signal option = 第一道(abort 时自动杀**直接**子
152
+ // 进程——cmd——语义保证);killTree 仍是必需兜底(孙进程握管道使 close 不触发——见
153
+ // L191 注释——spawn 级 kill 不达孙进程)
154
+ ...(signal ? { signal } : {}),
162
155
  })
163
156
 
164
157
  const killTree = () => killProcessTree(child)
@@ -195,10 +188,18 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
195
188
  settled = true
196
189
  clearTimeout(timer)
197
190
  clearTimeout(graceTimer)
191
+ // 2026-09-05(advisor 🟡#2):收尾移除 abort 监听器——ctx.signal 为长生命周期对象,
192
+ // 残留 once 监听器每次 bash 调用累积(闭包持有 child/输出缓冲直到 abort 才释放)
193
+ if (signal) signal.removeEventListener("abort", killTree)
198
194
  resolve(result)
199
195
  }
200
196
 
201
197
  child.on("error", (error) => {
198
+ // signal 双保险(2026-09-05):abort 时 Node signal option 杀直接子进程 → 本事件
199
+ // 以 AbortError 先触发——**跳过 finish**(不 settled)——exit 事件随后必到(实证
200
+ // 2ms 内)走 L195 分支带已收集输出收尾(user interrupted——不丢 partial);
201
+ // 非 abort 的真 spawn 错误(command not found 等)保持原分支
202
+ if (error.name === "AbortError") return
202
203
  finish(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
203
204
  })
204
205
 
@@ -266,7 +267,6 @@ export const bashTool = {
266
267
  // guard anyway. Instead: snapshot every uncommitted file first, then ALLOW
267
268
  // the command. The snapshot makes the rollback reversible (defense in depth:
268
269
  // the wide matcher also covers variants like `git checkout HEAD -- .`).
269
- checkBashSafety(args.command, ctx.cwd)
270
270
  const guard = await gitGuardSnapshot(args.command, ctx.cwd)
271
271
  const result = await runBash(args.command, ctx.cwd, {
272
272
  timeout: args.timeout ?? BASH_TIMEOUT_MS,
@@ -289,7 +289,7 @@ export const globTool = {
289
289
  parameters: {
290
290
  type: "object",
291
291
  properties: {
292
- pattern: { type: "string", description: "Glob pattern" },
292
+ pattern: { type: "string", description: "Glob pattern — supports **, *, ?, [..], {a,b} braces, and space-separated exclusion (\"**/*.js !test/**\")" },
293
293
  path: { type: "string", description: "Directory to search in (default cwd)" },
294
294
  },
295
295
  required: ["pattern"],
@@ -297,10 +297,19 @@ export const globTool = {
297
297
  readonly: true,
298
298
  async execute(args, ctx) {
299
299
  const base = resolveInCwd(ctx, args.path ?? ".")
300
- const regex = globToRegex(args.pattern)
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
+ }
301
310
  const results = []
302
311
  for await (const relPath of walkFiles(base)) {
303
- if (regex.test(relPath)) {
312
+ if (match(relPath)) {
304
313
  results.push(relPath)
305
314
  if (results.length >= 1000) break
306
315
  }
@@ -343,7 +352,7 @@ export const grepTool = {
343
352
  properties: {
344
353
  pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
345
354
  path: { type: "string", description: "Directory or file to search (default cwd)" },
346
- glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
355
+ glob: { type: "string", description: "Only search files matching this glob — supports **, *, ?, [..], {a,b} braces and space-separated exclusion (\"**/*.js !test/**\")" },
347
356
  ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
348
357
  literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
349
358
  before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
@@ -361,7 +370,16 @@ export const grepTool = {
361
370
  } catch (e) {
362
371
  throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
363
372
  }
364
- const fileFilter = args.glob ? globToRegex(args.glob) : null
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
+ }
365
383
  const before = Math.max(0, Math.floor(args.before ?? 0))
366
384
  const after = Math.max(0, Math.floor(args.after ?? 0))
367
385
  const wantCtx = before > 0 || after > 0
@@ -388,13 +406,15 @@ export const grepTool = {
388
406
  }
389
407
  }
390
408
 
391
- async function walk(target) {
409
+ async function walk(target, rel) {
392
410
  if (hits.length >= 200) return
393
411
  // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
394
412
  let s
395
413
  try { s = await lstat(target) } catch { return }
396
414
  if (!s.isDirectory()) {
397
- if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
415
+ // rel 为相对搜索基的路径(排除前缀如 !test/** 必须按目录路径作用——不能用裸文件名);
416
+ // 单文件目标(path 指向文件)时 rel 为空 → 退化为按文件名过滤(既有语义)。
417
+ if (!fileTest || fileTest(rel || target.split(/[\\/]/).pop())) await search(target)
398
418
  return
399
419
  }
400
420
  let entries
@@ -405,11 +425,11 @@ export const grepTool = {
405
425
  }
406
426
  for (const e of entries) {
407
427
  if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
408
- await walk(join(target, e.name))
428
+ await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
409
429
  }
410
430
  }
411
431
 
412
- await walk(base)
432
+ await walk(base, "")
413
433
  if (hits.length === 0) return "(no matches)"
414
434
 
415
435
  // No context: keep original path:line: content format
package/src/tools/tree.md CHANGED
@@ -10,4 +10,5 @@ Parameters:
10
10
  Notes:
11
11
  - Capped at 200 entries.
12
12
  - Directories end with `/`; tree-drawing uses `├──`/`└──`/`│`.
13
- - Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
13
+ - Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
14
+ - Returns the directory text tree — directories first (`dir/`), files after, both sorted, capped at 200 entries.
@@ -0,0 +1,22 @@
1
+ Wait until a condition becomes true — polls the condition every interval_ms and returns as soon as it holds or the timeout_ms ceiling passes. Use it ONLY for genuinely asynchronous waits: an async subagent/consult still settling, a file appearing, a port opening. Synchronous tools (advisor, blocking subagent spawn) return when done — waiting after them is NOT needed and wastes time.
2
+
3
+ Parameters:
4
+ - condition (required): a semantic condition expression:
5
+ - `advisor settled` — an in-flight advisor review session has finished (async advisor-role children settled)
6
+ - `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled
7
+ - `consult done` — every consult_start session has drained
8
+ - `file exists:path` — the file at path exists (path relative to cwd)
9
+ - `port open:N` — something is listening on 127.0.0.1 port N
10
+ - The agent-internal conditions (advisor / subagent / consult) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for. An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
11
+ - interval_ms: poll interval (default 1000, floor 100 — polling never busy-spins)
12
+ - timeout_ms: overall ceiling (default 30000; config.json `agent.waitForTimeoutMs` overrides the default; hard cap 600000 like the execute tool)
13
+
14
+ Returns:
15
+ - `wait_for: condition satisfied after Nms (N checks): "<condition>"` — condition held
16
+ - `wait_for: timed out after Nms waiting for "<condition>" ...` — ceiling passed with the condition still false (never burns beyond the ceiling)
17
+ - interrupts (Ctrl+C / cancel) exit immediately; malformed conditions and unsupported syntax error out explicitly
18
+
19
+ Notes:
20
+ - Read-only and non-destructive — it only observes (agent pools, the filesystem, a local port probe).
21
+ - Blocking by design — call it ALONE in a turn, not batched with calls that depend on its result.
22
+ - Do not use it to wait after synchronous tools (advisor / blocking subagent spawn / verify return only when done).