@thincoder/core 0.9.1 → 0.9.3

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 (75) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/advisor/loop.mjs +2 -2
  5. package/advisor/run.mjs +1 -1
  6. package/agent/completion.mjs +3 -1
  7. package/agent/family-tools.mjs +24 -11
  8. package/agent/helpers.mjs +11 -2
  9. package/agent/run-stages.mjs +27 -5
  10. package/agent/setup-reminders.mjs +67 -11
  11. package/agent/setup.mjs +6 -0
  12. package/agent/write-gate.mjs +5 -5
  13. package/agent-tools/advisor-async.mjs +4 -4
  14. package/agent-tools/advisor.mjs +3 -3
  15. package/agent-tools/async-discard.mjs +1 -1
  16. package/agent-tools/audit-block.mjs +106 -0
  17. package/agent-tools/batch-lifecycle.mjs +301 -0
  18. package/agent-tools/batch-segment.mjs +16 -263
  19. package/agent-tools/batch-skeleton.mjs +156 -0
  20. package/agent-tools/batch.mjs +410 -0
  21. package/agent-tools/context.mjs +174 -0
  22. package/agent-tools/eng.mjs +4 -0
  23. package/agent-tools/goal.mjs +7 -0
  24. package/agent-tools/parent-channel.mjs +18 -1
  25. package/agent-tools/plan.mjs +39 -5
  26. package/agent-tools/read-history.mjs +122 -24
  27. package/agent-tools/settings.mjs +4 -2
  28. package/agent-tools/subagent-async.mjs +3 -3
  29. package/agent-tools/subagent-spawn.mjs +29 -101
  30. package/agent-tools/task.mjs +11 -0
  31. package/agent-tools.mjs +9 -2
  32. package/agent.mjs +10 -4
  33. package/config.mjs +1 -1
  34. package/context.mjs +66 -121
  35. package/fts-text.mjs +41 -0
  36. package/generate-title.mjs +6 -6
  37. package/i18n.mjs +4 -4
  38. package/ledger-cmd.mjs +30 -7
  39. package/ledger-db.mjs +22 -2
  40. package/ledger-executors.mjs +103 -0
  41. package/ledger-surface.mjs +15 -8
  42. package/ledger.mjs +15 -4
  43. package/manifest.mjs +172 -60
  44. package/memory/core.mjs +4 -18
  45. package/memory/schema.mjs +4 -11
  46. package/package.json +22 -1
  47. package/prompts/advisor-design.md +1 -1
  48. package/prompts/advisor-round2.md +1 -1
  49. package/prompts/advisor-round3.md +1 -1
  50. package/prompts/common.md +3 -3
  51. package/prompts/discipline-engineering.md +17 -2
  52. package/prompts/persona-eng-coder.md +1 -1
  53. package/prompts/persona-eng-designer.md +1 -1
  54. package/prompts/persona-engineering.md +9 -6
  55. package/session-gc.mjs +129 -74
  56. package/session-index-build.mjs +298 -0
  57. package/session-index-cmd.mjs +61 -0
  58. package/session-index-pass.mjs +95 -0
  59. package/session-index-query.mjs +102 -0
  60. package/session-index.mjs +285 -0
  61. package/session-lifecycle.mjs +18 -5
  62. package/session-slots-manifest.mjs +55 -3
  63. package/session-stale.mjs +247 -0
  64. package/token-window.mjs +188 -0
  65. package/tools/bash.mjs +4 -15
  66. package/tools/execute.mjs +5 -13
  67. package/tools/git-checkpoint.mjs +1 -1
  68. package/tools/git-ext.mjs +23 -20
  69. package/tools/git-run.mjs +141 -0
  70. package/tools/git.mjs +56 -45
  71. package/tools/index.mjs +3 -1
  72. package/tools/process-tree.mjs +20 -0
  73. package/tools/shared.mjs +8 -4
  74. package/traces/trace-cleanup.mjs +109 -0
  75. package/traces/trace-store.mjs +32 -36
@@ -0,0 +1,247 @@
1
+ /**
2
+ * session-stale.mjs — sessions 存量治理(SESSION.md §6.17 · STARTUP-LATENCY 批,2026-09-21)。
3
+ *
4
+ * 职责 = 分组 / 三合取判据 / 回收(rename 进回收批)/ 清运(超期批)/ 有界逐扫。
5
+ * 全链异步(D-SE34):本档零同步扫描——目录与逐文件面一律 `node:fs/promises`
6
+ * (同步 fs 面只留单条目 `existsSync`——本档不使用;bounded 单文件面归既有档)。
7
+ *
8
+ * 可清组 ⟺ 三合取(D-SE35;组 = sessions 根下同一 40 位 cwd 哈希前缀的全文件集合):
9
+ * ③ 安全窗:组内最新 mtime < now − STALE_SAFETY_WINDOW_MS(7 天——异常现场窗口);
10
+ * ① 无活属主:manifest `slotSessions` 全量经入口一次探测束 + `ownerState` 三态——
11
+ * 活 / 未知(探测失败 / 缺行)⇒ 保留;无 manifest ⇒ 无认领面 ⇒ 该条自动满足;
12
+ * ② 内容面不可达或无内容:**T1** = 组内可读数据文件 `cwd` 全部不存在于磁盘(至少读到一份);
13
+ * **T2** = 组内无任何数据文件。数据文件不可读 / 无 `cwd` 字段 ⇒ T1 不成立 ⇒ 保留(fail-safe)。
14
+ * 求值序 = ③ → ① → ②(任一步不通过 ⇒ 保留并短路);选取顺序 = 组最新 mtime 升序(最旧优先)。
15
+ * 预算(D-SE40)= **过 ③ 进 ① 即耗 1**(含 ① 面 manifest 读)——每 pass 总评估 ≤ `STALE_SWEEP_LIMIT`,
16
+ * 达上限即停、余量下一 pass 继续;③ 短路组零预算(未读盘),① / ② 短路组已耗 1(不退);
17
+ * 显式面 limit = Infinity(全量)。
18
+ * 90 天冷 cwd 判据**不在本档**(cwd 存活组唯一出口——保持显式命令面;实现住 session-gc.mjs)。
19
+ *
20
+ * 回收(D-SE36):判据窗 7 天 + 回收保留 7 天 ⇒ 不可逆删除最早 = 最后写入 + 14 天;
21
+ * 回收根 = 当次 sessions 根 `dir` 派生的同级 `sessions-trash/<批次时间戳>/`(sessions 根外——
22
+ * 不参与扫描 / 不入发现面;`dir` 注入缝因此覆盖回收批);逐文件 rename(同卷元数据操作)——
23
+ * 单文件失败 / 回收根不可写 = 跳过并计数,**不 unlink 兜底**(零误删;部分移动态安全)。
24
+ */
25
+ import { readdir, readFile, stat, rename, mkdir, rm } from "node:fs/promises"
26
+ import { join, dirname } from "node:path"
27
+ import { ownerPid } from "./session-slots-manifest.mjs"
28
+ import { probeOwnersAsync, ownerState } from "./process-probe.mjs"
29
+
30
+ export const STALE_SAFETY_WINDOW_MS = 7 * 24 * 3600 * 1000
31
+ export const STALE_SWEEP_LIMIT = 500
32
+ export const TRASH_RETENTION_MS = 7 * 24 * 3600 * 1000
33
+ export const TRASH_DIR_NAME = "sessions-trash"
34
+
35
+ /** 测试注入缝(`_traceHooks` 同惯例):rename / mkdir / rm / stat / readdir 计数与失败注入。
36
+ * 生产永远走真实实现(本对象只在测试内 `Object.assign` + 还原)。 */
37
+ export const _staleHooks = {
38
+ readdir: (dir) => readdir(dir),
39
+ readFile: (p) => readFile(p, "utf8"),
40
+ stat: (p) => stat(p),
41
+ rename: (from, to) => rename(from, to),
42
+ mkdir: (dir) => mkdir(dir, { recursive: true }),
43
+ rm: (dir) => rm(dir, { recursive: true, force: true }),
44
+ }
45
+
46
+ const GROUP_RE = /^([0-9a-f]{40})\.json(?:\.|$)/
47
+ const DATA_SUFFIX_RE = /^(?:\.\d+)?$/
48
+ const STAT_CHUNK = 128
49
+
50
+ /** 回收根(D-SE36)= 当次 sessions 根的同级 `sessions-trash`。 */
51
+ export function trashRootFor(dir) { return join(dirname(dir), TRASH_DIR_NAME) }
52
+
53
+ /** 分组(纯函数):entries(名串或 {name, mtimeMs})→ Map<hash, {hash, prefix, files,
54
+ * dataFiles, newestMtimeMs}>。只认 40 位哈希前缀族(非本族名一律不碰);数据文件 =
55
+ * 裸 `{hash}.json` 与 `{hash}.json.N`(记录存储 sidecar `.N.d` 与残留随组处理,不计入)。 */
56
+ export function groupSessionEntries(entries) {
57
+ const groups = new Map()
58
+ for (const e of entries) {
59
+ const name = typeof e === "string" ? e : e?.name
60
+ if (typeof name !== "string") continue
61
+ const m = GROUP_RE.exec(name)
62
+ if (!m) continue
63
+ const hash = m[1]
64
+ const prefix = `${hash}.json`
65
+ let g = groups.get(hash)
66
+ if (!g) { g = { hash, prefix, files: [], dataFiles: [], newestMtimeMs: 0 }; groups.set(hash, g) }
67
+ g.files.push(name)
68
+ if (DATA_SUFFIX_RE.test(name.slice(prefix.length))) g.dataFiles.push(name)
69
+ if (typeof e !== "string") g.newestMtimeMs = Math.max(g.newestMtimeMs, Number(e?.mtimeMs) || 0)
70
+ }
71
+ return groups
72
+ }
73
+
74
+ /** 三合取判定(**纯函数**——零 IO;入参 = 已取证事实):
75
+ * facts = { now, newestMtimeMs, ownerStates, dataFiles: [{readable, cwdExists}] }
76
+ * → { cleanable, reason }(reason 面:window / live-owner / unknown-owner / no-content /
77
+ * cwd-gone / cwd-exists / unreadable)。 */
78
+ export function judgeStaleGroup(facts) {
79
+ const { now, newestMtimeMs = 0, ownerStates = [], dataFiles = [] } = facts
80
+ if (!(newestMtimeMs < now - STALE_SAFETY_WINDOW_MS)) return { cleanable: false, reason: "window" }
81
+ if (ownerStates.includes("alive")) return { cleanable: false, reason: "live-owner" }
82
+ if (ownerStates.includes("unknown")) return { cleanable: false, reason: "unknown-owner" }
83
+ if (!dataFiles.length) return { cleanable: true, reason: "no-content" } // T2
84
+ for (const f of dataFiles) { // T1:可读数据文件 cwd 全不在盘(至少一份;不可读 ⇒ fail-safe 保留)
85
+ if (!f.readable || f.cwdExists == null) return { cleanable: false, reason: "unreadable" }
86
+ if (f.cwdExists) return { cleanable: false, reason: "cwd-exists" }
87
+ }
88
+ return { cleanable: true, reason: "cwd-gone" }
89
+ }
90
+
91
+ /** 目录快照 → 组集合:本族名逐条目 `stat` 异步(分块——非阻塞、不进启动关键路径)。
92
+ * names 缺省 = 自行 readdir(dir 注入缝;pass 编排可传共享快照——readdir 恰一次)。
93
+ * `hashes` 在场 ⇒ **按前缀预筛后再 stat**(单组重校验面:逐组调用不随存量放大——
94
+ * `--confirm <hash>` / 端侧命令面的逐组重校验成本 = O(该组文件数))。 */
95
+ async function snapshotGroups(dir, names, hashes = null) {
96
+ if (!names) {
97
+ try { names = await _staleHooks.readdir(dir) } catch { return new Map() }
98
+ }
99
+ const want = hashes ? hashes.map((h) => `${h}.json`) : null
100
+ const hit = names.map((n) => (typeof n === "string" ? n : n?.name))
101
+ .filter((n) => typeof n === "string" && GROUP_RE.test(n) && (!want || want.some((p) => n === p || n.startsWith(p + "."))))
102
+ const facts = []
103
+ for (let i = 0; i < hit.length; i += STAT_CHUNK) {
104
+ const chunk = await Promise.all(hit.slice(i, i + STAT_CHUNK).map(async (name) => {
105
+ try { return { name, mtimeMs: (await _staleHooks.stat(join(dir, name))).mtimeMs } } catch { return null }
106
+ }))
107
+ for (const f of chunk) if (f) facts.push(f)
108
+ }
109
+ return groupSessionEntries(facts)
110
+ }
111
+
112
+ /** ① 面入参:manifest `slotSessions` 属主 pid(manifest 缺失 / 损坏 ⇒ 空 ⇒ 该条自动满足)。 */
113
+ async function manifestOwners(dir, prefix) {
114
+ let m
115
+ try { m = JSON.parse(await _staleHooks.readFile(join(dir, `${prefix}.manifest`))) } catch { return [] }
116
+ const pids = []
117
+ for (const owner of Object.values(m?.slotSessions ?? {})) {
118
+ const pid = typeof owner === "string" && owner ? ownerPid(owner) : 0
119
+ if (pid) pids.push(pid)
120
+ }
121
+ return pids
122
+ }
123
+
124
+ /** cwd 是否在盘(异步单条目);非 ENOENT(权限 / 网络错)⇒ 不可判 ⇒ 视为存在(保守向)。 */
125
+ async function pathExists(p) {
126
+ try { await _staleHooks.stat(p); return true } catch (e) { return e?.code !== "ENOENT" }
127
+ }
128
+
129
+ /** ② 面取证:逐数据文件(可读 ∧ cwd 字段在盘)。不可读 / 无 `cwd` ⇒ readable:false(fail-safe)。 */
130
+ async function dataFileFacts(dir, group) {
131
+ const out = []
132
+ for (const name of group.dataFiles) {
133
+ let data = null
134
+ try { data = JSON.parse(await _staleHooks.readFile(join(dir, name))) } catch { data = null }
135
+ const cwd = typeof data?.cwd === "string" && data.cwd ? data.cwd : null
136
+ out.push(cwd ? { readable: true, cwdExists: await pathExists(cwd) } : { readable: false, cwdExists: null })
137
+ }
138
+ return out
139
+ }
140
+
141
+ /**
142
+ * 候选列举(显式面 / 自动面共用):③(廉价——短路组零预算)→ ①(**过 ③ 进 ① 即耗 1 预算**——
143
+ * 该组 manifest 读与(若过 ① 的)② 内容读均在此预算内)→ ②(内容判据)。
144
+ * 预算闸 = 过 ③ 进 ① 的组数 ≤ `limit`(达上限即停——升序余量下一 pass 继续;显式面传 Infinity)。
145
+ * 返回 { candidates, evaluated, groups }——`evaluated` = 过 ③ 进 ① 的组数(= 预算消耗组数,
146
+ * 含 ① 面 manifest 读——D-SE40)。
147
+ * `hashes` 在场 ⇒ 只判这些 hash(单组重校验面——`deleteStaleCwd` 用)。
148
+ */
149
+ export async function listStaleCwds({ dir, entries = null, now = Date.now(), probeFn = probeOwnersAsync, limit = STALE_SWEEP_LIMIT, hashes = null } = {}) {
150
+ const groups = await snapshotGroups(dir, entries, hashes)
151
+ const all = [...groups.values()]
152
+ const wanted = hashes ? all.filter((g) => hashes.includes(g.hash)) : all
153
+ const ordered = wanted.sort((a, b) => a.newestMtimeMs - b.newestMtimeMs || (a.hash < b.hash ? -1 : 1))
154
+ const ownersByHash = new Map()
155
+ const allPids = new Set()
156
+ let evaluated = 0
157
+ for (const g of ordered) {
158
+ if (!(g.newestMtimeMs < now - STALE_SAFETY_WINDOW_MS)) continue // ③ 短路(零预算——未读盘)
159
+ if (evaluated >= limit) break // 预算闸(过 ③ 进 ① 即耗——达上限即停;余量下一 pass 继续)
160
+ evaluated++ // 该组 ① manifest 读 +(若过 ① 的)② 内容读均在此预算内(D-SE40)
161
+ const pids = await manifestOwners(dir, g.prefix)
162
+ ownersByHash.set(g.hash, pids)
163
+ for (const pid of pids) allPids.add(pid)
164
+ }
165
+ const bundle = allPids.size ? await probeFn([...allPids]) : { aliveSet: null, cmds: null }
166
+ const candidates = []
167
+ for (const g of ordered) {
168
+ const pids = ownersByHash.get(g.hash)
169
+ if (!pids) continue // ③ 短路 / 预算外(未进 ①)
170
+ const states = pids.map((pid) => ownerState(pid, bundle))
171
+ if (states.some((s) => s !== "dead")) continue // ① 短路(活 / 未知 ⇒ 保留——预算已耗,不退)
172
+ const verdict = judgeStaleGroup({ now, newestMtimeMs: g.newestMtimeMs, ownerStates: states, dataFiles: await dataFileFacts(dir, g) })
173
+ if (verdict.cleanable) {
174
+ candidates.push({ hash: g.hash, prefix: g.prefix, reason: verdict.reason, dataFiles: g.dataFiles.length, newestMtime: g.newestMtimeMs, files: g.files })
175
+ }
176
+ }
177
+ return { candidates, evaluated, groups: ordered.length }
178
+ }
179
+
180
+ /** 回收序(§6.17 落地顺序):残留 / 端标记 → manifest → 数据文件。 */
181
+ function recycleRank(name, prefix) {
182
+ const rest = name.slice(prefix.length)
183
+ if (rest === ".manifest") return 1
184
+ if (rest === "" || /^\.\d+$/.test(rest) || /^\.\d+\.d$/.test(rest)) return 2
185
+ return 0
186
+ }
187
+
188
+ /** 逐组回收(组粒度——非事务):逐文件 rename 进回收批。单文件失败 / 回收根不可写 = 跳过并计数,
189
+ * 不 unlink 兜底(原文件零删除);返回 { moved, skipped, batch }(零移动 ⇒ batch:null)。 */
190
+ export async function recycleGroup(group, { dir, now = Date.now() } = {}) {
191
+ const batch = join(trashRootFor(dir), String(now))
192
+ const out = { moved: [], skipped: [], batch: null }
193
+ const ordered = [...group.files].sort((a, b) => recycleRank(a, group.prefix) - recycleRank(b, group.prefix))
194
+ for (const name of ordered) {
195
+ try {
196
+ if (!out.batch) { await _staleHooks.mkdir(batch); out.batch = batch }
197
+ await _staleHooks.rename(join(dir, name), join(batch, name))
198
+ out.moved.push(name)
199
+ } catch { out.skipped.push(name) }
200
+ }
201
+ if (!out.moved.length) out.batch = null
202
+ return out
203
+ }
204
+
205
+ /** 单组回收(显式命令面 / 端侧命令面):重校验(TOCTOU——单组重判三合取)→ 回收。
206
+ * `entries` = 目录名快照(可选——命令面循环可传同一快照免逐组全目录 readdir;
207
+ * mtime 事实同自动面 pass 模型,**① 面 manifest 读与探测束仍逐组新鲜**——变活组由 ① 拦截)。 */
208
+ export async function deleteStaleCwd(hash, { dir, now = Date.now(), probeFn = probeOwnersAsync, entries = null } = {}) {
209
+ const { candidates } = await listStaleCwds({ dir, now, probeFn, limit: Infinity, hashes: [hash], entries })
210
+ const target = candidates.find((c) => c.hash === hash)
211
+ if (!target) return { ok: false, reason: "not-cold", deleted: [] } // 期间变活 / 出窗 ⇒ 拒绝(零删除)
212
+ const r = await recycleGroup(target, { dir, now })
213
+ if (!r.moved.length) return { ok: false, reason: "recycle-failed", deleted: [], skipped: r.skipped }
214
+ return { ok: true, reason: target.reason, deleted: r.moved, skipped: r.skipped, batch: r.batch }
215
+ }
216
+
217
+ /** 清运(D-SE36):超期回收批整删(批名 = 批次时间戳,13 位 epoch ms ⇒ 到期可比)。失败静默跳过
218
+ * (不误删在期批);后续 pass 重试。非批次名(外来目录)一律不碰。 */
219
+ export async function purgeTrash({ dir, now = Date.now(), retentionMs = TRASH_RETENTION_MS } = {}) {
220
+ const root = trashRootFor(dir)
221
+ const out = { purged: [], failed: [] }
222
+ let batches
223
+ try { batches = await _staleHooks.readdir(root) } catch { return out } // 无回收根 ⇒ 无事可做
224
+ for (const batch of batches) {
225
+ if (!/^\d{13}$/.test(batch)) continue // 批名形态锁(本档写入口 = String(Date.now()))——外来目录不碰
226
+ const ts = Number(batch)
227
+ if (!(ts + retentionMs <= now)) continue
228
+ try { await _staleHooks.rm(join(root, batch)); out.purged.push(batch) } catch { out.failed.push(batch) }
229
+ }
230
+ return out
231
+ }
232
+
233
+ /** 有界清立面 pass(**自动面**——绑启动**窗外延迟拍**(`scheduleSessionGC`),每进程一次):
234
+ * 候选(**过 ③ 进 ① ≤ `limit`**——含 ① 面 manifest 读)→ 逐组回收 → 超期批清运。
235
+ * 全链异步;失败由调用面静默(本函数不抛——逐文件失败已计数)。 */
236
+ export async function sweepStale({ dir, entries = null, now = Date.now(), probeFn = probeOwnersAsync, limit = STALE_SWEEP_LIMIT } = {}) {
237
+ const { candidates, evaluated, groups } = await listStaleCwds({ dir, entries, now, probeFn, limit })
238
+ let moved = 0
239
+ let skipped = 0
240
+ for (const c of candidates) {
241
+ const r = await recycleGroup(c, { dir, now })
242
+ moved += r.moved.length
243
+ skipped += r.skipped.length
244
+ }
245
+ const purged = await purgeTrash({ dir, now })
246
+ return { groups, evaluated, candidates: candidates.length, moved, skipped, purged: purged.purged.length, purgeFailed: purged.failed.length }
247
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * token-window.mjs — token 计量 / 窗口 / 切割族(自 `context.mjs` **逐字迁出**)+ 上下文用量单源
3
+ * (CONTEXT-COMPACTION.md §6.16.3 / §6.16.4 / §6.16.8 · context-tool 批 2026-09-21)。
4
+ * 迁出三族(语义零改——原档越限在即,本批按 §6.16.8 拆出):① 计量面(IMAGE_TOKEN_ESTIMATE + `estimateTokens`);
5
+ * ② 尾族(KEEP_HEAD 头注 + TAIL_BUDGET_FRACTION / SUMMARY_TOKEN_ESTIMATE / TAIL_FLOOR_MESSAGES + `keepTailSize` + `tailBudgetTokens`);
6
+ * ③ 切分与配对修复族(`splitHistory` + `repairedTailStart` + `tightenTailByBudget`)。
7
+ * 新增族:`contextUsage`(与 `compressIfNeeded` 的 token 判定**同一函数**)· `historyPercent`(与两端状态行**同式**)·
8
+ * `collectStaleToolOutputs`(陈旧工具输出合格集)+ 门槛常量 `PRUNE_MIN_TOKENS`(**与合格集同住本档** ⇒ 消费方单向取用、零回指)。
9
+ * 依赖面 = `provider/rate.mjs`(`estimateText`)+ `config.mjs`(`providerSpec` / `resolveCompactThreshold`)——**零 import 环**。
10
+ */
11
+ import { estimateText } from "./provider/rate.mjs"
12
+ import { providerSpec, resolveCompactThreshold } from "./config.mjs"
13
+
14
+ const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
15
+
16
+ /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
17
+ export function estimateTokens(messages) {
18
+ let tokens = 0
19
+ for (const m of messages) {
20
+ if (typeof m.content === "string") tokens += estimateText(m.content)
21
+ else if (Array.isArray(m.content)) {
22
+ for (const part of m.content) {
23
+ if (part.type === "text") tokens += estimateText(part.text)
24
+ else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
25
+ }
26
+ }
27
+ if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
28
+ for (const tc of m.tool_calls ?? []) {
29
+ tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
30
+ }
31
+ }
32
+ return tokens
33
+ }
34
+
35
+ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
36
+ // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
37
+ // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
38
+ // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
39
+ // at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
40
+ // × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
41
+ // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
42
+ const TAIL_BUDGET_FRACTION = 0.15
43
+ const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
44
+ const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
45
+ export function keepTailSize(provider, historyLen) {
46
+ // provider is guaranteed at every call site (runAgent always builds one); providerSpec
47
+ // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
48
+ // because the 40% history cap still bounds the tail. providers[].context override
49
+ // (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
50
+ const ctxWindow = providerSpec(provider).context
51
+ return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
52
+ }
53
+ // §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
54
+ export function tailBudgetTokens(provider) {
55
+ return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
56
+ }
57
+
58
+ /**
59
+ * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
60
+ * head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
61
+ * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
62
+ * `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
63
+ * forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
64
+ * the 40% cap made it < 10 — short history).
65
+ */
66
+ export function splitHistory(history, keepTail, budgetTokens = null) {
67
+ if (history.length <= KEEP_HEAD + keepTail + 1) return null
68
+ let headEnd = KEEP_HEAD
69
+ // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
70
+ // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
71
+ if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
72
+ while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
73
+ }
74
+ const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
75
+ if (candidate <= headEnd) return null
76
+ let tailStart = candidate
77
+ // §6.4④ D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
78
+ if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
79
+ const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
80
+ if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
81
+ }
82
+ return { headEnd, tailStart }
83
+ }
84
+
85
+ /**
86
+ * D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
87
+ * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
88
+ * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
89
+ * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
90
+ */
91
+ function repairedTailStart(history, headEnd, tailStart) {
92
+ const tailToolIds = new Set()
93
+ for (let i = tailStart; i < history.length; i++) {
94
+ if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
95
+ }
96
+ for (let i = tailStart - 1; i > headEnd; i--) {
97
+ const m = history[i]
98
+ if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
99
+ tailStart = i
100
+ break
101
+ }
102
+ }
103
+ while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
104
+ tailStart++
105
+ }
106
+ return tailStart
107
+ }
108
+
109
+ /**
110
+ * §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
111
+ * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
112
+ * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
113
+ * middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
114
+ * No fit before the floor → keep the floor, accept the overrun.
115
+ */
116
+ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
117
+ const suffixTokens = new Array(history.length + 1)
118
+ suffixTokens[history.length] = 0
119
+ for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
120
+ if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
121
+ for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
122
+ if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
123
+ }
124
+ return floorStart
125
+ }
126
+
127
+ /**
128
+ * 上下文用量单源(§6.16.4「取自哪里」)——与 `compressIfNeeded` 的 token 判定**同一函数**:实测优先 =
129
+ * `_lastPromptTokens + 增量(history.slice(_usageAtLen))`;无实测 = `estimateTokens(history) + system + tools`(固定开销面经 extras 随带)。
130
+ * `threshold` = 该回合检查所用值(`agent._ctxBasis.threshold`——核 `agent.mjs` / VSC `checkAndCompact` 各一处暂存);缺省回退 `resolveCompactThreshold`(退化面①)。
131
+ * `window` = `providerSpec(agent.provider).context`。
132
+ * @returns {{basis:"measured"|"estimated", total, history, system, tools, overhead, threshold, window}}
133
+ */
134
+ export function contextUsage(agent, extras = {}) {
135
+ const history = Array.isArray(agent?.history) ? agent.history : []
136
+ const system = extras.systemPrompt ? estimateText(extras.systemPrompt) : 0
137
+ const tools = extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0
138
+ const overhead = system + tools
139
+ const historyTokens = estimateTokens(history)
140
+ const measured = agent?._lastPromptTokens != null
141
+ return {
142
+ basis: measured ? "measured" : "estimated",
143
+ total: measured
144
+ ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
145
+ : historyTokens + overhead,
146
+ history: historyTokens,
147
+ system,
148
+ tools,
149
+ overhead,
150
+ threshold: agent?._ctxBasis?.threshold
151
+ ?? resolveCompactThreshold(agent?.config?.agent?.compactThreshold, agent?.provider).value,
152
+ window: providerSpec(agent?.provider).context,
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 状态行百分比(§6.16.4「与状态行 ctx% 的单源关系」)——与 CLI 状态行(`render-frame.mjs` 式)与 VSC `ctxPercentForHistory` **同式**;
158
+ * stats 报出的就是用户看到的那个百分比(不新增第三口径);空历史 ⇒ 0(状态行的 `ctxPct > 0` 显示门在端侧)。
159
+ */
160
+ export function historyPercent(history, provider) {
161
+ return Math.round((estimateTokens(history ?? []) / providerSpec(provider).context) * 100)
162
+ }
163
+
164
+ /** 陈旧工具输出门槛(§6.16.3 判据③):低于门槛的替换是净增 token(stub 本身有长度)——常量与合格集同住本档。 */
165
+ export const PRUNE_MIN_TOKENS = 200
166
+
167
+ /**
168
+ * 陈旧工具输出合格集(§6.16.3——三条件合取,单源实现):① `role === "tool"`;② **保护尾之外**(复用 `splitHistory` 的同一 `tailStart`——不新增第二处切割判据);③ 估算 ≥ `PRUNE_MIN_TOKENS`。
169
+ * 返回逐条计数面(prune 回执 + stats 两处消费):`indexes` 合格索引(升序)· `tokens` 合格集估算和 · `candidates` 全史 tool 结果数 · `tailKept` 保护尾内保留数 · `belowMin` 门槛下跳过数 · `tailCount` 保护尾消息数。
170
+ * **短历史(无中段)⇒ 合格集为空**(no-op——§6.16.3 边界)。
171
+ */
172
+ export function collectStaleToolOutputs(history, provider) {
173
+ const h = Array.isArray(history) ? history : []
174
+ const split = splitHistory(h, keepTailSize(provider, h.length), tailBudgetTokens(provider))
175
+ // 无中段(短历史)⇒ 可摘要面为空 ⇒ **全史即保护尾**(tailStart = 0——合格集必为空,同压缩面 no-op)
176
+ const tailStart = split ? split.tailStart : 0
177
+ const r = { indexes: [], tokens: 0, candidates: 0, tailKept: 0, belowMin: 0, tailCount: h.length - tailStart }
178
+ for (let i = 0; i < h.length; i++) {
179
+ if (h[i]?.role !== "tool") continue
180
+ r.candidates++
181
+ if (i >= tailStart) { r.tailKept++; continue }
182
+ const est = estimateTokens([h[i]])
183
+ if (est < PRUNE_MIN_TOKENS) { r.belowMin++; continue }
184
+ r.indexes.push(i)
185
+ r.tokens += est
186
+ }
187
+ return r
188
+ }
package/tools/bash.mjs CHANGED
@@ -5,7 +5,10 @@ import {
5
5
  makeDecoder,
6
6
  BASH_TIMEOUT_MS,
7
7
  } from "./shared.mjs";
8
- import { spawn, execFileSync } from "node:child_process";
8
+ import { spawn } from "node:child_process";
9
+ // 树杀单源(台账 #208② · TOOLS.md §6.14 落位表行 2):本地副本删除——改用 `./process-tree.mjs`
10
+ // (行为逐字同:win32 `taskkill /T /F` · POSIX 组杀 + 直杀兜底)。
11
+ import { killProcessTree } from "./process-tree.mjs";
9
12
 
10
13
  /** Maximum buffer size per stream (stdout / stderr) before truncation */
11
14
  const MAX_STREAM_BUF = 2_000_000
@@ -58,20 +61,6 @@ function buildBashEnv() {
58
61
  }
59
62
  }
60
63
 
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
64
  /**
76
65
  * Detect destructive git commands and auto-snapshot BEFORE execution.
77
66
  * The scenario: the model writes uncommitted code, then (after breaking things)
package/tools/execute.mjs CHANGED
@@ -24,9 +24,10 @@
24
24
  * filter — return only output lines matching this regex (case-insensitive)
25
25
  * timeoutMs — timeout (default 30s, max 600000ms)
26
26
  */
27
- import { spawn, execFileSync } from "node:child_process"
27
+ import { spawn } from "node:child_process"
28
28
  import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
+ import { killProcessTree } from "./process-tree.mjs"
30
31
 
31
32
  const MAX_SCRIPT = 50_000
32
33
  const MAX_OUTPUT = 50_000
@@ -58,18 +59,9 @@ function applyFilter(output, filter) {
58
59
  }
59
60
  }
60
61
 
61
- /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
62
- * Timeout/abort must reach grandchildren: a script that spawned children keeps the
63
- * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
64
- * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
65
- function killProcessTree(child) {
66
- if (process.platform === "win32") {
67
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
- } else {
69
- try { process.kill(-child.pid, "SIGKILL") } catch {}
70
- try { child.kill("SIGKILL") } catch {}
71
- }
72
- }
62
+ /** 树杀单源 = `tools/process-tree.mjs`(TOOLS.md §6.14 落位表行 2:自本档抽出以断
63
+ * `shared git-run execute shared` 环);**再导出面保留**(`test/tool-seams.test.mjs:26` 消费,
64
+ * `configureProcessTreeKill` 缺省径零改)。 */
73
65
  export { killProcessTree }
74
66
 
75
67
  // ─── 树杀注入缝(#57——「树杀实现按端注入」,形态参 §2.13.5 注入缝)───────────────
@@ -25,7 +25,7 @@ export async function lazyClearIfCommitted(cwd) {
25
25
  try {
26
26
  const cps = await listCheckpoints(cwd)
27
27
  if (cps.length === 0) return
28
- const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
28
+ const headSec = await runGit(cwd, ["log", "-1", "--format=%ct"])
29
29
  const headMs = Number.parseInt(headSec, 10) * 1000
30
30
  if (!Number.isFinite(headMs) || headMs <= 0) return
31
31
  const newest = cps[0] // listCheckpoints returns newest → oldest
package/tools/git-ext.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * 供 git.mjs 核心 action 复用——500 行硬限拆分)。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
5
5
  */
6
6
  import { runGit, truncate } from "./shared.mjs"
7
- import { execFileSync } from "node:child_process"
7
+ import { spawnGit, gitTimeoutNote } from "./git-run.mjs"
8
8
 
9
9
  /** Keep only output lines matching a regex (git filter, case-insensitive). */
10
10
  export function filterLines(output, filter) {
@@ -19,12 +19,15 @@ export function filterLines(output, filter) {
19
19
  }
20
20
 
21
21
  /** Run git and report failure (stderr + exit code) instead of swallowing it.
22
- * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
23
- export function runGitStrict(cwd, cmdArgs, config = []) {
22
+ * Used by write ops (commit/push/rm) where a silent "" would masquerade as success.
23
+ * §6.14:体改**异步薄壳**(`spawnGit` 单点——加固 env + 两档超时 + 树杀);`{ok, out, err}` 形逐字保留
24
+ * + 超时分支(`err` = 单源超时注——可辨性不靠 stderr 文本猜)。 */
25
+ export async function runGitStrict(cwd, cmdArgs, config = []) {
24
26
  try {
25
- const out = execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
27
+ const out = (await spawnGit(cwd, [...config, ...cmdArgs])).trim().replace(/\r/g, "")
26
28
  return { ok: true, out }
27
29
  } catch (e) {
30
+ if (e.timedOut) return { ok: false, out: String(e.stdout || "").trim(), err: gitTimeoutNote(e.timeoutMs) }
28
31
  return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
29
32
  }
30
33
  }
@@ -36,7 +39,7 @@ export function validateRef(ref, what = "git ref") {
36
39
  }
37
40
 
38
41
  /** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
39
- * Values are execFileSync array args (no shell injection) — still reject newlines/empty. */
42
+ * Values are spawn array args (no shell injection) — still reject newlines/empty. */
40
43
  export function gitConfigArgs(config) {
41
44
  if (config == null) return []
42
45
  if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
@@ -70,11 +73,11 @@ export async function executeExtAction(args, ctx) {
70
73
  if (!args.remote) return "Error: clone requires remote (URL or local path)"
71
74
  const cmdArgs = ["clone", args.remote]
72
75
  if (args.path) cmdArgs.push(args.path)
73
- const r = runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
76
+ const r = await runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
74
77
  return r.ok ? truncate(r.out || `Cloned ${args.remote}`) : truncate(`git clone failed: ${r.err || r.out}`)
75
78
  }
76
79
  case "init": {
77
- const r = runGitStrict(ctx.cwd, ["init"])
80
+ const r = await runGitStrict(ctx.cwd, ["init"])
78
81
  return r.ok ? truncate(r.out || "Initialized empty git repository") : truncate(`git init failed: ${r.err || r.out}`)
79
82
  }
80
83
  case "rebase": {
@@ -89,21 +92,21 @@ export async function executeExtAction(args, ctx) {
89
92
  if (sub === "abort") cmdArgs.push("--abort")
90
93
  else if (sub === "continue") cmdArgs.push("--continue")
91
94
  else { if (!args.ref) return "Error: rebase requires ref (branch/commit to rebase onto)"; cmdArgs.push(validateRef(args.ref)) }
92
- const r = runGitStrict(ctx.cwd, cmdArgs)
95
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
93
96
  return r.ok ? truncate(snap + (r.out || `Rebase ${sub} complete`)) : truncate(snap + `git rebase failed: ${r.err || r.out} — use rebaseAction=abort to abort`)
94
97
  }
95
98
  case "remote": {
96
99
  const sub = args.remoteAction ?? "list"
97
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
100
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
98
101
  if (!args.remote) return `Error: remote ${sub} requires remote (name)`
99
102
  validateRef(args.remote, "remote name")
100
103
  if (sub === "add" || sub === "set-url") {
101
104
  if (!args.remoteUrl) return `Error: remote ${sub} requires remoteUrl`
102
- const r = runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
105
+ const r = await runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
103
106
  return r.ok ? `Remote ${args.remote} ${sub === "add" ? "added" : "URL set"}` : truncate(`git remote ${sub} failed: ${r.err || r.out}`)
104
107
  }
105
108
  if (sub === "remove") {
106
- const r = runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
109
+ const r = await runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
107
110
  return r.ok ? `Remote ${args.remote} removed` : truncate(`git remote remove failed: ${r.err || r.out}`)
108
111
  }
109
112
  return "Error: remote requires remoteAction — use: list | add | remove | set-url"
@@ -113,7 +116,7 @@ export async function executeExtAction(args, ctx) {
113
116
  // dryRun (-n) is a preview: no deletion, no snapshot.
114
117
  const snap = args.dryRun ? "" : await snapshotBefore(ctx, "clean")
115
118
  const cmdArgs = ["clean", args.dryRun ? "-n" : "-f", "-d"]
116
- const r = runGitStrict(ctx.cwd, cmdArgs)
119
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
117
120
  return r.ok ? truncate(snap + (r.out || (args.dryRun ? "Nothing to clean (dry run)" : "Clean complete"))) : truncate(snap + `git clean failed: ${r.err || r.out}`)
118
121
  }
119
122
  case "switch": {
@@ -122,28 +125,28 @@ export async function executeExtAction(args, ctx) {
122
125
  const cmdArgs = ["switch"]
123
126
  if (args.create) cmdArgs.push("-c")
124
127
  cmdArgs.push(args.name)
125
- const r = runGitStrict(ctx.cwd, cmdArgs)
128
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
126
129
  return r.ok ? truncate(r.out || `Switched to branch ${args.name}`) : truncate(`git switch failed: ${r.err || r.out}`)
127
130
  }
128
131
  case "apply": {
129
132
  // Apply a patch — non-destructive (fails cleanly on conflict, applies nothing).
130
133
  if (!args.path) return "Error: apply requires path (patch file)"
131
- const r = runGitStrict(ctx.cwd, ["apply", "--", args.path])
134
+ const r = await runGitStrict(ctx.cwd, ["apply", "--", args.path])
132
135
  return r.ok ? truncate(r.out || `Applied ${args.path}`) : truncate(`git apply failed: ${r.err || r.out}`)
133
136
  }
134
137
  case "worktree": {
135
138
  const sub = args.worktreeAction ?? "list"
136
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
139
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
137
140
  if (sub === "add") {
138
141
  if (!args.path) return "Error: worktree add requires path (new worktree directory)"
139
142
  const cmdArgs = ["worktree", "add", args.path]
140
143
  if (args.ref) cmdArgs.push(validateRef(args.ref))
141
- const r = runGitStrict(ctx.cwd, cmdArgs)
144
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
142
145
  return r.ok ? truncate(r.out || `Worktree added at ${args.path}`) : truncate(`git worktree add failed: ${r.err || r.out}`)
143
146
  }
144
147
  if (sub === "remove") {
145
148
  if (!args.path) return "Error: worktree remove requires path"
146
- const r = runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
149
+ const r = await runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
147
150
  return r.ok ? truncate(r.out || `Worktree removed: ${args.path}`) : truncate(`git worktree remove failed: ${r.err || r.out}`)
148
151
  }
149
152
  return "Error: worktree requires worktreeAction — use: list | add | remove"
@@ -154,17 +157,17 @@ export async function executeExtAction(args, ctx) {
154
157
  const cmdArgs = ["archive", "--format=tar", "-o", args.path]
155
158
  if (args.ref) cmdArgs.push(validateRef(args.ref))
156
159
  else cmdArgs.push("HEAD")
157
- const r = runGitStrict(ctx.cwd, cmdArgs)
160
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
158
161
  return r.ok ? truncate(r.out || `Archived ${args.ref ?? "HEAD"} to ${args.path}`) : truncate(`git archive failed: ${r.err || r.out}`)
159
162
  }
160
163
  case "blame": {
161
164
  if (!args.path) return "Error: blame requires path (file)"
162
- const out = runGit(ctx.cwd, ["blame", "--", args.path])
165
+ const out = await runGit(ctx.cwd, ["blame", "--", args.path])
163
166
  return truncate(out || `(no blame output for ${args.path})`)
164
167
  }
165
168
  case "mv": {
166
169
  if (!args.path || !args.dest) return "Error: mv requires path (source) and dest (destination)"
167
- const r = runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
170
+ const r = await runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
168
171
  return r.ok ? truncate(r.out || `Moved ${args.path} → ${args.dest}`) : truncate(`git mv failed: ${r.err || r.out}`)
169
172
  }
170
173
  default: