@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.
- package/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
package/tools/bash.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESC,
|
|
3
|
+
sanitizeOutput,
|
|
4
|
+
truncate,
|
|
5
|
+
makeDecoder,
|
|
6
|
+
BASH_TIMEOUT_MS,
|
|
7
|
+
} from "./shared.mjs";
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
/** Maximum buffer size per stream (stdout / stderr) before truncation */
|
|
11
|
+
const MAX_STREAM_BUF = 2_000_000
|
|
12
|
+
|
|
13
|
+
/** Keep only output lines matching a regex (bash filter, case-insensitive). */
|
|
14
|
+
function applyLineFilter(output, filter) {
|
|
15
|
+
let re
|
|
16
|
+
try { re = new RegExp(filter, "i") } catch (e) { return `Error: filter regex invalid: ${e.message}` }
|
|
17
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
18
|
+
if (lines.length === 0) return `(no output lines matched filter "${filter}")`
|
|
19
|
+
return truncate(lines.join("\n"))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** POSIX-only constructs that cmd.exe reads literally (and thus breaks). Detected
|
|
23
|
+
* so the agent is told IMMEDIATELY instead of chasing a confusing failure — warning
|
|
24
|
+
* only, never a block (the approval layer is the real gate, same as destructive-command policy). */
|
|
25
|
+
function posixSyntaxHint(command) {
|
|
26
|
+
const hits = []
|
|
27
|
+
if (/\$\([^)]*\)/.test(command)) hits.push("$(...)")
|
|
28
|
+
if (command.includes("`")) hits.push("backtick")
|
|
29
|
+
if (/;\s+/.test(command)) hits.push("';' separators (cmd.exe needs && or newline)")
|
|
30
|
+
if (/2>\s*\/dev\/null|>\s*\/dev\/null|&>\s*\/dev\/null/.test(command)) hits.push("/dev/null (use NUL)")
|
|
31
|
+
if (/'.*'/.test(command)) hits.push("single quotes (cmd.exe doesn't group)")
|
|
32
|
+
if (/\$\{[A-Za-z_]/.test(command)) hits.push("${VAR} (use %VAR%)")
|
|
33
|
+
if (!hits.length) return ""
|
|
34
|
+
return "[hint: POSIX-only construct(s) detected — " + hits.join(", ") + ". Current shell is cmd.exe; these will NOT work. Use && / NUL / %VAR%, or use the execute tool (node) for complex logic]"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// ====================================================================
|
|
39
|
+
// bash — command execution with safety gates
|
|
40
|
+
// ====================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build environment for child process.
|
|
44
|
+
* Passes through all parent env vars, with non-interactive overrides (EDITOR/PAGER/TERM).
|
|
45
|
+
* Sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
|
|
46
|
+
*/
|
|
47
|
+
function buildBashEnv() {
|
|
48
|
+
const isWindows = process.platform === "win32"
|
|
49
|
+
return {
|
|
50
|
+
...process.env,
|
|
51
|
+
GIT_EDITOR: "true",
|
|
52
|
+
EDITOR: "true",
|
|
53
|
+
VISUAL: "true",
|
|
54
|
+
GIT_PAGER: "cat",
|
|
55
|
+
PAGER: "cat",
|
|
56
|
+
TERM: "dumb",
|
|
57
|
+
...(isWindows ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Platform-aware process tree kill.
|
|
63
|
+
* POSIX: kill process group (spawned with detached=true).
|
|
64
|
+
* Windows: taskkill /T to reach grandchildren (npm test's subprocesses, etc.).
|
|
65
|
+
*/
|
|
66
|
+
function killProcessTree(child) {
|
|
67
|
+
if (process.platform === "win32") {
|
|
68
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
69
|
+
} else {
|
|
70
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
71
|
+
try { child.kill("SIGKILL") } catch {} // fallback: kill directly if group kill fails
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect destructive git commands and auto-snapshot BEFORE execution.
|
|
77
|
+
* The scenario: the model writes uncommitted code, then (after breaking things)
|
|
78
|
+
* runs `git checkout -- .` / `git restore` / `git reset --hard` / `git clean -f` to
|
|
79
|
+
* roll back — which silently DESTROYS all uncommitted work. The pre-task checkpoint
|
|
80
|
+
* cannot help (it was taken before the code was written); only a snapshot taken
|
|
81
|
+
* immediately before the destructive command can.
|
|
82
|
+
*
|
|
83
|
+
* This is the defense-in-depth guard: a WIDE match that snapshots before the command
|
|
84
|
+
* runs — the command itself is NEVER blocked (a determined model bypasses text
|
|
85
|
+
* matching anyway; the real gate is the approval layer). A snapshot alone is not
|
|
86
|
+
* enough: the model may retry a variant that slips through the exact matcher
|
|
87
|
+
* (e.g. `git checkout HEAD -- .`), or run git outside the bash tool. The snapshot
|
|
88
|
+
* taken here survives all of those paths.
|
|
89
|
+
*
|
|
90
|
+
* Matching is intentionally WIDE (false positives are harmless — one extra snapshot;
|
|
91
|
+
* a missed match is a data-loss disaster).
|
|
92
|
+
*/
|
|
93
|
+
const GIT_DESTRUCTIVE_RE = /\bgit\s+(?:checkout\s+(?:[\w./-]+\s+)?--(?!\w)|checkout\s+\.|restore\s+(?!--help\b)(?!--staged\b(?!.*--worktree))|reset\s+--hard|clean\s+-(?=\S*f)(?!\S*n))/i
|
|
94
|
+
|
|
95
|
+
/** Returns { id, notice } when a snapshot was taken, else null. Never throws. */
|
|
96
|
+
async function gitGuardSnapshot(command, cwd) {
|
|
97
|
+
if (!GIT_DESTRUCTIVE_RE.test(command)) return null
|
|
98
|
+
try {
|
|
99
|
+
const { isGitRepo, createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
100
|
+
if (!isGitRepo(cwd)) return null
|
|
101
|
+
const cp = await createCheckpoint(cwd)
|
|
102
|
+
if (!cp) return null
|
|
103
|
+
return {
|
|
104
|
+
id: cp.id,
|
|
105
|
+
notice: `[auto-protection] Destructive git command detected — snapshot ${cp.id} created BEFORE execution (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked). If this command destroyed uncommitted work, restore it: checkpoint action=checkpoint checkpointAction=rewind checkpointId=${cp.id}`,
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
return null // protection is best-effort — never block the command
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
|
|
114
|
+
* enforce timeout and signal abort, return formatted result.
|
|
115
|
+
*
|
|
116
|
+
* Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
|
|
117
|
+
*/
|
|
118
|
+
function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
// Windows + default cmd: force UTF-8 code page for this child process (each spawn
|
|
121
|
+
// is an independent cmd, so chcp has no side effects on other shells) — otherwise
|
|
122
|
+
// cmd emits GBK bytes that the UTF-8 decoder turns into mojibake, and the model
|
|
123
|
+
// fights encoding errors instead of the actual command (reported UX on win11).
|
|
124
|
+
const effectiveCommand = process.platform === "win32" && !shell
|
|
125
|
+
? `chcp 65001 >nul && ${command}`
|
|
126
|
+
: command
|
|
127
|
+
// args MUST be an explicit [] — the two-arg spawn(cmd, options) form is
|
|
128
|
+
// DEP0190-deprecated (Node 24): the options object would be misread as args.
|
|
129
|
+
const child = spawn(effectiveCommand, [], {
|
|
130
|
+
cwd,
|
|
131
|
+
shell: shell ?? true,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
detached: process.platform !== "win32",
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
env: buildBashEnv(),
|
|
136
|
+
// 双保险(2026-09-05——裸 spawn 无 signal 教训:abort 只靠 killTree 手动杀——taskkill
|
|
137
|
+
// best-effort 可能失败/竞态)——Node signal option = 第一道(abort 时自动杀**直接**子
|
|
138
|
+
// 进程——cmd——语义保证);killTree 仍是必需兜底(孙进程握管道使 close 不触发——见
|
|
139
|
+
// L191 注释——spawn 级 kill 不达孙进程)
|
|
140
|
+
...(signal ? { signal } : {}),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const killTree = () => killProcessTree(child)
|
|
144
|
+
|
|
145
|
+
// Separate decoders for stdout / stderr (same encoding in practice, but separate
|
|
146
|
+
// collection is cleaner and lets the model locate errors via stderr quickly)
|
|
147
|
+
const outDecoder = makeDecoder()
|
|
148
|
+
const errDecoder = makeDecoder()
|
|
149
|
+
let outBuf = ""
|
|
150
|
+
let errBuf = ""
|
|
151
|
+
let truncatedNote = ""
|
|
152
|
+
|
|
153
|
+
child.stdout.on("data", (d) => {
|
|
154
|
+
const s = sanitizeOutput(outDecoder(d))
|
|
155
|
+
if (s) onOutput?.(s)
|
|
156
|
+
if (outBuf.length < MAX_STREAM_BUF) outBuf += s
|
|
157
|
+
else if (!truncatedNote) truncatedNote =
|
|
158
|
+
"\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
child.stderr.on("data", (d) => {
|
|
162
|
+
const s = sanitizeOutput(errDecoder(d))
|
|
163
|
+
if (s) onOutput?.(s)
|
|
164
|
+
if (errBuf.length < MAX_STREAM_BUF) errBuf += s
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const timer = setTimeout(killTree, timeout)
|
|
168
|
+
if (signal) signal.addEventListener("abort", killTree, { once: true })
|
|
169
|
+
|
|
170
|
+
let settled = false
|
|
171
|
+
let graceTimer = null
|
|
172
|
+
const finish = (result) => {
|
|
173
|
+
if (settled) return
|
|
174
|
+
settled = true
|
|
175
|
+
clearTimeout(timer)
|
|
176
|
+
clearTimeout(graceTimer)
|
|
177
|
+
// 2026-09-05(advisor 🟡#2):收尾移除 abort 监听器——ctx.signal 为长生命周期对象,
|
|
178
|
+
// 残留 once 监听器每次 bash 调用累积(闭包持有 child/输出缓冲直到 abort 才释放)
|
|
179
|
+
if (signal) signal.removeEventListener("abort", killTree)
|
|
180
|
+
resolve(result)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
child.on("error", (error) => {
|
|
184
|
+
// signal 双保险(2026-09-05):abort 时 Node signal option 杀直接子进程 → 本事件
|
|
185
|
+
// 以 AbortError 先触发——**跳过 finish**(不 settled)——exit 事件随后必到(实证
|
|
186
|
+
// 2ms 内)走 L195 分支带已收集输出收尾(user interrupted——不丢 partial);
|
|
187
|
+
// 非 abort 的真 spawn 错误(command not found 等)保持原分支
|
|
188
|
+
if (error.name === "AbortError") return
|
|
189
|
+
// spawn 失败形态(2026-09-18 工具失败判据同族残项批):进程未启动 ⇒ 无退出码(不伪造),
|
|
190
|
+
// 尾附独占状态位 `(spawn failed)`(判据族第三成员——`webview/lib.js toolFailureStatus`);
|
|
191
|
+
// 诊断行 `Command failed: <errno>` 与 `[stdout]:` 段原样保留(诊断面不入判据)。
|
|
192
|
+
const parts = [
|
|
193
|
+
`Command failed: ${error.message}`,
|
|
194
|
+
`[stdout]:\n${outBuf || "(empty)"}`,
|
|
195
|
+
"(spawn failed)",
|
|
196
|
+
]
|
|
197
|
+
finish(truncate(parts.join("\n\n")))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Shell exited. Normally 'close' follows within milliseconds — but a BACKGROUND
|
|
201
|
+
// child (start /b, &, nohup …) inherits the stdio pipes, so the pipe stays open
|
|
202
|
+
// and 'close' never fires: the tool would hang until the timeout. Resolve after
|
|
203
|
+
// a short grace period instead, returning whatever output was collected.
|
|
204
|
+
child.on("exit", (code, exitSignal) => {
|
|
205
|
+
graceTimer = setTimeout(() => {
|
|
206
|
+
const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
207
|
+
const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
208
|
+
if (outFlush) onOutput?.(outFlush)
|
|
209
|
+
if (errFlush) onOutput?.(errFlush)
|
|
210
|
+
const status = exitSignal ? `killed: ${exitSignal}` : `exit code ${code}`
|
|
211
|
+
const parts = [`[stdout]:\n${(outBuf + outFlush).trim() || "(empty)"}`]
|
|
212
|
+
if ((errBuf + errFlush).trim()) parts.push(`[stderr]:\n${(errBuf + errFlush).trim()}`)
|
|
213
|
+
parts.push(`(${status})`)
|
|
214
|
+
parts.push("[background] the shell exited but a child process still holds the output pipe — output may be incomplete; the process may still be running")
|
|
215
|
+
finish(truncate(parts.join("\n\n") + truncatedNote))
|
|
216
|
+
}, 1500)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
child.on("close", (code, exitSignal) => {
|
|
220
|
+
// Flush decoder tails — also push final bytes to panel
|
|
221
|
+
const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
222
|
+
const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
223
|
+
outBuf += outFlush
|
|
224
|
+
errBuf += errFlush
|
|
225
|
+
if (outFlush) onOutput?.(outFlush)
|
|
226
|
+
if (errFlush) onOutput?.(errFlush)
|
|
227
|
+
|
|
228
|
+
// Windows has no POSIX signals — check signal.aborted for user interrupts
|
|
229
|
+
const status = (exitSignal || signal?.aborted)
|
|
230
|
+
? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
|
|
231
|
+
: `exit code ${code}`
|
|
232
|
+
|
|
233
|
+
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
234
|
+
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
235
|
+
parts.push(`(${status})`)
|
|
236
|
+
finish(truncate(parts.join("\n\n") + truncatedNote))
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ====================================================================
|
|
242
|
+
// tool definitions
|
|
243
|
+
// ====================================================================
|
|
244
|
+
|
|
245
|
+
export const bashTool = {
|
|
246
|
+
name: "bash",
|
|
247
|
+
description: DESC("bash"),
|
|
248
|
+
parameters: {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: {
|
|
251
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
252
|
+
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
253
|
+
filter: { type: "string", description: "Optional: only return output lines matching this regex (case-insensitive)" },
|
|
254
|
+
},
|
|
255
|
+
required: ["command"],
|
|
256
|
+
},
|
|
257
|
+
readonly: false,
|
|
258
|
+
outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
|
|
259
|
+
async execute(args, ctx) {
|
|
260
|
+
// Git destructive commands are NEVER rejected — the model would bypass the
|
|
261
|
+
// guard anyway. Instead: snapshot every uncommitted file first, then ALLOW
|
|
262
|
+
// the command. The snapshot makes the rollback reversible (defense in depth:
|
|
263
|
+
// the wide matcher also covers variants like `git checkout HEAD -- .`).
|
|
264
|
+
const guard = await gitGuardSnapshot(args.command, ctx.cwd)
|
|
265
|
+
const result = await runBash(args.command, ctx.cwd, {
|
|
266
|
+
timeout: args.timeout ?? BASH_TIMEOUT_MS,
|
|
267
|
+
signal: ctx.signal,
|
|
268
|
+
onOutput: ctx.onOutput,
|
|
269
|
+
shell: ctx.agent?.config?.shell ?? null,
|
|
270
|
+
})
|
|
271
|
+
const filtered = args.filter ? applyLineFilter(result, args.filter) : result
|
|
272
|
+
const hint = posixSyntaxHint(args.command)
|
|
273
|
+
const body = guard ? `${guard.notice}\n\n${filtered}` : filtered
|
|
274
|
+
return hint ? `${hint}\n${body}` : body
|
|
275
|
+
},
|
|
276
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
|
|
3
|
+
* (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积";
|
|
4
|
+
* 2026-09-04 EDIT.md §6——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
|
|
5
|
+
*
|
|
6
|
+
* 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
|
|
7
|
+
* 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
|
|
8
|
+
* 全不写(原子性保留)。每条目独立按判定序(EDIT.md §4——分支 0 单行替换 / 零重叠→替换即删 /
|
|
9
|
+
* LCS / 空 new 显式报错;EDIT.md §5(约束——删行形态):省略 new_string = 删行。顶层 path
|
|
10
|
+
* (args.path)为无自带 path 条目的默认(2026-09-05 用户裁定——条目自带 path 优先——见
|
|
11
|
+
* EDIT.md §5 修订注)。
|
|
12
|
+
*/
|
|
13
|
+
import { readFile } from "node:fs/promises"
|
|
14
|
+
import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
|
|
15
|
+
// edit-batch ↔ edit-diff 循环引用(#69 后同型):两侧导入的都是函数声明(提升初始化),
|
|
16
|
+
// 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
|
|
17
|
+
// 写盘经单一写路径点(§2.13.5)——本档不再直调 fs;回执组装迁 composeEditReceipt(#69)。
|
|
18
|
+
import { writeThroughPath } from "./write-path.mjs"
|
|
19
|
+
// EDIT.md §6:批量条目判定+应用共用 edit-diff(EDIT.md §4 分支 0 单行替换 + 行级 LCS——零重叠→替换即删);
|
|
20
|
+
// EDIT.md §5:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
|
|
21
|
+
// #69:回执组装共用 edit-diff 的 composeEditReceipt(回执形态按端注入——缺省 = CLI 形态)。
|
|
22
|
+
import { assertEditArgsExclusive, validateEditEntry, computeEditEntry, splitLines, EMPTY_NEW_STRING_LINE, deleteTarget, composeEditReceipt } from "./edit-diff.mjs"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apply the `edits` array form: multi-file atomic replacement. Throws on any
|
|
26
|
+
* failure (atomic — nothing written). Returns the per-entry result text (joined).
|
|
27
|
+
*/
|
|
28
|
+
export async function applyEditBatch(args, ctx) {
|
|
29
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
30
|
+
throw new Error("edits must be a non-empty array of {path, old_string | line/startLine+endLine, new_string}")
|
|
31
|
+
}
|
|
32
|
+
assertEditArgsExclusive(args)
|
|
33
|
+
// 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
|
|
34
|
+
// 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
|
|
35
|
+
// 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
|
|
36
|
+
// (原实现每条都基于盘上原始内容计算、写盘循环后置,同文件后者覆盖前者 →
|
|
37
|
+
// 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
|
|
38
|
+
const groups = new Map() // abs → 每文件一条流水线
|
|
39
|
+
for (const e of args.edits) {
|
|
40
|
+
// 2026-09-05 用户裁定:顶层 path + edits 并存合法化——`e.path || args.path`(条目优先;
|
|
41
|
+
// 缺省入口仅在此兜底);两者皆无 → 原错误文本(措辞补顶层选项)。
|
|
42
|
+
const p = e.path || args.path
|
|
43
|
+
if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
|
|
44
|
+
// 前置校验(空 old / 非字符串 new——文本同 edit-diff 批量形态;读盘前校验)
|
|
45
|
+
validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
|
|
46
|
+
const abs = resolveInCwd(ctx, p)
|
|
47
|
+
let g = groups.get(abs)
|
|
48
|
+
if (!g) {
|
|
49
|
+
const raw = await readFile(abs, "utf8")
|
|
50
|
+
g = { abs, path: p, raw, content: normalizeEOL(raw), edits: [] }
|
|
51
|
+
groups.set(abs, g)
|
|
52
|
+
}
|
|
53
|
+
g.edits.push(e)
|
|
54
|
+
}
|
|
55
|
+
const prepared = [] // 每条目一条回显;同文件内按 args 序,跨文件按首次出现分组序(应用/回显语义均正确)
|
|
56
|
+
for (const g of groups.values()) {
|
|
57
|
+
g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
|
|
58
|
+
for (const e of g.edits) {
|
|
59
|
+
// 条目级判定+应用(edit-diff——EDIT.md §4 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 +
|
|
60
|
+
// >1000 行报错(diff 形态——D1 行号条目走 applyLineEdit,不经 LCS,无此上限))
|
|
61
|
+
const out = computeEditEntry(g.content, e, {
|
|
62
|
+
path: g.path,
|
|
63
|
+
absPath: g.abs,
|
|
64
|
+
abortPrefix: "edit aborted (atomic — no files written): ",
|
|
65
|
+
})
|
|
66
|
+
prepared.push({
|
|
67
|
+
g,
|
|
68
|
+
editStartLine: out.editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
|
|
69
|
+
lineShift: out.lineShift,
|
|
70
|
+
occurrences: out.occurrences,
|
|
71
|
+
note: out.note ?? null, // P15.11——空白差异自动落点标记(成功消息追加)
|
|
72
|
+
deleted: out.deleted ?? false, // 删行形态(EDIT.md §5)——结果文本用 Deleted 前缀
|
|
73
|
+
})
|
|
74
|
+
g.content = out.updated // 串行累积:下一条基于本条应用后的内容
|
|
75
|
+
g.netShift += out.lineShift
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
|
|
79
|
+
// recordWrite 每组一条合并快照:startLine = 组内**所有**编辑受影响行的最小值
|
|
80
|
+
// (#2,2026-09-01 交付评审尾巴——原实现取首条 = 调用序第一条,逆序条目时
|
|
81
|
+
// 护栏下界过高、受影响区内的 insert_after 被放行),shift = 全组行数差累积
|
|
82
|
+
// ——受影响区护栏覆盖组内所有编辑
|
|
83
|
+
for (const g of groups.values()) {
|
|
84
|
+
const startLine = Math.min(...prepared.filter((p) => p.g === g).map((p) => p.editStartLine))
|
|
85
|
+
// 单一写路径点:默认 = writeFile + 记账;端侧注入 ⇒ 编辑器径(§2.13.5)。
|
|
86
|
+
await writeThroughPath(g.abs, joinWithEol(normalizeEOL(g.content).split("\n"), g.raw), {
|
|
87
|
+
op: "write",
|
|
88
|
+
record: { type: "edit", startLine, shift: g.netShift },
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
const results = []
|
|
92
|
+
for (const p of prepared) {
|
|
93
|
+
// #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
|
|
94
|
+
// autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
|
|
95
|
+
const diff = gitDiffOne(ctx.cwd, p.g.abs)
|
|
96
|
+
const base = p.deleted
|
|
97
|
+
? `Deleted ${deleteTarget(p)} of ${p.g.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
|
|
98
|
+
: `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${p.note ? ` — ${p.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
|
|
99
|
+
results.push(await composeEditReceipt({
|
|
100
|
+
abs: p.g.abs,
|
|
101
|
+
path: p.g.path,
|
|
102
|
+
writeLine: p.editStartLine,
|
|
103
|
+
base,
|
|
104
|
+
deleted: p.deleted === true,
|
|
105
|
+
occurrences: p.occurrences,
|
|
106
|
+
note: p.note,
|
|
107
|
+
}))
|
|
108
|
+
}
|
|
109
|
+
return results.join("\n")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// 2026-09-08 edit 语义升级(EDIT.md §2/§3——D1 按行号改 / D2 模糊匹配,落点定死本模块;
|
|
114
|
+
// 条目判定接线在 edit-diff.mjs computeEditEntry——单形态/批量/ACP 桥三通道自动继承;
|
|
115
|
+
// 阶段 2 删行形态见 EDIT.md §5(约束)。以下均为纯函数(无 IO)——edit-diff.mjs 调用期导入
|
|
116
|
+
// (ESM 循环引用安全:两侧仅函数声明,提升初始化——同 file.mjs ↔ edit-diff.mjs 先例)。
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* D2 行级 normalize(EDIT.md §3——匹配档位;阶段 2 normalize 统一基准——双端同算法):
|
|
120
|
+
* ① 去首尾空白 + 去行尾空格;② tab → 2 空格;③ 引号**单遍逐字符映射**——ASCII 单引号 /
|
|
121
|
+
* 弯引号 ‘ ’ “ ” / 反引号 → 直双引号 "(评审 #2 定稿目标字符 = "——与 ASCII 单引号规则
|
|
122
|
+
* 合并为单遍映射,无顺序依赖——防两端分叉)。**不做行内 \s+ 折叠**——缩进/对齐是结构信息,
|
|
123
|
+
* 折叠会误匹配文字相似但结构不同的行(统一基准 5——CLI 从未折叠,防误配回归)。仅用于匹配
|
|
124
|
+
* 比较——替换永远用文件原文窗口。
|
|
125
|
+
*/
|
|
126
|
+
const QUOTE_TO_DOUBLE = {
|
|
127
|
+
"'": '"', "\u2018": '"', "\u2019": '"', "\u201c": '"', "\u201d": '"', "`": '"',
|
|
128
|
+
}
|
|
129
|
+
export function normalizeEditLine(line) {
|
|
130
|
+
let out = ""
|
|
131
|
+
for (const ch of line) {
|
|
132
|
+
if (ch === "\t") out += " "
|
|
133
|
+
else out += QUOTE_TO_DOUBLE[ch] ?? ch
|
|
134
|
+
}
|
|
135
|
+
return out.replace(/\s+$/g, "").trim()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** D2 模糊匹配阈值:行级 normalize 后逐行相等比例 ≥0.9 即匹配(评审 #4 定稿)。 */
|
|
139
|
+
export const FUZZY_MATCH_THRESHOLD = 0.9
|
|
140
|
+
/** D2 成功消息追加 note(双端同句——同 WHITESPACE_VARIANT_NOTE 机制)。 */
|
|
141
|
+
export const FUZZY_MATCH_NOTE = "applied via fuzzy match (≥90% of lines identical after whitespace/indent/quote normalization)"
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* D2 模糊匹配(P15.11 空白自动落点的推广——逐字/trim 等价都失败后的最后一档):
|
|
145
|
+
* 找文件中**唯一**窗口——行数与 old 相同、normalizeEditLine 后逐行相等比例 ≥90% →
|
|
146
|
+
* 返回 { actual }(actual = 文件窗口原文);多窗口达标 → null(歧义不猜——走 not-found
|
|
147
|
+
* 报错引导);old 含尾换行 → null(终止符语义边界——同 P15.11)。比例向上取整行数:
|
|
148
|
+
* 需要相等行数 = ceil(m × 0.9)(m=1 即 normalize 后全等——单行细微差异由此命中)。
|
|
149
|
+
*/
|
|
150
|
+
export function findFuzzyWindow(content, old) {
|
|
151
|
+
if (old.endsWith("\n")) return null
|
|
152
|
+
const oldLines = old.split("\n")
|
|
153
|
+
const m = oldLines.length
|
|
154
|
+
const fileLines = content.split("\n")
|
|
155
|
+
if (m === 0 || fileLines.length < m) return null
|
|
156
|
+
const norm = oldLines.map(normalizeEditLine)
|
|
157
|
+
const need = Math.ceil(m * FUZZY_MATCH_THRESHOLD)
|
|
158
|
+
let hit = null
|
|
159
|
+
for (let i = 0; i + m <= fileLines.length; i++) {
|
|
160
|
+
let eq = 0
|
|
161
|
+
for (let j = 0; j < m; j++) {
|
|
162
|
+
if (normalizeEditLine(fileLines[i + j]) === norm[j]) eq++
|
|
163
|
+
}
|
|
164
|
+
if (eq < need) continue
|
|
165
|
+
const actual = fileLines.slice(i, i + m).join("\n")
|
|
166
|
+
if (actual === old) continue // 逐字已匹配——occurrences=0 前提下不会发生
|
|
167
|
+
if (hit) return null // 多窗口达标 → 歧义 → 不猜
|
|
168
|
+
hit = { actual }
|
|
169
|
+
}
|
|
170
|
+
return hit
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* D1 按行号改(EDIT.md §2/§5——F1):entry 带 line(单行)或 startLine/endLine(1-based
|
|
175
|
+
* 闭区间)→ 直接按行号替换该行/行范围为 new_string(无需 old_string——互斥校验在
|
|
176
|
+
* validateEditEntry)。阶段 2 删行形态(EDIT.md §5——裁定 A):**省略 new_string =
|
|
177
|
+
* 删除该行/范围**(意图有界——删哪行是显式声明);显式空串 `new_string: ""` →
|
|
178
|
+
* EMPTY_NEW_STRING_LINE 显式报错(模板生成 new_string 但落空 ≠ 删行意图——防误删)。
|
|
179
|
+
* 内容域 = normalizeEOL 后 LF(与 computeEditEntry 同域);尾随换行随原文件保持(删到
|
|
180
|
+
* 文件为空 → ""——无剩余行即无终止符)。行号越界 → 明确报错。原子性由调用方保证
|
|
181
|
+
* (本函数纯计算)。
|
|
182
|
+
* 返回 { updated, editStartLine, lineShift, occurrences, note, deleted? }(同 computeEditEntry 形态)。
|
|
183
|
+
*/
|
|
184
|
+
export function applyLineEdit(content, entry, opts = {}) {
|
|
185
|
+
const prefix = opts.abortPrefix ?? ""
|
|
186
|
+
if (entry.new_string === "") throw new Error(prefix + EMPTY_NEW_STRING_LINE)
|
|
187
|
+
const lines = splitLines(content)
|
|
188
|
+
const start = entry.line ?? entry.startLine
|
|
189
|
+
const end = entry.line ?? entry.endLine
|
|
190
|
+
if (start > lines.length || end > lines.length) {
|
|
191
|
+
throw new Error(prefix + `line ${end > lines.length ? end : start} out of range — ${opts.path ?? "file"} has ${lines.length} line(s)`)
|
|
192
|
+
}
|
|
193
|
+
const removed = end - start + 1
|
|
194
|
+
if (entry.new_string === undefined) {
|
|
195
|
+
// 删行/删范围(EDIT.md §5)——省略 new_string:受影响行整体移除
|
|
196
|
+
const remaining = [...lines.slice(0, start - 1), ...lines.slice(end)]
|
|
197
|
+
const updated = remaining.join("\n") + (remaining.length > 0 && content.endsWith("\n") ? "\n" : "")
|
|
198
|
+
return { updated, editStartLine: start, lineShift: -removed, occurrences: 1, note: null, deleted: true }
|
|
199
|
+
}
|
|
200
|
+
const newLines = splitLines(entry.new_string)
|
|
201
|
+
const updated = [...lines.slice(0, start - 1), ...newLines, ...lines.slice(end)].join("\n") +
|
|
202
|
+
(content.endsWith("\n") ? "\n" : "")
|
|
203
|
+
return { updated, editStartLine: start, lineShift: newLines.length - removed, occurrences: 1, note: null }
|
|
204
|
+
}
|