thincoder 0.12.59 → 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.
- package/CHANGELOG.md +62 -4
- package/README.md +10 -8
- package/bin/thincoder.mjs +99 -133
- package/package.json +6 -4
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +45 -21
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +59 -137
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +156 -359
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +27 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +145 -27
- package/src/agent/helpers.mjs +107 -13
- package/src/agent/record-results.mjs +55 -11
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +242 -0
- package/src/agent/setup-reminders.mjs +69 -0
- package/src/agent/setup.mjs +107 -127
- package/src/agent/spawn-child.mjs +55 -13
- package/src/agent-tools/advisor-async.mjs +346 -0
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +167 -116
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +139 -107
- package/src/agent-tools/design-token.mjs +117 -0
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +289 -0
- package/src/agent-tools/read-history.mjs +166 -32
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +137 -34
- package/src/agent-tools/subagent-actions.mjs +180 -133
- package/src/agent-tools/subagent-async.mjs +184 -177
- package/src/agent-tools/subagent-panel.mjs +160 -0
- package/src/agent-tools/subagent-run.mjs +205 -0
- package/src/agent-tools/subagent-scheduler.mjs +100 -27
- package/src/agent-tools/subagent-spawn.mjs +453 -0
- package/src/agent-tools/subagent.mjs +256 -578
- package/src/agent-tools/verify.mjs +119 -292
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +89 -205
- package/src/cli/distill-command.mjs +12 -6
- package/src/cli/make-agent.mjs +26 -8
- package/src/cli/memory-command.mjs +4 -3
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +42 -17
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +180 -63
- package/src/context.mjs +5 -147
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +128 -0
- package/src/distill.mjs +11 -11
- package/src/expand-home.mjs +16 -0
- package/src/explore-distill.mjs +155 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +72 -32
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +236 -0
- package/src/memory/docs.mjs +68 -54
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/memory.mjs +3 -1
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +82 -0
- package/src/prompts/advisor-design.md +18 -75
- package/src/prompts/advisor-round1.md +14 -67
- package/src/prompts/advisor-round2.md +15 -51
- package/src/prompts/advisor-round3.md +15 -51
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +5 -23
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +18 -98
- package/src/provider/errors.mjs +101 -0
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/retry.mjs +8 -45
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +188 -60
- package/src/session.mjs +104 -124
- package/src/token-ttl.mjs +274 -0
- package/src/tools/{system.mjs → bash.mjs} +19 -221
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +41 -16
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +23 -34
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +9 -8
- package/src/tools/ops.mjs +188 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +4 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -2
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tools/shared.mjs +14 -13
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +36 -228
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +142 -30
- package/src/tui/cmd-eng.mjs +28 -40
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +40 -38
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +52 -9
- package/src/tui/pickers.mjs +28 -392
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +12 -9
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +106 -295
- package/src/tui/subagent-children.mjs +162 -0
- package/src/tui/subagent-freeze.mjs +169 -0
- package/src/tui/subagent-panel.mjs +24 -31
- package/src/tui/suspension-drive.mjs +297 -0
- package/src/tui/tool-args.mjs +7 -5
- package/src/tui/tool-display.mjs +143 -0
- package/src/tui/tool-events.mjs +56 -185
- package/src/tui/tui-lifecycle.mjs +46 -4
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +61 -21
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -56
- package/src/prompts/discipline.md +0 -102
- package/src/prompts/eng-coder.md +0 -44
- package/src/prompts/engineering-sub.md +0 -34
- package/src/prompts/engineering.md +0 -340
- package/src/prompts/explore.md +0 -21
- package/src/prompts/main.md +0 -56
- package/src/prompts/methodology-template.md +0 -58
- package/src/prompts/plan.md +0 -16
- package/src/prompts/system.md +0 -63
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
package/src/tools/file.mjs
CHANGED
|
@@ -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
|
|
99
|
-
const
|
|
100
|
-
const ln =
|
|
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
|
-
|
|
108
|
-
|
|
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]
|
|
@@ -217,21 +237,26 @@ export const editTool = {
|
|
|
217
237
|
type: "object",
|
|
218
238
|
properties: {
|
|
219
239
|
path: { type: "string", description: "File path — single form: required; with the edits array: optional top-level default for entries without their own path" },
|
|
220
|
-
old_string: { type: "string", description: "Exact text to replace" },
|
|
221
|
-
new_string: { type: "string", description: "Replacement text" },
|
|
240
|
+
old_string: { type: "string", description: "Exact text to replace (tolerant matching: exact → whitespace-only variant → fuzzy match at ≥90% line equality after whitespace/indent/quote normalization). Mutually exclusive with line/startLine/endLine" },
|
|
241
|
+
new_string: { type: "string", description: "Replacement text — content-based edits require it (empty is an explicit error); with line-based targeting (line/startLine/endLine) give it to replace the line/range, or OMIT it to delete (an explicit empty string is an error — omission is the delete signal)" },
|
|
242
|
+
line: { type: "integer", description: "1-based line number — replace that single line with new_string, or OMIT new_string to DELETE the line; no old_string needed (mutually exclusive with old_string and startLine/endLine)" },
|
|
243
|
+
startLine: { type: "integer", description: "1-based first line of the range to replace with new_string (inclusive — requires endLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
|
|
244
|
+
endLine: { type: "integer", description: "1-based last line of the range to replace with new_string (inclusive — requires startLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
|
|
222
245
|
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
|
|
223
246
|
edits: {
|
|
224
247
|
type: "array",
|
|
225
|
-
description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string — provide each change's
|
|
248
|
+
description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
|
|
226
249
|
items: {
|
|
227
250
|
type: "object",
|
|
228
251
|
properties: {
|
|
229
252
|
path: { type: "string" },
|
|
230
253
|
old_string: { type: "string" },
|
|
231
254
|
new_string: { type: "string" },
|
|
255
|
+
line: { type: "integer" },
|
|
256
|
+
startLine: { type: "integer" },
|
|
257
|
+
endLine: { type: "integer" },
|
|
232
258
|
replace_all: { type: "boolean" },
|
|
233
259
|
},
|
|
234
|
-
required: ["old_string", "new_string"],
|
|
235
260
|
},
|
|
236
261
|
},
|
|
237
262
|
},
|
|
@@ -253,8 +278,8 @@ export const editTool = {
|
|
|
253
278
|
// (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
|
|
254
279
|
if (args.edits) return applyEditBatch(args, ctx)
|
|
255
280
|
|
|
256
|
-
// 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(
|
|
257
|
-
// 行级 LCS
|
|
281
|
+
// 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(EDIT.md §6——
|
|
282
|
+
// 行级 LCS 判定:零重叠→替换即删 / 一般 diff→LCS / 空 new→显式报错)
|
|
258
283
|
return runSingleEdit(args, ctx)
|
|
259
284
|
},
|
|
260
285
|
}
|
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':
|
|
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
|
@@ -42,7 +42,7 @@ export const gitTool = {
|
|
|
42
42
|
action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "add", "rm", "commit", "push", "tag", "branch", "checkout", "restore", "stash", "fetch", "pull", "reset", "revert", "merge", "cherry-pick", "ls-remote", "clone", "init", "rebase", "remote", "clean", "switch", "apply", "worktree", "archive", "blame", "mv"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote / clone / init / rebase / remote / clean / switch / apply / worktree / archive / blame / mv — clean/rebase 操作前自动快照,checkpointAction=rewind 恢复" },
|
|
43
43
|
// diff/log params
|
|
44
44
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
45
|
-
path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm/apply/archive/blame/mv/worktree) File or directory to scope to / stage / restore(checkout/restore 操作前自动快照,checkpointAction=rewind
|
|
45
|
+
path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm/apply/archive/blame/mv/worktree) File or directory to scope to / stage / restore(checkout/restore 操作前自动快照,checkpointAction=rewind 恢复)— add/rm/commit: space-separated for multiple(git add a b c——2026-09-05 发版痛点;含空格的文件名会被按多路径拆分——请用 bash git 处理)" },
|
|
46
46
|
ref: { type: "string", description: "(diff/show/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create/rebase/worktree:add/archive) Commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)" },
|
|
47
47
|
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
48
48
|
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
@@ -143,17 +143,28 @@ export const gitTool = {
|
|
|
143
143
|
}
|
|
144
144
|
case "rm": {
|
|
145
145
|
if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
|
|
146
|
-
const
|
|
147
|
-
|
|
146
|
+
const paths = args.path.split(/\s+/).filter(Boolean)
|
|
147
|
+
const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
|
|
148
|
+
return r.ok ? truncate(r.out || `Untracked ${paths.join(" ")} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
|
|
148
149
|
}
|
|
149
150
|
case "commit": {
|
|
150
151
|
if (!args.message) return "Error: commit requires message"
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
// F-3 (QUICKFIX-BATCH-2):path 给定(含空串——空/空白 trim 后空一律明确错误,绝不
|
|
153
|
+
// 回落 add -A)→ git commit --only <paths>——从工作树取列文件提交(忽略索引他批——
|
|
154
|
+
// 原子——不再先 add)。空格分隔唯一形态(含空格文件名不支持)。无 path(缺省)→ add
|
|
155
|
+
// -A 全量(单代理语义保留)。
|
|
155
156
|
const parts = []
|
|
156
|
-
|
|
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
|
+
}
|
|
157
168
|
if (commit.ok) {
|
|
158
169
|
if (commit.out) parts.push(commit.out)
|
|
159
170
|
// F6: commit = new safety baseline — clear this project's checkpoints
|
|
@@ -189,9 +200,11 @@ export const gitTool = {
|
|
|
189
200
|
}
|
|
190
201
|
case "add": {
|
|
191
202
|
// Granular staging: stage `path` when given, else all changes (add -A).
|
|
192
|
-
|
|
203
|
+
// 多路径:空格分隔(ref 先例 L175——2026-09-05 发版痛点——git add 单路径被迫 N 次调用)
|
|
204
|
+
const paths = args.path ? args.path.split(/\s+/).filter(Boolean) : null
|
|
205
|
+
const cmdArgs = paths?.length ? ["add", "--", ...paths] : ["add", "-A"]
|
|
193
206
|
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
194
|
-
return r.ok ? truncate(r.out || `Staged ${
|
|
207
|
+
return r.ok ? truncate(r.out || `Staged ${paths?.join(" ") || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
|
|
195
208
|
}
|
|
196
209
|
case "tag": {
|
|
197
210
|
const sub = args.tagAction
|
|
@@ -341,27 +354,3 @@ export const gitTool = {
|
|
|
341
354
|
},
|
|
342
355
|
}
|
|
343
356
|
|
|
344
|
-
// ---------------------------------------------------------------- question
|
|
345
|
-
|
|
346
|
-
export const questionTool = {
|
|
347
|
-
name: "question",
|
|
348
|
-
description: DESC("question"),
|
|
349
|
-
parameters: {
|
|
350
|
-
type: "object",
|
|
351
|
-
properties: {
|
|
352
|
-
question: { type: "string", description: "The question to ask the user" },
|
|
353
|
-
options: {
|
|
354
|
-
type: "array",
|
|
355
|
-
items: { type: "string" },
|
|
356
|
-
description: "Single-choice options for the user to pick from (optional)",
|
|
357
|
-
},
|
|
358
|
-
},
|
|
359
|
-
required: ["question"],
|
|
360
|
-
},
|
|
361
|
-
readonly: true,
|
|
362
|
-
async execute(args, ctx) {
|
|
363
|
-
if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
|
|
364
|
-
return ctx.onQuestion(args.question, args.options ?? [])
|
|
365
|
-
},
|
|
366
|
-
}
|
|
367
|
-
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glob-dialect.mjs — glob 方言(TOOLS.md §17 — 2026-09-06)单一权威模块(CLI)。
|
|
3
|
+
*
|
|
4
|
+
* `{a,b}` brace expansion is handled BEFORE the sentinel/escape flow (braces must
|
|
5
|
+
* not be literal-escaped — a bare "star-star slash star .{js,txt}" pattern
|
|
6
|
+
* previously escaped to a literal that silently matched nothing). Unsupported
|
|
7
|
+
* extglob dialects (`?(x)`/`@(a|b)`/`+(x)`/`*(x)`/`!(x)`) and malformed braces
|
|
8
|
+
* (empty `{}`, unclosed `{`, nested `{a,{b,c}}`) are EXPLICIT errors — never a
|
|
9
|
+
* silent no-match. Exclude prefixes (`!pattern`) are the CALLER's job: the
|
|
10
|
+
* glob/grep tools whitespace-split the pattern expression (splitGlobPatterns —
|
|
11
|
+
* never inside a brace group) and pass the parts to compileGlobMatchers, which
|
|
12
|
+
* intersects includes and excludes.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from shared.mjs (2026-09-06 advisor #5 — shared.mjs exceeded the
|
|
15
|
+
* 500-line cap after §17); shared.mjs re-exports these symbols so all existing
|
|
16
|
+
* importers (system.mjs / ls filter / tests) keep their import paths.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const BRACE_OPEN = "\u0003", BRACE_SEP = "\u0004", BRACE_CLOSE = "\u0005"
|
|
20
|
+
const EXT_GLOB_RE = /[@?+*!]\(/
|
|
21
|
+
|
|
22
|
+
export const GLOB_EXTGLOB_ERROR =
|
|
23
|
+
"glob syntax error: unsupported extglob syntax — ?(x)/@(a|b)/+(x)/*(x)/!(x) are not supported; use {a,b} brace expansion or multiple space-separated patterns instead"
|
|
24
|
+
export const GLOB_EMPTY_BRACE_ERROR =
|
|
25
|
+
'glob syntax error: empty brace group "{}" — put at least one alternative between the braces (e.g. {js,txt})'
|
|
26
|
+
export const GLOB_UNCLOSED_BRACE_ERROR =
|
|
27
|
+
'glob syntax error: unclosed brace group — add a matching "}" (e.g. {js,txt})'
|
|
28
|
+
export const GLOB_NESTED_BRACE_ERROR =
|
|
29
|
+
"glob syntax error: nested brace groups are not supported (e.g. {a,{b,c}}) — use a flat alternative list (e.g. {a,b,c})"
|
|
30
|
+
|
|
31
|
+
function assertNoExtglob(pattern) {
|
|
32
|
+
if (EXT_GLOB_RE.test(pattern)) throw new Error(GLOB_EXTGLOB_ERROR)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Replace every {a,b,c} group with placeholder alternation markers. Runs BEFORE
|
|
36
|
+
* the sentinel/escape flow so the inserted alternation survives literal escaping. */
|
|
37
|
+
function expandBraces(pattern) {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const open = pattern.indexOf("{")
|
|
40
|
+
if (open === -1) return pattern
|
|
41
|
+
let close = -1
|
|
42
|
+
for (let j = open + 1; j < pattern.length; j++) {
|
|
43
|
+
if (pattern[j] === "{") throw new Error(GLOB_NESTED_BRACE_ERROR)
|
|
44
|
+
if (pattern[j] === "}") { close = j; break }
|
|
45
|
+
}
|
|
46
|
+
if (close === -1) throw new Error(GLOB_UNCLOSED_BRACE_ERROR)
|
|
47
|
+
const inner = pattern.slice(open + 1, close)
|
|
48
|
+
if (inner === "") throw new Error(GLOB_EMPTY_BRACE_ERROR)
|
|
49
|
+
const alts = inner.split(",").map((a) => a.trim())
|
|
50
|
+
pattern = pattern.slice(0, open) + BRACE_OPEN + alts.join(BRACE_SEP) + BRACE_CLOSE + pattern.slice(close + 1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Convert a single glob pattern to a RegExp (anchored full match). Supports the
|
|
55
|
+
* recursive "star-star" forms, single-star, "?", "[..]" character classes and
|
|
56
|
+
* {a,b} brace expansion. Malformed braces and unsupported extglob syntax throw
|
|
57
|
+
* explicit errors (no silent mismatch). */
|
|
58
|
+
export function globToRegex(pattern) {
|
|
59
|
+
pattern = String(pattern)
|
|
60
|
+
assertNoExtglob(pattern)
|
|
61
|
+
pattern = expandBraces(pattern)
|
|
62
|
+
// Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
|
|
63
|
+
// come from model output or the filesystem) — safe as **/ and ** placeholders.
|
|
64
|
+
const DS = "\u0001", DP = "\u0002"
|
|
65
|
+
const escaped = pattern
|
|
66
|
+
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
67
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
68
|
+
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
69
|
+
.replace(new RegExp(DS, "g"), "(?:.+/)?")
|
|
70
|
+
.replace(new RegExp(DP, "g"), ".*")
|
|
71
|
+
.replace(new RegExp(BRACE_OPEN, "g"), "(?:")
|
|
72
|
+
.replace(new RegExp(BRACE_SEP, "g"), "|")
|
|
73
|
+
.replace(new RegExp(BRACE_CLOSE, "g"), ")")
|
|
74
|
+
return new RegExp(`^${escaped}$`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whitespace-split a glob expression into parts WITHOUT splitting inside {a,b}
|
|
78
|
+
* brace groups and WITHOUT splitting literal spaces inside a single pattern
|
|
79
|
+
* (2026-09-06 audit F1 / advisor #4): a whitespace run is a pattern separator
|
|
80
|
+
* ONLY when the next non-space token starts an exclusion ("!" — the §17
|
|
81
|
+
* "include !exclude" form). So a BARE pattern like "docs/my file/*.md" and an
|
|
82
|
+
* exclude like "!docs/my file/**" each stay ONE part; the standard
|
|
83
|
+
* "js-pattern 空格 !test-pattern" form still splits into its two parts. */
|
|
84
|
+
export function splitGlobPatterns(expr) {
|
|
85
|
+
const s = String(expr ?? "").trim()
|
|
86
|
+
if (!s) return []
|
|
87
|
+
const parts = []
|
|
88
|
+
let depth = 0
|
|
89
|
+
let cur = ""
|
|
90
|
+
for (let i = 0; i < s.length; i++) {
|
|
91
|
+
const ch = s[i]
|
|
92
|
+
if (ch === "{") depth++
|
|
93
|
+
else if (ch === "}") depth = Math.max(0, depth - 1)
|
|
94
|
+
if (/\s/.test(ch) && depth === 0) {
|
|
95
|
+
let j = i
|
|
96
|
+
while (j < s.length && /\s/.test(s[j])) j++
|
|
97
|
+
if (s[j] === "!") {
|
|
98
|
+
if (cur) { parts.push(cur); cur = "" }
|
|
99
|
+
i = j - 1 // next loop iteration continues at the "!"
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
// literal space inside a pattern (or trailing) — keep it in the current part
|
|
103
|
+
cur += ch
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
cur += ch
|
|
107
|
+
}
|
|
108
|
+
if (cur) parts.push(cur)
|
|
109
|
+
return parts
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Compile whitespace-split pattern parts (see splitGlobPatterns) into a matcher:
|
|
113
|
+
* non-"!" parts are includes, leading-"!" parts are excludes — a rel path matches
|
|
114
|
+
* when it matches ≥1 include (or there are no includes) and no exclude. Every
|
|
115
|
+
* part runs the full brace/error dialect through globToRegex (excludes too). */
|
|
116
|
+
export function compileGlobMatchers(parts) {
|
|
117
|
+
const includes = []
|
|
118
|
+
const excludes = []
|
|
119
|
+
for (const p of parts) {
|
|
120
|
+
const part = String(p)
|
|
121
|
+
if (part.startsWith("!")) excludes.push(part.slice(1))
|
|
122
|
+
else if (part !== "") includes.push(part)
|
|
123
|
+
}
|
|
124
|
+
const include = includes.map((p) => globToRegex(p))
|
|
125
|
+
const exclude = excludes.map((p) => globToRegex(p))
|
|
126
|
+
const test = (relPath) =>
|
|
127
|
+
(include.length === 0 || include.some((re) => re.test(relPath))) &&
|
|
128
|
+
!exclude.some((re) => re.test(relPath))
|
|
129
|
+
return { include, exclude, test }
|
|
130
|
+
}
|
package/src/tools/glob.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
Find files by glob pattern. Returns matching paths
|
|
1
|
+
Find files by glob pattern. Returns matching paths (relative to the search path), sorted, capped at 1000. Use this to discover file structure; use grep to search file contents.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
|
-
- pattern (required): Glob pattern — supports
|
|
4
|
+
- pattern (required): Glob pattern — supports `**` (recursive), `**/`, `*` (within a segment), `?` (single char), `[..]` character classes, and `{a,b}` brace expansion (e.g. `**/*.{js,txt}` matches .js and .txt files at any depth). Space-separated multiple patterns with a leading `!` are EXCLUSIONS — `**/*.js !test/**` matches .js files except those under test/. Pattern matching is relative to `path`. A pattern containing a literal space is fine on its own (spaces only separate patterns when a `!` exclusion is present). An expression splits ONLY before `!`-exclusion tokens — to match several extensions in one include, use a `{a,b}` group (e.g. `**/*.{js,md}`); adjacent space-separated includes are not a supported form.
|
|
5
5
|
- path: Directory to search in (default cwd)
|
|
6
6
|
|
|
7
7
|
Notes:
|
|
8
|
+
- Invalid glob syntax is an EXPLICIT error — never a silent no-match. Unsupported extglob dialects (`?(x)`/`@(a|b)`/`+(x)`) and malformed braces (empty `{}`, unclosed `{`, nested `{a,{b,c}}`) return `glob error: ...` — use `{a,b}` or a space-separated `!exclude` pattern instead.
|
|
8
9
|
- Skips node_modules, .git, dist, build, .turbo, coverage
|
|
9
10
|
- Results capped at 1000 matches
|
|
10
|
-
- Use this to discover file structure; use grep to search file contents
|
|
11
11
|
- Prefer patterns with a literal anchor (extension or subdirectory) over bare wildcards
|
package/src/tools/grep.md
CHANGED
|
@@ -5,7 +5,7 @@ Search file contents with a regex. Returns matching lines as path:line: content.
|
|
|
5
5
|
Parameters:
|
|
6
6
|
- pattern (required): JavaScript regular expression, or a literal string when literal=true
|
|
7
7
|
- path: Directory or file to search (default cwd)
|
|
8
|
-
- glob: Only search files matching this glob (e.g. '*.mjs'
|
|
8
|
+
- glob: Only search files matching this glob — supports `**`, `*`, `?`, `[..]`, `{a,b}` braces and space-separated exclusion (`"**/*.js !test/**"` = .js files outside test/); e.g. '*.mjs'
|
|
9
9
|
- ignoreCase: Case-insensitive match (default false)
|
|
10
10
|
- literal: Literal string match — no regex interpretation (default false; use for strings with `. \` etc.)
|
|
11
11
|
- before: Lines of context to show before each match (grep -B). Default 0
|
package/src/tools/index.mjs
CHANGED
|
@@ -2,34 +2,35 @@
|
|
|
2
2
|
export { toOpenAISchema } from "./shared.mjs";
|
|
3
3
|
|
|
4
4
|
import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
|
|
5
|
-
import { readPdfTool } from "./pdf.mjs";
|
|
6
5
|
import { applyPatchTool, deleteTool } from "./patch.mjs";
|
|
7
|
-
import { bashTool
|
|
6
|
+
import { bashTool } from "./bash.mjs";
|
|
7
|
+
import { globTool, grepTool, lsTool } from "./search.mjs";
|
|
8
8
|
import { websearchTool, fetchTool } from "./web.mjs";
|
|
9
|
-
import { gitTool
|
|
9
|
+
import { gitTool } from "./git.mjs";
|
|
10
|
+
import { questionTool } from "./question.mjs";
|
|
10
11
|
import { checklistTool } from "./checklist.mjs";
|
|
11
12
|
import { lintTool } from "./linter.mjs";
|
|
12
13
|
import { lspTool } from "./lsp.mjs";
|
|
13
14
|
import { executeTool } from "./execute.mjs";
|
|
14
|
-
import { fileOpsTool, processTool, getCurrentTimeTool } from "./ops.mjs";
|
|
15
|
+
import { fileOpsTool, processTool, getCurrentTimeTool, waitForTool } from "./ops.mjs";
|
|
15
16
|
import { treeTool } from "./tree.mjs";
|
|
16
17
|
|
|
17
18
|
export const builtinTools = [
|
|
18
19
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
19
|
-
readImageTool,
|
|
20
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
20
21
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
21
22
|
gitTool, questionTool,
|
|
22
23
|
checklistTool, lintTool, lspTool, executeTool,
|
|
23
|
-
fileOpsTool, processTool, getCurrentTimeTool,
|
|
24
|
+
fileOpsTool, processTool, getCurrentTimeTool, waitForTool,
|
|
24
25
|
treeTool,
|
|
25
26
|
];
|
|
26
27
|
|
|
27
28
|
export {
|
|
28
29
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
29
|
-
readImageTool,
|
|
30
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
30
31
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
31
32
|
gitTool, questionTool,
|
|
32
33
|
checklistTool, lintTool, lspTool, executeTool,
|
|
33
|
-
fileOpsTool, processTool, getCurrentTimeTool,
|
|
34
|
+
fileOpsTool, processTool, getCurrentTimeTool, waitForTool,
|
|
34
35
|
treeTool,
|
|
35
36
|
};
|
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
|
|
4
|
-
* instead of shelling out to `bash` for the same operation
|
|
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,186 @@ 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
|
+
/** 评审池双载体(第 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() ?? []) {
|
|
187
|
+
if (pred(e) && !(e.done === true || e.status === "done")) return true
|
|
188
|
+
}
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
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
|
+
|
|
200
|
+
/** "consult done" — every consult session has drained (pending 0) or was
|
|
201
|
+
* explicitly stopped (its children were aborted). No sessions → true (vacuously). */
|
|
202
|
+
function consultAllDone(agent) {
|
|
203
|
+
const sessions = agent?._consultSessions
|
|
204
|
+
if (!sessions || sessions.size === 0) return true
|
|
205
|
+
for (const s of sessions.values()) {
|
|
206
|
+
if (!s.stopped && (s.pending ?? 0) > 0) return false
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** "port open:N" — 127.0.0.1 probe with a short connect timeout (never hangs a poll). */
|
|
212
|
+
function portOpenProbe(port) {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const socket = net.connect({ host: "127.0.0.1", port, timeout: 400 })
|
|
215
|
+
let settled = false
|
|
216
|
+
const finish = (v) => {
|
|
217
|
+
if (settled) return
|
|
218
|
+
settled = true
|
|
219
|
+
socket.destroy()
|
|
220
|
+
resolve(v)
|
|
221
|
+
}
|
|
222
|
+
socket.once("connect", () => finish(true))
|
|
223
|
+
socket.once("error", () => finish(false))
|
|
224
|
+
socket.once("timeout", () => finish(false))
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Real condition evaluator (production path) — returns a boolean; throws on an
|
|
229
|
+
* unsupported condition string. Polling is async (await setTimeout) — the event
|
|
230
|
+
* loop keeps running so background async work (subagents/consult) can settle
|
|
231
|
+
* between polls (评审 #1 — no blocking spin). */
|
|
232
|
+
export async function evaluateWaitForCondition(condition, ctx) {
|
|
233
|
+
const parsed = parseWaitForCondition(condition)
|
|
234
|
+
const agent = ctx?.agent
|
|
235
|
+
switch (parsed.kind) {
|
|
236
|
+
case "advisor":
|
|
237
|
+
// 第 10 批修正(§18.3 #2——修前恒真 0ms 秒过):判据 = 评审池真实态(双载体)——
|
|
238
|
+
// 不再读子代理池的 role==="advisor" 条目(该池永无此类条目);语义与 §11.2 未决
|
|
239
|
+
// 评审判定同源(advisorReviewPending)。
|
|
240
|
+
return advisorSettled(agent)
|
|
241
|
+
case "subagent":
|
|
242
|
+
return asyncEntryDone(agent, parsed.arg)
|
|
243
|
+
case "consult":
|
|
244
|
+
return consultAllDone(agent)
|
|
245
|
+
case "file":
|
|
246
|
+
return existsSync(resolveInCwd(ctx, parsed.arg))
|
|
247
|
+
case "port":
|
|
248
|
+
return await portOpenProbe(parsed.arg)
|
|
249
|
+
default:
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function evalConditionSource(condition, ctx) {
|
|
255
|
+
if (injectedConditionSource) return injectedConditionSource(condition, ctx)
|
|
256
|
+
return evaluateWaitForCondition(condition, ctx)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const waitForTool = {
|
|
260
|
+
name: "wait_for",
|
|
261
|
+
description: DESC("wait_for"),
|
|
262
|
+
parameters: {
|
|
263
|
+
type: "object",
|
|
264
|
+
properties: {
|
|
265
|
+
condition: { type: "string", description: 'Condition expression — "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"' },
|
|
266
|
+
interval_ms: { type: "integer", description: "Poll interval in ms (default 1000, floor 100)" },
|
|
267
|
+
timeout_ms: { type: "integer", description: "Overall timeout in ms (default 30000, cap 600000; config.json agent.waitForTimeoutMs overrides the default)" },
|
|
268
|
+
},
|
|
269
|
+
required: ["condition"],
|
|
270
|
+
},
|
|
271
|
+
readonly: true,
|
|
272
|
+
async execute(args, ctx = {}) {
|
|
273
|
+
const condition = typeof args?.condition === "string" ? args.condition.trim() : ""
|
|
274
|
+
if (!condition) return "Error: condition is required"
|
|
275
|
+
const intervalMs = Math.max(WAIT_FOR_MIN_INTERVAL_MS, Math.floor(args?.interval_ms ?? WAIT_FOR_DEFAULT_INTERVAL_MS))
|
|
276
|
+
const cfgTimeout = ctx?.agent?.config?.agent?.waitForTimeoutMs
|
|
277
|
+
const base = Number.isFinite(args?.timeout_ms)
|
|
278
|
+
? args.timeout_ms
|
|
279
|
+
: Number.isFinite(cfgTimeout) && cfgTimeout > 0 ? cfgTimeout : WAIT_FOR_DEFAULT_TIMEOUT_MS
|
|
280
|
+
const timeoutMs = Math.min(Math.max(1, Math.floor(base)), WAIT_FOR_MAX_TIMEOUT_MS)
|
|
281
|
+
const started = Date.now()
|
|
282
|
+
let polls = 0
|
|
283
|
+
for (;;) {
|
|
284
|
+
// Cancel/interrupt-safe exit(T-W7):Ctrl+C / 定向 abort 传播——不吞信号、
|
|
285
|
+
// 不把用户停变成工具错误(dispatch 对 aborted signal 原样再抛)。
|
|
286
|
+
if (ctx.signal?.aborted) throw new Error("wait_for: interrupted by abort signal")
|
|
287
|
+
polls++
|
|
288
|
+
const ok = await evalConditionSource(condition, ctx)
|
|
289
|
+
if (ok === true) {
|
|
290
|
+
return `wait_for: condition satisfied after ${Date.now() - started}ms (${polls} check${polls === 1 ? "" : "s"}): "${condition}"`
|
|
291
|
+
}
|
|
292
|
+
const remaining = started + timeoutMs - Date.now()
|
|
293
|
+
if (remaining <= 0) {
|
|
294
|
+
return `wait_for: timed out after ${timeoutMs}ms waiting for "${condition}" (${polls} checks — condition never became true)`
|
|
295
|
+
}
|
|
296
|
+
await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)))
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
}
|