@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
package/tools/ops.mjs ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
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).
6
+ */
7
+ import { DESC, resolveInCwd, truncate } from "./shared.mjs"
8
+ // 单一写路径点(编辑器编辑径注入缝——CORE-UNIFICATION §2.13.5):路径操作也经它落盘
9
+ // (默认径 = 既有 cp / rename / EXDEV 回退语义,零行为变)。
10
+ import { writeThroughPath } from "./write-path.mjs"
11
+ import { existsSync } from "node:fs"
12
+ import { execFileSync } from "node:child_process"
13
+ import net from "node:net"
14
+
15
+ // ─── file_ops ──────────────────────────────────────────────────
16
+
17
+ export const fileOpsTool = {
18
+ name: "file_ops",
19
+ description: DESC("file_ops"),
20
+ parameters: {
21
+ type: "object",
22
+ properties: {
23
+ action: { type: "string", enum: ["move", "copy", "rename"], description: "move | copy | rename" },
24
+ source: { type: "string", description: "Source path, relative to cwd or absolute" },
25
+ dest: { type: "string", description: "Destination path" },
26
+ },
27
+ required: ["action", "source", "dest"],
28
+ },
29
+ readonly: false,
30
+ async execute({ action, source, dest }, ctx) {
31
+ if (typeof source !== "string" || !source) return "Error: source is required"
32
+ if (typeof dest !== "string" || !dest) return "Error: dest is required"
33
+ if (!["move", "copy", "rename"].includes(action)) return `Error: action must be move | copy | rename (got "${action}")`
34
+ const src = resolveInCwd(ctx, source)
35
+ const dst = resolveInCwd(ctx, dest)
36
+ if (src === dst) return "Error: source and dest resolve to the same path"
37
+
38
+ // move & rename share the rename syscall; cross-device move falls back to copy+rm
39
+ // (回退语义住写路径点——§2.13.5)。
40
+ await writeThroughPath(src, null, { op: action, dest: dst })
41
+ const verb = action === "copy" ? "Copied" : action === "rename" ? "Renamed" : "Moved"
42
+ return `${verb} ${source} → ${dest}`
43
+ },
44
+ }
45
+
46
+ // ─── process ───────────────────────────────────────────────────
47
+
48
+ export const processTool = {
49
+ name: "process",
50
+ description: DESC("process"),
51
+ parameters: {
52
+ type: "object",
53
+ properties: {
54
+ name: { type: "string", description: "Optional name substring filter (case-insensitive)" },
55
+ },
56
+ },
57
+ readonly: true,
58
+ async execute({ name }, ctx) {
59
+ const filter = typeof name === "string" && name.trim() ? name.trim().toLowerCase() : null
60
+ let rows
61
+ try {
62
+ rows = process.platform === "win32" ? listWindows() : listPosix()
63
+ } catch (e) {
64
+ return `process listing failed: ${e?.message ?? String(e)}`
65
+ }
66
+ if (filter) rows = rows.filter((r) => r.name.toLowerCase().includes(filter))
67
+ if (rows.length === 0) return filter ? `No running processes match "${name}"` : "(no processes)"
68
+ return truncate(rows.map((r) => `${r.name}\tPID ${r.pid}${r.mem ? `\t${r.mem}` : ""}`).join("\n"))
69
+ },
70
+ }
71
+
72
+ function listWindows() {
73
+ // tasklist /FO CSV /NH → lines: "name.exe","1234","Console","1","12,345 K"
74
+ const out = execFileSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 10000 })
75
+ const rows = []
76
+ for (const line of out.split("\n")) {
77
+ const parts = line.split('","')
78
+ if (parts.length < 2) continue
79
+ const name = parts[0].replace(/^"/, "").trim()
80
+ const pid = parts[1].replace(/"/, "").trim()
81
+ const mem = parts[4] ? parts[4].replace(/"/, "").trim() : ""
82
+ if (!name || !pid) continue
83
+ rows.push({ name, pid, mem })
84
+ }
85
+ return rows
86
+ }
87
+
88
+ function listPosix() {
89
+ const out = execFileSync("ps", ["-eo", "pid=,comm="], { encoding: "utf8", timeout: 10000 })
90
+ const rows = []
91
+ for (const line of out.split("\n")) {
92
+ const m = line.trim().match(/^(\d+)\s+(.+)$/)
93
+ if (m) rows.push({ name: m[2], pid: m[1], mem: "" })
94
+ }
95
+ return rows
96
+ }
97
+
98
+ // ─── get_current_time ──────────────────────────────────────────
99
+
100
+ export const getCurrentTimeTool = {
101
+ name: "get_current_time",
102
+ description: DESC("get_current_time"),
103
+ parameters: { type: "object", properties: {} },
104
+ readonly: true,
105
+ async execute() {
106
+ const now = new Date()
107
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "unknown"
108
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
109
+ return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
110
+ },
111
+ }
112
+
113
+ // ─── wait_for ───────────────────────────────────────────────────
114
+
115
+ /** wait_for bounds (TOOLS.md §16): a BUILT-IN ceiling replaces the unbounded
116
+ * sleep-then-wait — timeout_ms defaults to 30s (config.json agent.waitForTimeoutMs
117
+ * overrides), capped at WAIT_FOR_MAX_TIMEOUT_MS (600s — the execute-tool timeout
118
+ * convention, Math.min(t, 600_000)); interval_ms defaults to 1s with a 100ms
119
+ * floor so polling never busy-spins. */
120
+ export const WAIT_FOR_DEFAULT_TIMEOUT_MS = 30_000
121
+ export const WAIT_FOR_MAX_TIMEOUT_MS = 600_000
122
+ export const WAIT_FOR_DEFAULT_INTERVAL_MS = 1_000
123
+ export const WAIT_FOR_MIN_INTERVAL_MS = 100
124
+
125
+ // Condition-source seam (§16 评审 #4): production uses the real evaluator
126
+ // (evaluateWaitForCondition); tests inject a deterministic fake source via
127
+ // setWaitForConditionSource (default null — production path unchanged).
128
+ let injectedConditionSource = null
129
+ export function setWaitForConditionSource(source) {
130
+ injectedConditionSource = typeof source === "function" ? source : null
131
+ }
132
+
133
+ /** Parse a wait_for condition into { kind, arg }. Unknown conditions are an
134
+ * EXPLICIT error enumerating the supported forms (评审 #2 — never a silent
135
+ * wait on a misspelled condition). */
136
+ export function parseWaitForCondition(condition) {
137
+ const c = String(condition ?? "").trim()
138
+ let m
139
+ if (c === "advisor settled") return { kind: "advisor", arg: null }
140
+ if (c === "consult done") return { kind: "consult", arg: null }
141
+ if ((m = c.match(/^subagent id:\s*(\d+) done$/))) return { kind: "subagent", arg: String(Number(m[1])) }
142
+ if ((m = c.match(/^file exists:\s*(.+)$/))) return { kind: "file", arg: m[1].trim() }
143
+ if ((m = c.match(/^port open:\s*(\d+)$/))) return { kind: "port", arg: Number(m[1]) }
144
+ throw new Error(
145
+ `wait_for: unsupported condition "${c}" — supported: "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"`,
146
+ )
147
+ }
148
+
149
+ /** Async-subagent pool of an agent — ctx.agent 可达性(评审 #1):dispatch 把
150
+ * agent 实例放进工具 ctx(agent/dispatch.mjs toolCtx.agent),池挂在 agent 上
151
+ * (深度 0 的 VS Code 侧 agent._asyncSubagents 与 history._asyncSubagents 同
152
+ * 一 Map——见 agent.mjs;CLI 侧直接是 agent._asyncSubagents)。 */
153
+ function asyncPool(agent) {
154
+ if (!agent) return new Map()
155
+ if (agent._asyncSubagents instanceof Map) return agent._asyncSubagents
156
+ return agent.history?._asyncSubagents instanceof Map ? agent.history._asyncSubagents : new Map()
157
+ }
158
+
159
+ /** "subagent id:N done" — the async entry for N settled (status done / done flag)
160
+ * or already left the pool (moved to pending results / digested / never spawned —
161
+ * nothing is left to wait on). Absent ids are done by vacuity: the model waits on
162
+ * ids its own spawn ack just returned, so an id no longer in the pool means done.
163
+ * Dual key lookup (2026-09-06 advisor #1): VS Code pool keys are the numeric
164
+ * spawn-time id, the CLI keys them as String(id) — accept both. */
165
+ function asyncEntryDone(agent, id) {
166
+ const pool = asyncPool(agent)
167
+ const key = String(id)
168
+ const entry = pool.get(key) ?? pool.get(Number(key)) ?? null
169
+ if (!entry) return true
170
+ return entry.done === true || entry.status === "done"
171
+ }
172
+
173
+ /** 评审池双载体(第 10 批 §18.3 #2):agent ∪ history——未初始化的载体不进集合。 */
174
+ function advisorPools(agent) {
175
+ return [agent?._asyncAdvisors, agent?.history?._asyncAdvisors].filter((m) => m instanceof Map)
176
+ }
177
+
178
+ /** Any entry matching pred that is still running/queued (not done) — in the given pool. */
179
+ function hasRunningIn(pool, pred) {
180
+ for (const e of pool?.values() ?? []) {
181
+ if (pred(e) && !(e.done === true || e.status === "done")) return true
182
+ }
183
+ return false
184
+ }
185
+
186
+ /** "advisor settled" 真判据(第 10 批 §18.3 #2 修正):**评审池真实态**——双载体
187
+ * (agent._asyncAdvisors ∪ history._asyncAdvisors)无 running/queued 条目(与 §11.2 的
188
+ * 未决评审判定 advisorReviewPending 同源语义)。修前读**子代理池**的 role==="advisor"
189
+ * 条目——评审条目只在 _asyncAdvisors → 恒无命中 → 恒真 0ms 秒过(用户实证)。 */
190
+ function advisorSettled(agent) {
191
+ return !advisorPools(agent).some((pool) => hasRunningIn(pool, (e) => e.status === "running" || e.status === "queued"))
192
+ }
193
+
194
+ /** "consult done" — every consult session has drained (pending 0) or was
195
+ * explicitly stopped (its children were aborted). No sessions → true (vacuously). */
196
+ function consultAllDone(agent) {
197
+ const sessions = agent?._consultSessions
198
+ if (!sessions || sessions.size === 0) return true
199
+ for (const s of sessions.values()) {
200
+ if (!s.stopped && (s.pending ?? 0) > 0) return false
201
+ }
202
+ return true
203
+ }
204
+
205
+ /** "port open:N" — 127.0.0.1 probe with a short connect timeout (never hangs a poll). */
206
+ function portOpenProbe(port) {
207
+ return new Promise((resolve) => {
208
+ const socket = net.connect({ host: "127.0.0.1", port, timeout: 400 })
209
+ let settled = false
210
+ const finish = (v) => {
211
+ if (settled) return
212
+ settled = true
213
+ socket.destroy()
214
+ resolve(v)
215
+ }
216
+ socket.once("connect", () => finish(true))
217
+ socket.once("error", () => finish(false))
218
+ socket.once("timeout", () => finish(false))
219
+ })
220
+ }
221
+
222
+ /** Real condition evaluator (production path) — returns a boolean; throws on an
223
+ * unsupported condition string. Polling is async (await setTimeout) — the event
224
+ * loop keeps running so background async work (subagents/consult) can settle
225
+ * between polls (评审 #1 — no blocking spin). */
226
+ export async function evaluateWaitForCondition(condition, ctx) {
227
+ const parsed = parseWaitForCondition(condition)
228
+ const agent = ctx?.agent
229
+ switch (parsed.kind) {
230
+ case "advisor":
231
+ // 第 10 批修正(§18.3 #2——修前恒真 0ms 秒过):判据 = 评审池真实态(双载体)——
232
+ // 不再读子代理池的 role==="advisor" 条目(该池永无此类条目);语义与 §11.2 未决
233
+ // 评审判定同源(advisorReviewPending)。
234
+ return advisorSettled(agent)
235
+ case "subagent":
236
+ return asyncEntryDone(agent, parsed.arg)
237
+ case "consult":
238
+ return consultAllDone(agent)
239
+ case "file":
240
+ return existsSync(resolveInCwd(ctx, parsed.arg))
241
+ case "port":
242
+ return await portOpenProbe(parsed.arg)
243
+ default:
244
+ return false
245
+ }
246
+ }
247
+
248
+ async function evalConditionSource(condition, ctx) {
249
+ if (injectedConditionSource) return injectedConditionSource(condition, ctx)
250
+ return evaluateWaitForCondition(condition, ctx)
251
+ }
252
+
253
+ export const waitForTool = {
254
+ name: "wait_for",
255
+ description: DESC("wait_for"),
256
+ parameters: {
257
+ type: "object",
258
+ properties: {
259
+ condition: { type: "string", description: 'Condition expression — "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"' },
260
+ interval_ms: { type: "integer", description: "Poll interval in ms (default 1000, floor 100)" },
261
+ timeout_ms: { type: "integer", description: "Overall timeout in ms (default 30000, cap 600000; config.json agent.waitForTimeoutMs overrides the default)" },
262
+ },
263
+ required: ["condition"],
264
+ },
265
+ readonly: true,
266
+ async execute(args, ctx = {}) {
267
+ const condition = typeof args?.condition === "string" ? args.condition.trim() : ""
268
+ if (!condition) return "Error: condition is required"
269
+ const intervalMs = Math.max(WAIT_FOR_MIN_INTERVAL_MS, Math.floor(args?.interval_ms ?? WAIT_FOR_DEFAULT_INTERVAL_MS))
270
+ const cfgTimeout = ctx?.agent?.config?.agent?.waitForTimeoutMs
271
+ const base = Number.isFinite(args?.timeout_ms)
272
+ ? args.timeout_ms
273
+ : Number.isFinite(cfgTimeout) && cfgTimeout > 0 ? cfgTimeout : WAIT_FOR_DEFAULT_TIMEOUT_MS
274
+ const timeoutMs = Math.min(Math.max(1, Math.floor(base)), WAIT_FOR_MAX_TIMEOUT_MS)
275
+ const started = Date.now()
276
+ let polls = 0
277
+ for (;;) {
278
+ // Cancel/interrupt-safe exit(T-W7):Ctrl+C / 定向 abort 传播——不吞信号、
279
+ // 不把用户停变成工具错误(dispatch 对 aborted signal 原样再抛)。
280
+ if (ctx.signal?.aborted) throw new Error("wait_for: interrupted by abort signal")
281
+ polls++
282
+ const ok = await evalConditionSource(condition, ctx)
283
+ if (ok === true) {
284
+ return `wait_for: condition satisfied after ${Date.now() - started}ms (${polls} check${polls === 1 ? "" : "s"}): "${condition}"`
285
+ }
286
+ const remaining = started + timeoutMs - Date.now()
287
+ if (remaining <= 0) {
288
+ return `wait_for: timed out after ${timeoutMs}ms waiting for "${condition}" (${polls} checks — condition never became true)`
289
+ }
290
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)))
291
+ }
292
+ },
293
+ }
@@ -0,0 +1,290 @@
1
+ import {
2
+ DESC,
3
+ autoSyntaxCheck,
4
+ resolveInCwd,
5
+ normalizeEOL,
6
+ detectFileEol,
7
+ majorityEol
8
+ } from "./shared.mjs";
9
+ import { writeThroughPath, TMP_SUFFIX, markDirty } from "./write-path.mjs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { mkdir, readFile, lstat, unlink } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
13
+ import { relative, dirname } from "node:path";
14
+
15
+ /**
16
+ * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
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.
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.
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
+
35
+ function parsePatch(patch) {
36
+ // Patch text often comes from CRLF terminals/model output; trailing \r mixed into hunk content breaks context matching, strip uniformly
37
+ const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
38
+ const files = []
39
+ let cur = null
40
+ let i = 0
41
+ const stripPrefix = (p) => p.replace(/^[ab]\//, "")
42
+ while (i < lines.length) {
43
+ const line = lines[i]
44
+ if (line.startsWith("--- ")) {
45
+ const oldPath = line.slice(4).trim()
46
+ const plus = lines[i + 1]
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}"`)
66
+ }
67
+ if (line.startsWith("@@")) {
68
+ if (!cur) throw new Error("Malformed patch: hunk header before any file header")
69
+ const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
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
+ }
110
+ let oldNeed = m[1] == null ? 1 : Number(m[1])
111
+ let newNeed = m[2] == null ? 1 : Number(m[2])
112
+ const hunk = { ops: [] }
113
+ i++
114
+ while (oldNeed > 0 || newNeed > 0) {
115
+ if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
116
+ const hl = lines[i]
117
+ if (hl.startsWith("\\")) { i++; continue } // ""
118
+ const tag = hl === "" ? " " : hl[0] // pure blank line: treat leniently as context line
119
+ const text = hl === "" ? "" : hl.slice(1)
120
+ if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
121
+ else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
122
+ else if (tag === "+") { hunk.ops.push({ type: "+", text }); newNeed-- }
123
+ else throw new Error(`Malformed patch: unexpected line "${hl.slice(0, 60)}" inside hunk`)
124
+ i++
125
+ }
126
+ cur.hunks.push(hunk)
127
+ continue
128
+ }
129
+ i++ // skip diff --git / index / blank lines and other metadata
130
+ }
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
135
+ }
136
+
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 */
138
+ function applyHunks(fileLines, hunks, eol, path) {
139
+ const cr = eol === "\r\n" ? "\r" : ""
140
+ for (let h = 0; h < hunks.length; h++) {
141
+ const oldSeq = hunks[h].ops.filter((o) => o.type !== "+").map((o) => o.text)
142
+ if (oldSeq.length === 0) throw new Error(`Hunk ${h + 1} in ${path} has no context/removed lines to locate`)
143
+ const matches = []
144
+ for (let pos = 0; pos + oldSeq.length <= fileLines.length; pos++) {
145
+ let ok = true
146
+ for (let j = 0; j < oldSeq.length; j++) {
147
+ if (fileLines[pos + j].replace(/\r$/, "") !== oldSeq[j]) { ok = false; break }
148
+ }
149
+ if (ok) matches.push(pos)
150
+ }
151
+ if (matches.length === 0) {
152
+ const preview = oldSeq.slice(0, 3).join(" ⏎ ")
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.`)
154
+ }
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
+ }
159
+ const pos = matches[0]
160
+ const out = []
161
+ let src = pos
162
+ for (const op of hunks[h].ops) {
163
+ if (op.type === " ") out.push(fileLines[src++]) // preserve original context line (trailing whitespace as-is)
164
+ else if (op.type === "-") src++
165
+ else out.push(op.text + cr)
166
+ }
167
+ fileLines.splice(pos, oldSeq.length, ...out)
168
+ }
169
+ }
170
+
171
+ export const applyPatchTool = {
172
+ name: "apply_patch",
173
+ description: DESC("apply_patch"),
174
+ parameters: {
175
+ type: "object",
176
+ properties: {
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>)." },
178
+ },
179
+ required: ["patch"],
180
+ },
181
+ readonly: false,
182
+ /** For the agent layer to track touched files (multi-path, replaces single path parameter) */
183
+ touchedPaths(args) {
184
+ try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
185
+ },
186
+ async execute(args, ctx) {
187
+ const files = parsePatch(args.patch ?? "")
188
+ // Read all into memory first for trial: if any hunk fails, abort entirely — never write a partial patch (atomicity)
189
+ const planned = []
190
+ for (const f of files) {
191
+ const abs = resolveInCwd(ctx, f.path)
192
+ if (f.isNew) {
193
+ if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
194
+ // New file: follow the directory's majority EOL style (default LF).
195
+ const eol = majorityEol(dirname(abs))
196
+ const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join(eol) + eol
197
+ planned.push({ abs, path: f.path, content, isNew: true })
198
+ } else {
199
+ const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
200
+ const eol = detectFileEol(original)
201
+ // Apply hunks in the normalized LF domain, then write back joined with the
202
+ // file's ORIGINAL EOL style — join("\n") here used to rewrite CRLF files as LF.
203
+ const lines = normalizeEOL(original).split("\n")
204
+ applyHunks(lines, f.hunks, "\n", f.path)
205
+ planned.push({ abs, path: f.path, content: lines.join(eol), isNew: false })
206
+ }
207
+ }
208
+ // Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
209
+ // 两段式经单一写路径点(§2.13.5):阶段一全部 staging(写入各自 `<abs>.thincoder-tmp`)
210
+ // ⇒ 阶段二逐档 commit(rename 到目标)。**默认径 = 改前语义逐字同**(任一失败无档落盘:
211
+ // 阶段一失败时目标档全未动、阶段二失败时已提交档保持——同改前)。
212
+ // 编辑器径(注入面)例外:内容在阶段一即提交(宿主侧同款——applyEdit + save)⇒ 阶段
213
+ // 二跳过该档;已提交档不回滚(与宿主侧现行为一致,非本档引入的语义)。
214
+ const staged = []
215
+ const committedByEditor = new Set()
216
+ try {
217
+ for (const p of planned) {
218
+ await mkdir(dirname(p.abs), { recursive: true })
219
+ const r = await writeThroughPath(p.abs, p.content, { op: "write", stage: true })
220
+ if (r.via === "editor") committedByEditor.add(p.abs)
221
+ else staged.push(p.abs)
222
+ }
223
+ for (const p of planned) {
224
+ if (committedByEditor.has(p.abs)) continue
225
+ await writeThroughPath(p.abs, null, { op: "commit" })
226
+ }
227
+ } catch (renameError) {
228
+ // rename phase failed: clean up leftover .tmp files
229
+ for (const abs of staged) {
230
+ try { await unlink(abs + TMP_SUFFIX) } catch {}
231
+ }
232
+ throw renameError
233
+ }
234
+ const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
235
+ // Mark every touched file dirty — insert_after must not run on stale line numbers.
236
+ // 整批记账(dirty)= apply_patch 的批级语义(两段式全成功后一次落账——与改前逐字同)。
237
+ for (const p of planned) markDirty(p.abs)
238
+ const syntaxChecks = await Promise.all(planned.map(async (p) => {
239
+ const r = await autoSyntaxCheck(p.abs)
240
+ return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
241
+ }))
242
+ const syntaxResults = syntaxChecks.filter(Boolean).join("\n")
243
+ return `Applied patch to ${planned.length} file(s):\n${summary}${syntaxResults ? "\n\nSyntax checks:\n" + syntaxResults : ""}`
244
+ },
245
+ }
246
+
247
+
248
+
249
+ // ---------------------------------------------------------------- bash
250
+
251
+ export const deleteTool = {
252
+ name: "delete",
253
+ description: DESC("delete"),
254
+ parameters: {
255
+ type: "object",
256
+ properties: {
257
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
258
+ force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
259
+ },
260
+ required: ["path"],
261
+ },
262
+ readonly: false,
263
+ touchedPaths(args) { return args.path ? [args.path] : [] },
264
+ async execute(args, ctx) {
265
+ const abs = resolveInCwd(ctx, args.path)
266
+ let s
267
+ try {
268
+ s = await lstat(abs)
269
+ } catch {
270
+ throw new Error(`File not found: ${args.path}`)
271
+ }
272
+ if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
273
+ // git-tracked files: refuse direct deletion (safety net); untracked: allow
274
+ // Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
275
+ const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
276
+ let tracked = false
277
+ try {
278
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
279
+ tracked = true
280
+ } catch {
281
+ // untracked / non-git repo
282
+ }
283
+ if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
284
+ // 单一写路径点(§2.13.5):删除也经注入面(打开且脏 ⇒ 拒删);默认径 = unlink + 记账。
285
+ await writeThroughPath(abs, null, { op: "delete" })
286
+ return `Deleted ${args.path}`
287
+ },
288
+ }
289
+
290
+ // ---------------------------------------------------------------- git_diff
@@ -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
+ }