@thincoder/core 0.10.7 → 0.10.8

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 (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/advisor/compaction.mjs +1 -1
  3. package/advisor/run.mjs +1 -1
  4. package/agent/chat-call.mjs +40 -27
  5. package/agent/child-marks.mjs +1 -1
  6. package/agent/dispatch-gates.mjs +7 -4
  7. package/agent/dispatch-run.mjs +1 -0
  8. package/agent/family-tools.mjs +11 -6
  9. package/agent/helpers.mjs +6 -4
  10. package/agent/image-heal.mjs +65 -0
  11. package/agent/light-round.mjs +4 -4
  12. package/agent/live-beat.mjs +4 -4
  13. package/agent/record-results.mjs +9 -1
  14. package/agent/run-stages.mjs +2 -1
  15. package/agent/setup-reminders.mjs +14 -13
  16. package/agent/setup.mjs +62 -46
  17. package/agent/spawn-child.mjs +1 -1
  18. package/agent/subagent-event.mjs +1 -1
  19. package/agent/suspension.mjs +2 -1
  20. package/agent/timers.mjs +8 -8
  21. package/agent/turn-loop.mjs +1 -1
  22. package/agent-tools/advisor.mjs +1 -1
  23. package/agent-tools/async-discard.mjs +40 -5
  24. package/agent-tools/async-settle.mjs +3 -1
  25. package/agent-tools/audit-block.mjs +1 -1
  26. package/agent-tools/batch-paths.mjs +20 -20
  27. package/agent-tools/checkpoint.mjs +1 -1
  28. package/agent-tools/design-slots.mjs +44 -5
  29. package/agent-tools/doc-check.mjs +37 -0
  30. package/agent-tools/eng.mjs +5 -3
  31. package/agent-tools/manifest.mjs +18 -18
  32. package/agent-tools/parent-channel.mjs +3 -3
  33. package/agent-tools/review-facts.mjs +2 -2
  34. package/agent-tools/settings.mjs +1 -1
  35. package/agent-tools/spawn-gates.mjs +8 -6
  36. package/agent-tools/ssh-session.mjs +439 -0
  37. package/agent-tools/ssh-transport.mjs +311 -0
  38. package/agent-tools/subagent-scheduler.mjs +6 -5
  39. package/agent-tools/subagent.mjs +7 -5
  40. package/agent-tools/task.mjs +1 -1
  41. package/agent-tools/timer.mjs +6 -0
  42. package/agent-tools/zero-write-watch.mjs +2 -2
  43. package/agent-tools.mjs +3 -2
  44. package/attachments.mjs +4 -4
  45. package/browser/cdp.mjs +2 -2
  46. package/browser/launch.mjs +50 -4
  47. package/browser/snapshot.mjs +1 -1
  48. package/config-io.mjs +7 -2
  49. package/config-watch.mjs +2 -2
  50. package/config.mjs +7 -0
  51. package/declaration.mjs +5 -1
  52. package/doc-check/anchors.mjs +100 -0
  53. package/doc-check/index.mjs +61 -0
  54. package/doc-check/width.mjs +131 -0
  55. package/file-refs.mjs +6 -6
  56. package/generate-title.mjs +1 -1
  57. package/hooks.mjs +1 -1
  58. package/i18n.mjs +11 -1
  59. package/ledger-cmd.mjs +4 -5
  60. package/ledger-db.mjs +100 -21
  61. package/ledger-migrate.mjs +3 -3
  62. package/ledger-read.mjs +6 -7
  63. package/ledger-tools.mjs +24 -9
  64. package/ledger.mjs +23 -54
  65. package/log.mjs +17 -3
  66. package/manifest-discovery.mjs +21 -18
  67. package/manifest-schema.mjs +2 -13
  68. package/manifest.mjs +37 -22
  69. package/mcp/transport-stdio.mjs +1 -1
  70. package/mcp.mjs +2 -2
  71. package/memory/core.mjs +1 -1
  72. package/memory/scan.mjs +1 -1
  73. package/memory/sweep.mjs +1 -1
  74. package/model-specs.mjs +7 -7
  75. package/notify-policy.mjs +6 -6
  76. package/package.json +2 -1
  77. package/permission.mjs +14 -0
  78. package/prompts/advisor-design.md +43 -13
  79. package/prompts/advisor-round1.md +25 -5
  80. package/prompts/advisor-round2.md +17 -4
  81. package/prompts/advisor-round3.md +17 -4
  82. package/prompts/common.md +60 -6
  83. package/prompts/consult-base.md +8 -2
  84. package/prompts/discipline-engineering.md +11 -10
  85. package/prompts/discipline-normal.md +22 -14
  86. package/prompts/persona-coder.md +1 -1
  87. package/prompts/persona-eng-designer.md +8 -3
  88. package/prompts/persona-engineering.md +21 -5
  89. package/prompts/persona-normal.md +4 -1
  90. package/provider/errors.mjs +8 -0
  91. package/provider/google.mjs +3 -0
  92. package/provider/normalize.mjs +42 -12
  93. package/provider/responses-request.mjs +1 -1
  94. package/provider/responses.mjs +2 -2
  95. package/provider/wait-status.mjs +1 -1
  96. package/provider-flows.mjs +1 -1
  97. package/queued.mjs +2 -2
  98. package/rules.mjs +4 -4
  99. package/session-gc.mjs +1 -1
  100. package/session-lifecycle.mjs +1 -1
  101. package/session-slot-write.mjs +2 -2
  102. package/session-slots-manifest.mjs +1 -1
  103. package/session.mjs +1 -1
  104. package/shell-candidates.mjs +2 -2
  105. package/shell-identity.mjs +1 -1
  106. package/team.mjs +61 -4
  107. package/think-off.mjs +3 -3
  108. package/token-ttl.mjs +1 -1
  109. package/tool-docs/bash.md +1 -1
  110. package/tool-docs/browser.md +1 -1
  111. package/tool-docs/doc_check.md +31 -0
  112. package/tool-docs/manifest.md +14 -2
  113. package/tool-docs/ssh.md +34 -0
  114. package/tool-docs/subagent.md +2 -0
  115. package/tool-docs/timer.md +2 -1
  116. package/tools/browser.mjs +1 -1
  117. package/tools/execute.mjs +1 -1
  118. package/tools/git-run.mjs +2 -2
  119. package/tools/git.mjs +8 -8
  120. package/tools/index.mjs +3 -0
  121. package/tools/ops.mjs +1 -1
  122. package/tools/process-tree.mjs +2 -2
  123. package/tools/ssh-askpass.mjs +35 -0
  124. package/tools/ssh.mjs +92 -0
  125. package/vision-reader.mjs +4 -4
@@ -0,0 +1,311 @@
1
+ /**
2
+ * ssh-transport.mjs — SSH 传输面单点(askpass 链 ∥ 子进程 ∥ 沉降 ∥ 遮蔽 ∥ 输出帽)。
3
+ * 设计 = `docs/core/design/TOOLS.md` §6.22(D-TO17/D-TO18 · 台账 #1436)。
4
+ *
5
+ * 拆档由 = 机制档触 500 顾问线(§6.22 落点表「触 500 顾问线 ⇒ 实施轮先审拆分」)——本档收**运行面**
6
+ * (与会话语义无关的传输原语),`ssh-session.mjs` 收**会话面**(池 / 生命周期 / 动作)。条目形
7
+ * (entry)由 `ssh-session.mjs` 定义并持有;本档只经其字段工作(`buf` / `mask` / `_waiters` /
8
+ * `promptRe` / `state` / `child` / `logStream` / `_notify` / `lastDataAt` / `bytes`)。
9
+ *
10
+ * - askpass 链:包装档(Win `.cmd` ∥ POSIX `.sh` 0700)→ `tools/ssh-askpass.mjs` → stdout;秘密只经
11
+ * ssh 子进程环境 `TC_SSH_SECRET`(零入参 / 零落盘 / 零回显;环境经 `nodeChildEnv` 单点补 Electron 旗标)。
12
+ * - 沉降:静默窗 800 ms ∥ 会话提示符正则命中 ⇒ 就绪;硬超时 10 s ⇒ 部分输出 + 可读句(不静默挂起)。
13
+ * - 遮蔽:写入的秘密对**捕获面与日志面**做字面移除(跨 chunk 切分:保留 `len-1` 尾缓冲)。
14
+ * - 失败分类族(auth ∥ unreachable ∥ hostkey)照服务端 `src/sandbox/ssh.mjs` 同源判据。
15
+ * 模块图:→ config.mjs(configDir 缺省)· tools/shared.mjs(解码 ∥ 清洗)· tools/node-child.mjs
16
+ * (nodeChildEnv)——叶子向、无环(本档不 import `ssh-session.mjs`)。
17
+ */
18
+ import { chmodSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
19
+ import { spawn } from "node:child_process"
20
+ import { fileURLToPath } from "node:url"
21
+ import { configDir } from "../config.mjs"
22
+ import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
23
+ import { nodeChildEnv } from "../tools/node-child.mjs"
24
+
25
+ /** 静默窗(§6.22 ③):到点无新输出 ⇒ 判定就绪。 */
26
+ export const SSH_SETTLE_MS = 800
27
+ /** 硬超时(§6.22 ③/⑥):到点返回部分输出 + 可读句。 */
28
+ export const SSH_TIMEOUT_MS = 10_000
29
+ /** 单次返回帽(§6.22 ⑥):保尾 + 截断注记;全量恒在 log。 */
30
+ export const SSH_RETURN_CAP = 8000
31
+ /** 秘密环境变量名(§6.22 ②「秘密传递」行)。 */
32
+ export const SECRET_ENV = "TC_SSH_SECRET"
33
+ /** 内存缓冲保留量(仅覆盖返回帽;全量恒在 log)。 */
34
+ export const BUF_KEEP_CHARS = 64 * 1024
35
+
36
+ /** 测试缝(单元测试沙箱——生产从不调用;`_setBgTaskLogDirForTest` 同式:一缝管三落点
37
+ * (log 目录 ∥ askpass 包装目录 ∥ 预填档)。`prefixArgs` = 夹具替身用(`<node> <夹具脚本> <ssh 参数…>`)。 */
38
+ let _sshBinary = null
39
+ let _sshArgPrefix = []
40
+ let _sshConfigDir = null
41
+ export function _setSshBinaryForTest(cmd, prefixArgs = []) {
42
+ _sshBinary = cmd
43
+ _sshArgPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
44
+ }
45
+ export function _setSshConfigDirForTest(dir) { _sshConfigDir = dir }
46
+ export const sshBinary = () => _sshBinary ?? "ssh"
47
+ export const sshArgPrefix = () => _sshArgPrefix
48
+ export const sshConfigDir = () => _sshConfigDir ?? configDir
49
+
50
+ // ─── askpass 链(包装档 ∥ 环境 ∥ 命令形)────────────────────────────────────
51
+
52
+ /** 包装档运行期写入(内容零秘密——仅路径;Win `.cmd` ∥ POSIX `.sh` 0700)。 */
53
+ export function writeAskpassWrapper(id) {
54
+ const dir = `${sshConfigDir()}/ssh-askpass`
55
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
56
+ const helper = fileURLToPath(new URL("../tools/ssh-askpass.mjs", import.meta.url))
57
+ const node = process.execPath
58
+ const win = process.platform === "win32"
59
+ const file = `${dir}/askpass-${process.pid}-${id}.${win ? "cmd" : "sh"}`
60
+ // `.cmd` 首行 `@echo off`:cmd 中介默认回显命令行——回显会占掉 ssh 读回的**首行**
61
+ // (`readpass.c` 只取首行 ⇒ 口令错位为空串);POSIX `.sh` 无此面。
62
+ writeFileSync(file, win ? `@echo off\r\n"${node}" "${helper}" %*\r\n` : `#!/bin/sh\nexec "${node}" "${helper}" "$@"\n`, { mode: 0o700 })
63
+ try { chmodSync(file, 0o700) } catch { /* win32 无 POSIX mode——尽力面 */ }
64
+ return file
65
+ }
66
+
67
+ /** 包装档清理(关闭 / 终结时尽力面——内容零秘密,残留无实害)。 */
68
+ export function unlinkAskpass(entry) {
69
+ try { if (entry.askpassFile) unlinkSync(entry.askpassFile) } catch { /* 尽力面 */ }
70
+ entry.askpassFile = null
71
+ }
72
+
73
+ /** ssh 子进程环境:askpass 链 + 秘密(无秘密时**显式清除**——防父进程残留变量误喂)。 */
74
+ export function buildSshEnv({ wrapper, secret }) {
75
+ const env = { ...process.env, SSH_ASKPASS: wrapper, SSH_ASKPASS_REQUIRE: "force" }
76
+ // 占位 DISPLAY:OpenSSH < 8.4 无 `SSH_ASKPASS_REQUIRE`(RHEL 8 / CentOS 8 代)——无 DISPLAY 时
77
+ // askpass 不被放行(`readpass.c` allow_askpass 判据);占位后新旧两代同走 askpass(§6.22 NS1 注)。
78
+ if (!env.DISPLAY) env.DISPLAY = ":0"
79
+ if (secret != null) env[SECRET_ENV] = secret
80
+ else delete env[SECRET_ENV]
81
+ return nodeChildEnv(env)
82
+ }
83
+
84
+ /** 会话命令形(§6.22 ⑤):`-tt` 强制远端 PTY + 保活三件 + 单提示;`port`/`user` 未给 ⇒ 不下发。
85
+ * 预探 = `NumberOfPasswordPrompts=0`(password ∥ keyboard-interactive 两径同被短路——
86
+ * `sshconnect2.c` `userauth_passwd` / `userauth_kbdint` 两处同判 `attempt++ >= n`;私钥口令提示
87
+ * 亦被短路:`load_identity_file` 循环 `i <= number_of_password_prompts`);带密 = 1(单提示)。 */
88
+ export function buildSshArgs({ host, user, port, probe }) {
89
+ return [
90
+ "-tt",
91
+ "-o", "ConnectTimeout=10",
92
+ "-o", "ServerAliveInterval=30",
93
+ "-o", "ServerAliveCountMax=4",
94
+ "-o", `NumberOfPasswordPrompts=${probe ? 0 : 1}`,
95
+ ...(port ? ["-p", String(port)] : []),
96
+ user ? `${user}@${host}` : host,
97
+ ]
98
+ }
99
+
100
+ // ─── 遮蔽(§6.22 ⑦)────────────────────────────────────────────────────────
101
+
102
+ /** 秘密遮蔽过滤器:字面移除 + `len-1` 尾缓冲(跨 chunk 切分不泄;尾至沉降冲涮)。 */
103
+ export function createMask() {
104
+ const secrets = []
105
+ let tail = ""
106
+ let keep = 0
107
+ return {
108
+ add(secret) {
109
+ const s = String(secret ?? "")
110
+ if (!s || secrets.includes(s)) return
111
+ secrets.push(s)
112
+ keep = Math.max(keep, s.length - 1)
113
+ },
114
+ push(text) {
115
+ let out = tail + String(text ?? "")
116
+ for (const s of secrets) out = out.split(s).join("")
117
+ if (out.length <= keep) { tail = out; return "" }
118
+ const emit = out.slice(0, out.length - keep)
119
+ tail = out.slice(out.length - keep)
120
+ return emit
121
+ },
122
+ /** 沉降时冲涮尾缓冲:**只放行非秘密前缀段**(尾内最长可疑后缀一律扣下——整尾 ∥ 移位前缀
123
+ * 都不出返回面;扣下的至多 `秘密长-1` 字符,代价 < 前缀外泄)。 */
124
+ flush() {
125
+ const t = tail
126
+ tail = ""
127
+ if (!t) return ""
128
+ let cut = 0
129
+ for (const s of secrets) {
130
+ const max = Math.min(t.length, s.length - 1)
131
+ for (let L = 1; L <= max; L++) if (t.endsWith(s.slice(0, L))) cut = Math.max(cut, L)
132
+ }
133
+ return cut ? t.slice(0, t.length - cut) : t
134
+ },
135
+ }
136
+ }
137
+
138
+ // ─── 子进程(起跑 ∥ 双流汇入 ∥ 退出)────────────────────────────────────────
139
+
140
+ /** 悬挂 chunk 双流汇入(遮蔽 → log ∥ 缓冲 ∥ 面板 → 唤醒沉降等待者)。 */
141
+ function handleChunk(entry, text) {
142
+ if (!text) return
143
+ entry.bytes += text.length
144
+ try { entry.logStream?.write(text) } catch { /* log 写失败不进语义——尽力面 */ }
145
+ entry.buf += text
146
+ if (entry.buf.length > BUF_KEEP_CHARS) {
147
+ entry.buf = entry.buf.slice(entry.buf.length - BUF_KEEP_CHARS)
148
+ entry.bufDropped = true
149
+ }
150
+ entry._notify?.(text)
151
+ entry.lastDataAt = Date.now()
152
+ for (const w of [...entry._waiters]) w("data")
153
+ }
154
+
155
+ /** 子进程挂载(双流解码 ∥ 退出 ∥ spawn 错误——探连与会话同径)。 */
156
+ export function attachChild(entry, child) {
157
+ entry.child = child
158
+ const outDecoder = makeDecoder()
159
+ const errDecoder = makeDecoder()
160
+ const feed = (d) => handleChunk(entry, entry.mask.push(sanitizeOutput(d)))
161
+ const flush = () => {
162
+ const rest = sanitizeOutput(outDecoder(Buffer.alloc(0), true)) + sanitizeOutput(errDecoder(Buffer.alloc(0), true))
163
+ if (rest) feed(Buffer.from(rest, "utf8"))
164
+ }
165
+ child.stdout.on("data", (d) => feed(outDecoder(d)))
166
+ child.stderr.on("data", (d) => feed(errDecoder(d)))
167
+ // stdin 无 error 监听 ⇒ 对端刚死时写入的异步 EPIPE 会以未处理 'error' 事件逸出(uncaughtException)。
168
+ // 同族先例:CLI `tui/clipboard.mjs` ∥ `mcp/transport-stdio.mjs` 同式吞 EPIPE。
169
+ child.stdin?.on("error", () => { /* 写失败由 send ∥ secret_input 的调用面回读;此处仅防未处理事件 */ })
170
+ child.on("error", (error) => {
171
+ if (entry.child !== child) return // 旧进程(已被重起替换)——判定面不采
172
+ entry.spawnError = error?.message ?? String(error)
173
+ for (const w of [...entry._waiters]) w("exit")
174
+ })
175
+ child.on("exit", (code, exitSignal) => {
176
+ if (entry.child !== child) return // 旧进程(探连 → 带密会话的重起面)——事件不污染新连接判定
177
+ entry.exitInfo = { code, signal: exitSignal }
178
+ flush()
179
+ // 已就绪(会话态)⇒ 远端断开:终态 + 墓碑(E5);探连期的退出由 connect 判定径处置(不在此收尾)。
180
+ if (entry.state === "ready" && entry.closing !== true) entry.onRemoteExit?.(entry)
181
+ for (const w of [...entry._waiters]) w("exit")
182
+ })
183
+ }
184
+
185
+ /** 起跑一条 ssh(探连 ∥ 带密会话同径)。重起(探连 → 带密会话)⇒ 判定面复位:exitInfo ∥
186
+ * spawnError 属**上一次**进程(残留会让新连接的成功判定被误读成「已退出」)。 */
187
+ export function spawnSession(entry, { user, port, secret }) {
188
+ entry.exitInfo = null
189
+ entry.spawnError = null
190
+ const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null })], {
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ windowsHide: true,
193
+ detached: process.platform !== "win32",
194
+ env: buildSshEnv({ wrapper: entry.askpassFile, secret }),
195
+ })
196
+ attachChild(entry, child)
197
+ return child
198
+ }
199
+
200
+ // ─── 沉降(§6.22 ③)────────────────────────────────────────────────────────
201
+
202
+ function promptHit(entry) {
203
+ return Boolean(entry.promptRe && entry.promptRe.test(entry.buf))
204
+ }
205
+
206
+ /** 等到「就绪判据」成立:提示符命中 ∥ 静默窗到点 ∥ 硬超时 ∥ 退出 ∥ 中止(返回 reason)。 */
207
+ export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null } = {}) {
208
+ return new Promise((resolve) => {
209
+ let done = false
210
+ let quietTimer = null
211
+ const deadline = Date.now() + Math.max(1, Number(timeoutMs) || SSH_TIMEOUT_MS)
212
+ const finish = (reason) => {
213
+ if (done) return
214
+ done = true
215
+ clearTimeout(quietTimer)
216
+ clearTimeout(deadlineTimer)
217
+ signal?.removeEventListener("abort", onAbort)
218
+ entry._waiters.delete(onEvent)
219
+ resolve(reason)
220
+ }
221
+ const arm = () => {
222
+ clearTimeout(quietTimer)
223
+ quietTimer = setTimeout(() => finish(promptHit(entry) ? "prompt" : "silence"), SSH_SETTLE_MS)
224
+ }
225
+ const onEvent = (kind) => {
226
+ if (kind === "exit") return finish("exited")
227
+ if (promptHit(entry)) return finish("prompt")
228
+ arm()
229
+ }
230
+ const onAbort = () => finish("aborted")
231
+ const deadlineTimer = setTimeout(() => finish("timeout"), Math.max(0, deadline - Date.now()))
232
+ if (entry.state === "dead" || entry.state === "closed") return finish("exited")
233
+ if (signal?.aborted) return finish("aborted")
234
+ signal?.addEventListener("abort", onAbort, { once: true })
235
+ entry._waiters.add(onEvent)
236
+ onEvent("data") // 起点:先查提示符,否则起静默窗(「send 后 800 ms 无新输出」从本刻起算)
237
+ })
238
+ }
239
+
240
+ /** 就绪结论注记(§6.22 ③:静默 ∥ 提示符 ∥ 超时——超时带 id / host / 已等时长,不静默挂起)。 */
241
+ export function settleNote(entry, reason, waitedMs) {
242
+ if (reason === "prompt") return "settled (prompt matched)"
243
+ if (reason === "silence") return "settled (silence window)"
244
+ if (reason === "timeout") return `not settled after ${Math.round(waitedMs / 100) / 10}s (${idLabel(entry)} @ ${entry.host}) — partial output above (silence window or promptPattern never hit)`
245
+ if (reason === "aborted") return `(interrupted — partial output above; the session stays open: ${idLabel(entry)} @ ${entry.host})`
246
+ if (reason === "exited") return `${idLabel(entry)} @ ${entry.host} ${entry.state === "ready" ? "ended (the remote closed the connection)" : "has stopped"}`
247
+ return String(reason)
248
+ }
249
+
250
+ // ─── 输出面(缓冲 ∥ 帽 ∥ 状态行)────────────────────────────────────────────
251
+
252
+ export const idLabel = (entry) => `ssh#${entry.id}`
253
+
254
+ /** 取出未读输出(遮蔽尾缓冲先冲涮)。 */
255
+ export function drain(entry) {
256
+ const flushed = entry.mask.flush()
257
+ if (flushed) entry.buf += flushed
258
+ const text = entry.buf
259
+ entry.buf = ""
260
+ const dropped = entry.bufDropped === true
261
+ entry.bufDropped = false
262
+ return { text, dropped }
263
+ }
264
+
265
+ /** 返回帽(保尾 + 截断注记——§6.22 ⑥)。 */
266
+ export function capText(text, max = SSH_RETURN_CAP, entry = null) {
267
+ if (text.length <= max) return text
268
+ const omitted = text.length - max
269
+ const where = entry?.logPath ? ` — full output in the log: ${entry.logPath}` : ""
270
+ return `[... first ${omitted} chars omitted (return cap ${max})${where}]\n${text.slice(text.length - max)}`
271
+ }
272
+
273
+ /** 状态行冠形(§6.22 动作表末行):每返回首行。 */
274
+ export function statusLine(entry) {
275
+ const idle = Math.max(0, Math.round((Date.now() - entry.lastActivityAt) / 1000))
276
+ return `[ssh (${idLabel(entry)}) ${entry.host} — ${entry.state} · idle ${idle}s]`
277
+ }
278
+
279
+ /** 体形(空 ⇒ `(no output)`;非空转义——`bash-async.mjs renderDigest` 同式)。 */
280
+ export function bodyText(text) {
281
+ return text.trim() ? escapeXmlLite(text.replace(/\s+$/u, "")) : "(no output)"
282
+ }
283
+
284
+ /** XML 转义(`agent/helpers.mjs escapeXml` 同式;本档为零依赖注入面自持小实现,避免
285
+ * transport → agent 反向依赖)。 */
286
+ function escapeXmlLite(s) {
287
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;")
288
+ }
289
+
290
+ // ─── 失败分类(照服务端分类族:auth ∥ unreachable ∥ fingerprint_changed)─────
291
+
292
+ export function classifyFailure(stderr) {
293
+ const text = String(stderr ?? "")
294
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|host key .* revoked|requested strict checking/i.test(text)) {
295
+ return { kind: "hostkey", message: "host key verification failed — the key changed or your local ssh config requires strict checking; known_hosts is never rewritten by this tool" }
296
+ }
297
+ if (/Permission denied|Too many authentication failures|Authentication failed/i.test(text)) {
298
+ return { kind: "auth", message: "authentication rejected" }
299
+ }
300
+ if (/Connection timed out|Connection refused|No route to host|Could not resolve hostname|Connection closed by|Network is unreachable|connect to host .* failure/i.test(text)) {
301
+ return { kind: "unreachable", message: "host unreachable or timed out" }
302
+ }
303
+ return null
304
+ }
305
+
306
+ /** 失败句(含 id ∥ host;尾附探测现场末段——诊断面)。 */
307
+ export function failureText(entry, cls, extra = "") {
308
+ const at = `(${idLabel(entry)} @ ${entry.host})`
309
+ const tail = entry.buf.trim() ? `\n${escapeXmlLite(entry.buf.trim().slice(-600))}` : ""
310
+ return `Error: ssh ${cls ? cls.message : "connection failed"} ${at}${extra}${tail}`
311
+ }
@@ -43,7 +43,7 @@ import { carrierField, getAsyncPool, tombstoneOf } from "./async-settle.mjs"
43
43
  * 大小写不敏感(todo.md/changelog.md 精确——路径任意层含 docs/、根、.thincoder/)——
44
44
  * 命中 → throw(fail-closed——先于调度器准入——无排队残留——错误即工具结果);
45
45
  * 提示列出全部违规条目——英文模板逐字定稿(AGENT-LOOP-SUBAGENT.md §6.9「父侧文件拦截(R26)」)。
46
- * M5 F6(2026-09-17):黑名单随同迁入 spawn-gates.mjs(rejectEngineeringFilePaths——族 = ledger.db / CHANGELOG + scripts/**;老台账 md 族随 M2 退役)——本函数只留调用(错误文案逐字保留)。 */
46
+ * M5 F6(2026-09-17——2026-10-15 口径随正):黑名单随同迁入 spawn-gates.mjs(rejectEngineeringFilePaths——族 = 过程档(CHANGELOG)+ 工程工具面(`scripts/*`——层级锚定:仅仓根与直接子层;目录名在深层不携带 face 信息);老台账 md 族随 M2 退役)——本函数只留调用(错误文案逐字保留)。 */
47
47
  export function normalizeFileList(files, cwd) {
48
48
  const out = []
49
49
  for (const f of Array.isArray(files) ? files : []) {
@@ -57,10 +57,10 @@ export function normalizeFileList(files, cwd) {
57
57
  }
58
58
  if (!out.includes(abs)) out.push(abs)
59
59
  }
60
- // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):scripts/** + 过程档拦截——
60
+ // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2——2026-10-15 口径随正):`scripts/*`(仅仓根/直接子层)+ 过程档拦截——
61
61
  // 谓词本体落 spawn-gates.mjs(rejectEngineeringFilePaths 收集全部违规 + 抛合并错误);
62
62
  // 循环内目录声明先行(错误序不变——目录错误先于过程档错误)。父侧维护文件
63
- // basename 族(ledger.db / CHANGELOG)随同迁入谓词——错误文案逐字保留。
63
+ // basename 族(CHANGELOG——`ledger.db` 随 M2 退役)随同迁入谓词——错误文案逐字保留。
64
64
  if (files !== undefined && files !== null) rejectEngineeringFilePaths(files)
65
65
  return out
66
66
  }
@@ -398,14 +398,15 @@ export function maybeRefillAsync(parent) {
398
398
  * spawn(subagent-spawn.mjs async 分支)、escalate(escalate-async.mjs)、async-advisor
399
399
  * 池(advisor-async.mjs——§6.10 跨池共号)、**后台 bash 任务池**(bash-async.mjs——#9:
400
400
  * `_bgTasks` 同入扫描域——VSC 形计数器不随 run 存活,缺此扫描会与在途 bg 条目撞号)、
401
- * **后台浏览器动作池**(browser-async.mjs——批 browser-async-fix:`_browserTasks` 同入)共用;
401
+ * **后台浏览器动作池**(browser-async.mjs——批 browser-async-fix:`_browserTasks` 同入)、
402
+ * **SSH 会话池**(ssh-session.mjs——批 ssh-session:`_sshSessions` 同入——同因)共用;
402
403
  * executeAsyncSpawn 直读 counter(分配与
403
404
  * 消费同步——无 await 间隙)。CLI 两池键均为字符串(set(String(id))——解析分支防御保留)。
404
405
  * @returns {number} 全池唯一的下一 id(单调——进程内)
405
406
  */
406
407
  export function nextSubagentId(parent) {
407
408
  let poolMax = 0
408
- for (const pool of [carrierField(parent, "_asyncSubagents"), carrierField(parent, "_asyncAdvisors"), carrierField(parent, "_bgTasks"), carrierField(parent, "_browserTasks")]) {
409
+ for (const pool of [carrierField(parent, "_asyncSubagents"), carrierField(parent, "_asyncAdvisors"), carrierField(parent, "_bgTasks"), carrierField(parent, "_browserTasks"), carrierField(parent, "_sshSessions")]) {
409
410
  if (!pool || pool.size === 0) continue
410
411
  for (const k of pool.keys()) {
411
412
  const n = typeof k === "number" ? k : Number.parseInt(k, 10)
@@ -3,8 +3,9 @@
3
3
  * 2026-09-07 token 链终消费制:+action: consume-design(ENGINEERING-MODE.md §2.6 F1——
4
4
  * 父侧链终核销消费 designId 槽——执行器 executeConsumeDesignAction 在 subagent-spawn.mjs)。
5
5
  * 2026-10-07 批 ledger-tool(#927 并入):+action: design-slots(只读清点面——
6
- * DESIGN-TOKEN-SETTLEMENT.md §10 F-SL1)+ consume-design 选择器扩展(designIds / expired /
6
+ * DESIGN-TOKEN-SETTLEMENT.md §9 F-SL1)+ consume-design 选择器扩展(designIds / expired /
7
7
  * olderThanDays)——实现在 `design-slots.mjs`(subagent-spawn.mjs re-export 保 import 面)。
8
+ * 2026-10-14 批 design-token-recovery(#1384 并入):design-slots 回收读口——`designId` 过滤 + `reveal:true` 单槽揭值(必携 designId;掩蔽 = 只揭活槽)——实现随动在 `design-slots.mjs`。
8
9
  *
9
10
  * 2026-09-03 拆分轮: subagent.mjs 超 500 硬顶——async 常量、共享 post-spawn 管线
10
11
  *(runChildPipeline)与队列/注入/并账机械迁至 ./subagent-async.mjs。execute
@@ -114,8 +115,8 @@ export function buildSyncStoppedReport(role, capturedOutput, designId) {
114
115
  * a stronger model) / cancel (stop ONE background subagent — AGENT-LOOP-SUBAGENT.md §6.7.2) / panel (view + fix
115
116
  * the live subagent panel — AGENT-LOOP-SUBAGENT.md §6.7.2) / consume-design (parent-side chain-terminal token
116
117
  * consumption — ENGINEERING-MODE.md §2.6, 2026-09-07; selectors designId / designIds / expired /
117
- * olderThanDays — DESIGN-TOKEN-SETTLEMENT.md §10 F-SL2) / design-slots (read-only slot inventory —
118
- * DESIGN-TOKEN-SETTLEMENT.md §10 F-SL1, 2026-10-07). The check
118
+ * olderThanDays — DESIGN-TOKEN-SETTLEMENT.md §9 F-SL2) / design-slots (read-only slot inventory —
119
+ * DESIGN-TOKEN-SETTLEMENT.md §9 F-SL1, 2026-10-07). The check
119
120
  * action was deleted (AGENT-LOOP-SUBAGENT.md §6.7.5): async results reach the model only via the auto channel.
120
121
  * - action:"spawn" roles: "explore" — read-only tools, search/read/analyze
121
122
  * (suitable for codebase exploration); "coder" — full tool set, self-contained
@@ -144,10 +145,11 @@ export const subagentTool = {
144
145
  role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer"], description: "The sub-agent role — see the role matrix in the tool description. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
145
146
  model: { type: "string", description: "action:'spawn': provider/model override ('provider:model', a provider, or a model name on the parent's provider) — defaults config.agent.subagentModels[role] → config.agent.subagentModel → the parent's provider; \"default\" inherits. action:'escalate': a consult candidate (default = the first)." },
146
147
  designToken: { type: "string", description: "Required when role='eng-coder': the token from advisor(type='design') after the review passed — without it, eng-coder cannot modify files." },
147
- designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the single-slot selector (required to pick between several; the gate refuses to guess); see designIds / expired / olderThanDays for batch consumption." },
148
+ designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the single-slot selector (required to pick between several; the gate refuses to guess); see designIds / expired / olderThanDays for batch consumption. action:'design-slots': filter the inventory to one slot (trimmed exact match; an empty string means no filter; an unknown id is refused with the held id list) — REQUIRED with reveal:true." },
148
149
  designIds: { type: "array", items: { type: "string" }, description: "action:'consume-design' (batch): an explicit non-empty list of design ids to close out — pass exactly ONE selector per call (designId / designIds / expired / olderThanDays)." },
149
150
  expired: { type: "boolean", description: "action:'consume-design' (batch): true = close out every expired slot (exactly ONE selector per call)." },
150
151
  olderThanDays: { type: "number", description: "action:'consume-design' (batch): close out every slot older than N days (age > N; exactly ONE selector per call)." },
152
+ reveal: { type: "boolean", description: "action:'design-slots' only: true = reveal the selected slot's full designToken — REQUIRES designId (one slot at a time, never a bulk dump); an expired or malformed slot is masked (designToken=—); a non-boolean value is refused. After a token drops from your context (compaction, restart), read it back this way and pass designId + designToken verbatim on the eng-coder spawn — the gate checks exact equality." },
151
153
  batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute) — the task book this spawn implements/writes. Refused when absent or when the path does not resolve to a readable file; the content is never validated. role='coder' (normal mode): optional — binds this spawn's §5 record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file." },
152
154
  round: { type: "string", enum: ROUND_VALUES, description: "REQUIRED for role='eng-coder'/'eng-designer': initial (first round for this design) or fix (a correction round reusing the same designId+designToken; docs FIRST). No default — missing = refused." },
153
155
  async: { type: "boolean", description: "action:'spawn'/'escalate': default at depth 0 = true (async; the report arrives automatically); depth>0 always sync. async:false forces the blocking flight." },
@@ -192,7 +194,7 @@ export const subagentTool = {
192
194
  // 非只读控制动作——depth-0 + 工程模式限定(本分流已过受限变体门;工程模式门在
193
195
  // 执行器内)——planMode 拒绝(dispatch 不豁免)——不入批审批分组(dispatch 免审)。
194
196
  if (action === "consume-design") return executeConsumeDesignAction(args, ctx)
195
- // 2026-10-07 批 ledger-tool(#927 并入——DESIGN-TOKEN-SETTLEMENT.md §10 F-SL1):design-slots =
197
+ // 2026-10-07 批 ledger-tool(#927 并入——DESIGN-TOKEN-SETTLEMENT.md §9 F-SL1):design-slots =
196
198
  // 只读清点(工程模式父侧限定——门在执行器内);dispatch 分类 = readonly(planMode 放行 /
197
199
  // 免审批 / digest 放行——同 status / observe)。
198
200
  if (action === "design-slots") return executeDesignSlotsAction(args, ctx)
@@ -51,7 +51,7 @@ export const taskTool = {
51
51
  },
52
52
  readonly: true,
53
53
  async execute(args, ctx) {
54
- // F10(工程模式机械停用——双层门的 **execute 层兜底**;先例 = escalate `subagent-actions.mjs:349`):
54
+ // F10(工程模式机械停用——双层门的 **execute 层兜底**;先例 = escalate `subagent-actions.mjs` 的 `executeEscalateAction`):
55
55
  // 前置位 = 本行以下全部副作用之前(alias 归一 / `_taskPushbacks` 归零 / `_onTaskUpdate` / 轻推)
56
56
  // ⇒ 零副作用(列表不变);装配层已摘(`agent/family-tools.mjs` 工程分支不入表,KD8)。
57
57
  // 普通模式(engineering 非真)逐字走原路径。
@@ -135,6 +135,12 @@ export const timerTool = {
135
135
  const hasDaily = args.daily !== undefined && args.daily !== null
136
136
  const hasCancel = args.cancel !== undefined && args.cancel !== null
137
137
  const wantsList = args.list === true
138
+ // `list` 键在场须恰为 `true`——否则显式拒(防 `{list:false}` 静默落缺省 180s:无义键触发真实副作用)。
139
+ if (args.list !== undefined && args.list !== null && args.list !== true) {
140
+ throw new Error(
141
+ `timer: list accepts only true (got: ${String(args.list)}) — omit list to set a timer; pass { list: true } to list pending timers`,
142
+ )
143
+ }
138
144
  const forms = [hasSeconds && "seconds", hasAt && "at", hasEvery && "every", hasDaily && "daily"].filter((v) => v !== false)
139
145
  if (forms.length > 1 || (forms.length > 0 && (hasCancel || wantsList)) || (hasCancel && wantsList)) {
140
146
  const given = [...forms, hasCancel && "cancel", wantsList && "list"].filter((v) => v !== false).join(" / ")
@@ -22,12 +22,12 @@ const WATCH_ROLES = new Set(["eng-coder", "eng-designer"])
22
22
 
23
23
  /** 子代理族文案(§6.32.4 逐字——父侧定稿;`from` = `_upstream.label`,形 `role#id`)。 */
24
24
  export function zeroWriteAlertText(from, rounds) {
25
- return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no file write — take a look (subagent action:'status' for the live view).`
25
+ return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no file write — check for a non-writing loop: inspect (subagent action:'observe'), then steer or cancel.`
26
26
  }
27
27
 
28
28
  /** 评审族文案(§6.32.4 逐字——父侧定稿;`from` = `advisor#<entry id>`)。 */
29
29
  export function advisorAlertText(from, rounds) {
30
- return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no review output — take a look (subagent action:'status' / 'cancel').`
30
+ return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no review output — check for a stalled review: inspect (subagent action:'status'), then cancel.`
31
31
  }
32
32
 
33
33
  /**
package/agent-tools.mjs CHANGED
@@ -22,9 +22,10 @@ export { batchTool } from "./agent-tools/batch.mjs"
22
22
  // 批次档生命周期工具(BATCH-RECORD §4.3 挂载表——主名 `batch` 单工具四 action:depth-0 主 agent
23
23
  // create/close + append/status 域随模式(工程面 = append §1/§4/§6 ∥ status §1;普通面 = append §1/§2/§4/§5/§6 ∥ status §1/§2/§5)(轮 2 裁定②:§4/§6 状态面走普通文档写)(D-BR18);
24
24
  // eng 子代理 eng 分支绑定段写入;评审 §3。
25
- // CORE-UNIFICATION TOOLS #83(统一登记册——VSC `agent-tools/index.mjs:15` 含 consult 家族;
26
- // CLI 原把 consult 另挂 `agent/setup.mjs:173` ⇒ 归位:登记册即单一来源,装配方只读本档)。
25
+ // CORE-UNIFICATION TOOLS #83(统一登记册——VSC `agent-tools/index.mjs` 含 consult 家族;
26
+ // CLI 原把 consult 另挂 `agent/setup.mjs` 的家族段装配 ⇒ 归位:登记册即单一来源,装配方只读本档)。
27
27
  export { consultStartTool, consultStopTool } from "./agent-tools/consult.mjs"
28
28
  // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-UPSTREAM.md §6.27.4 装配接线):子代理上行通道工具
29
29
  // (depth>0 段装配——`agent/family-tools.mjs`;depth-0 / consult 不装配)。
30
30
  export { parentChannelTool } from "./agent-tools/parent-channel.mjs"
31
+ export { docCheckTool } from "./agent-tools/doc-check.mjs" // 文档纪律机检(DOC-DISCIPLINE.md §3.20 条目 S——产品内建 · 报告态 · 非门禁)
package/attachments.mjs CHANGED
@@ -5,9 +5,9 @@
5
5
  * 上提源(迁移前坐标)= VSC `thincoder-vscode/src/extension/image-handler.mjs`(`parseDataUrl` :34 ·
6
6
  * `savePastedImages` :49 · `downgradeNonVisionImages` :109);预算语义(合计 30MB ∕ 轮 · 溢出项弃后
7
7
  * **继续扫**「其余照发」)与配额单位 = 桌面同档 `thincoder-desktop/src/main/attachments.mjs`
8
- * (15MB ∕ 条 —— 与核 `read_image` 内闸同值同单位:`tools/file.mjs:26` `MAX_IMAGE_BYTES = 15_000_000`,
8
+ * (15MB ∕ 条 —— 与核 `read_image` 内闸同值同单位:`tools/file.mjs` `MAX_IMAGE_BYTES = 15_000_000`,
9
9
  * 接纳面不得宽于该闸)。本档 = 单源(消费面 = 桌面 `src/main/attachments.mjs`;VSC 自持副本已随本批(parity-b4)迁移改指本档)。
10
- * 指引行(`[Attached images: …]`)不迁 —— 单源 = `agent/setup-reminders.mjs:248` `appendImagePointer`(原位)。
10
+ * 指引行(`[Attached images: …]`)不迁 —— 单源 = `agent/setup-reminders.mjs` `appendImagePointer`(原位)。
11
11
  *
12
12
  * 与 VSC 自持副本的在册差异(双写窗口)**收正已毕**(迁移轮 parity-b4 —— 逐条处置见批档 §2.3):① 阈单位 ∕
13
13
  * ② 弃项索引 ∕ ③ 合计预算闸 ∕ ④ 降级成功判据 = **两端同源单值**(收正方向——VSC 侧本地字面 ∕ 过滤后下标 ∕ 无预算 ∕
@@ -28,7 +28,7 @@
28
28
  import { join } from "node:path"
29
29
  import { specForModel } from "./model-specs.mjs"
30
30
 
31
- /** 单项上限 15MB(与 `read_image` 内闸同值同单位 —— `tools/file.mjs:26` `MAX_IMAGE_BYTES = 15_000_000`:
31
+ /** 单项上限 15MB(与 `read_image` 内闸同值同单位 —— `tools/file.mjs` `MAX_IMAGE_BYTES = 15_000_000`:
32
32
  * 超阈件交核也读不进 ⇒ 接纳面不得宽于该闸)。 */
33
33
  export const IMAGE_MAX_BYTES = 15_000_000
34
34
 
@@ -73,7 +73,7 @@ export function savePastedImages(dataUrls, cwd, { fs } = {}) {
73
73
  return { paths: [], dropped: list.length }
74
74
  }
75
75
  const tmpDir = join(cwd, ".thincoder", "tmp")
76
- const runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) // 命名先例 = image-handler.mjs:56
76
+ const runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) // 命名先例 = `image-handler.mjs` 的 `downgradeNonVisionImages`
77
77
  const paths = []
78
78
  let dropped = 0
79
79
  let budget = TURN_MAX_BYTES
package/browser/cdp.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * browser/cdp.mjs — CDP 传输客户端(零第三方——N-BT1:Node ≥ 24 原生 WebSocket)。
3
3
  *
4
- * 形沿 `scripts/console-walkthrough.mjs:133-141` 的实证客户端(id 派发 ∥ 事件派发)——本文档把它
4
+ * 形沿 `scripts/console-walkthrough.mjs` 的实证客户端(id 派发 ∥ 事件派发)——本文档把它
5
5
  * 拆成类以承载多动作会话(connect / call / on / close),并加 HTTP 面 `/json/new` 取标签页。
6
6
  * `WebSocketImpl` = 注入缝(缺省 `globalThis.WebSocket`;单元测试注假传输——缺省回落真实现,`??`)。
7
7
  *
@@ -135,7 +135,7 @@ export async function connectCdp(wsUrl, { WebSocketImpl, openTimeoutMs = CONNECT
135
135
  return new CdpClient(ws)
136
136
  }
137
137
 
138
- /** 开一张标签页并取其调试地址(`PUT /json/new`,GET 兜底——walkthrough `:131-132` 同式;fetch 帽 5s)。 */
138
+ /** 开一张标签页并取其调试地址(`PUT /json/new`,GET 兜底——walkthrough 同式;fetch 帽 5s)。 */
139
139
  export async function newTabWebSocketUrl(port, { host = "127.0.0.1", fetchImpl = globalThis.fetch, timeoutMs = NEWTAB_TIMEOUT_MS } = {}) {
140
140
  const url = `http://${host}:${port}/json/new?about:blank`
141
141
  let timer = null
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * browser/launch.mjs — 浏览器发现 ∥ profile 锁 ∥ 启动 ∥ 杀树(BROWSER-TOOL.md §4)。
3
3
  *
4
- * 候选表与启动参数沿 `scripts/console-walkthrough.mjs:33-44`(Edge 先于 Chrome)∥ `:123`
4
+ * 候选表与启动参数沿 `scripts/console-walkthrough.mjs`(Edge 先于 Chrome)∥ 同档启动参数段
5
5
  * (`--headless=new` ∥ `--remote-debugging-port=0` ∥ `stdio:"ignore"` ∥ POSIX `detached`)——
6
6
  * 本档不 import 该脚本,只内化其形。
7
- * profile = `~/.thincoder/browser/profile`(`configDir` 同源——`config-io.mjs:32`)= **持久**
7
+ * profile = `~/.thincoder/browser/profile`(`configDir` 同源——`config-io.mjs`)= **持久**
8
8
  * (F-BT6:有头登录一次 ⇒ 后续含无头复用登录态)。
9
9
  *
10
10
  * 注入缝(N-BT6):`resolveBrowser({platform, env, pathExists, which})` 与 `acquireProfileLock`
@@ -96,12 +96,56 @@ export function releaseProfileLock(lockPath, io = {}) {
96
96
  removeText(lockPath)
97
97
  }
98
98
 
99
+ /** 同 profile 残留进程列表(平台分支——一次 exec 取全表;失败 ⇒ []——尽力面零抛):win32 = 一次
100
+ * Get-CimInstance(PowerShell——wmic 已从新系统移除);POSIX = 一次 ps(自持列表面:既有
101
+ * `process-probe-exec.mjs` 那条链按已知 pid 查 cmdline,本面是反查——零第三方依赖)。 */
102
+ function defaultListProfileProcs(platform = process.platform) {
103
+ try {
104
+ if (platform === "win32") {
105
+ const out = execFileSync("powershell.exe",
106
+ ["-NoProfile", "-NonInteractive", "-Command",
107
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"],
108
+ { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] })
109
+ const rows = JSON.parse(out.trim() || "[]")
110
+ return (Array.isArray(rows) ? rows : [rows]).filter((r) => r && Number.isInteger(r.ProcessId)).map((r) => ({ pid: Number(r.ProcessId), cmdline: r.CommandLine ?? "" }))
111
+ }
112
+ const out = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] })
113
+ return out.split(/\r?\n/).map((l) => l.match(/^\s*(\d+)\s+(.*)$/)).filter(Boolean).map((m) => ({ pid: Number(m[1]), cmdline: m[2] }))
114
+ } catch { return [] }
115
+ }
116
+
117
+ /** 同 `--user-data-dir` 残留浏览器进程树回收(#1304——启动序在 spawn 前调用):陈旧实例占住
118
+ * profile ⇒ 新实例自退 + `DevToolsActivePort` 永不落盘 = 起不动;回收后再 spawn。
119
+ * 锁空闲判据同 `acquireProfileLock`(活 pid 持•非本进程 ⇒ 不回收——既有错由 acquire 支照抛);
120
+ * `io` = 注入缝(`platform` ∥ `readText`/`isAlive`/`pid` ∥ `list` ⇒ [{pid, cmdline}] ∥ `kill(pid)`);
121
+ * 平台分支 + 尽力面零抛(列表 ∥ 单杀失败均不阻断启动);`close` 路径既有杀树(`killBrowser`)零改
122
+ * ——默认杀器即复用它(pid 形)。 */
123
+ export function reclaimProfileOrphans(profile, io = {}) {
124
+ try {
125
+ const platform = io.platform ?? process.platform
126
+ const readText = io.readText ?? ((p) => { try { return readFileSync(p, "utf8") } catch { return "" } })
127
+ const isAlive = io.isAlive ?? defaultPidAlive
128
+ const pid = io.pid ?? process.pid
129
+ const held = Number(String(readText(join(dirname(profile), "session.lock")) ?? "").trim())
130
+ if (Number.isInteger(held) && held > 0 && held !== pid && isAlive(held)) return // 锁被活 pid 持 ⇒ 不回收
131
+ const list = io.list ?? (() => defaultListProfileProcs(platform))
132
+ const kill = io.kill ?? ((target) => killBrowser({ pid: target, kill: (signal) => process.kill(target, signal) }, { platform }))
133
+ const marker = `--user-data-dir=${profile}`
134
+ for (const row of list() ?? []) {
135
+ const target = Number(row?.pid)
136
+ if (!Number.isInteger(target) || target <= 0 || target === pid) continue
137
+ if (!String(row?.cmdline ?? "").replace(/"/g, "").includes(marker)) continue
138
+ try { kill(target) } catch { /* 单条失败不阻断其余 */ }
139
+ }
140
+ } catch { /* 回收失败不阻断启动(尽力面零抛) */ }
141
+ }
142
+
99
143
  /**
100
144
  * 启动浏览器(spawn + `DevToolsActivePort` 轮询 ≤12s)。
101
145
  * 持久 profile ⇒ 启动前清陈旧端口文件(否则旧值被当本次读数——发现即失效)。
102
146
  * @returns {Promise<{child: import("node:child_process").ChildProcess, port: number, profile: string}>}
103
147
  */
104
- export async function launchBrowser({ exe, headless = true, profile, timeoutMs = START_TIMEOUT_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
148
+ export async function launchBrowser({ exe, headless = true, profile, timeoutMs = START_TIMEOUT_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), reclaimIo = undefined } = {}) {
105
149
  const args = [
106
150
  ...(headless ? ["--headless=new"] : []),
107
151
  "--disable-gpu", "--no-first-run", "--hide-scrollbars",
@@ -109,6 +153,8 @@ export async function launchBrowser({ exe, headless = true, profile, timeoutMs =
109
153
  "--window-size=1440,900", "about:blank",
110
154
  ]
111
155
  mkdirSync(profile, { recursive: true })
156
+ // #1304:锁空闲(调用序保证——`acquireProfileLock` 在前)⇒ 先回收同 profile 残留进程树,再 spawn。
157
+ reclaimProfileOrphans(profile, reclaimIo)
112
158
  const portFile = join(profile, "DevToolsActivePort")
113
159
  rmSync(portFile, { force: true })
114
160
  const child = spawn(exe, args, { stdio: "ignore", detached: process.platform !== "win32" })
@@ -131,7 +177,7 @@ export async function launchBrowser({ exe, headless = true, profile, timeoutMs =
131
177
  return { child, port, profile }
132
178
  }
133
179
 
134
- /** 杀整棵进程树(win32 `taskkill /T /F`;POSIX 组杀 + 单杀兜底——walkthrough `:192-193` 同式)。 */
180
+ /** 杀整棵进程树(win32 `taskkill /T /F`;POSIX 组杀 + 单杀兜底——walkthrough 同式)。 */
135
181
  export function killBrowser(child, { platform = process.platform, exec = execFileSync } = {}) {
136
182
  if (!child || typeof child.pid !== "number") return
137
183
  if (platform === "win32") {
@@ -263,7 +263,7 @@ export function renderSnapshot({ url, title, rows = [], total = rows.length }) {
263
263
  return truncateText(lines.join("\n"), MAX_SNAPSHOT_CHARS, "pass selector to narrow")
264
264
  }
265
265
 
266
- /** 截断(`truncate` 同款标记形态——`tools/shared.mjs:190-193`)。 */
266
+ /** 截断(`truncate` 同款标记形态——`tools/shared.mjs`)。 */
267
267
  export function truncateText(text, max, note = "narrow the request") {
268
268
  if (text.length <= max) return text
269
269
  return `${text.slice(0, max)}\n[... truncated: ${text.length - max} chars omitted — ${note}]`
package/config-io.mjs CHANGED
@@ -197,8 +197,11 @@ export function cascadeRemoveProvider(raw, name) {
197
197
 
198
198
  // ─── Provider 持久化面(§2.5 #177「纯持久化函数取一侧 + UI 壳按端注入」)───
199
199
 
200
- /** 写一个渠道的 key(整 providers[] 重写)。冲突 → CONFIG_CONFLICT_HINT;成功 null。 */
200
+ /** 写一个渠道的 key(整 providers[] 重写)。渠道不在册 ⇒ `Provider "<name>" not found`(零写——#1179 假成功收正);
201
+ * 冲突 → CONFIG_CONFLICT_HINT;成功 null。 */
201
202
  export function setProviderKey(name, key) {
203
+ const existing = loadRaw()
204
+ if (!Array.isArray(existing.providers) || !existing.providers.some((p) => p && p.name === name)) return `Provider "${name}" not found`
202
205
  const r = persistRaw((raw) => {
203
206
  raw.providers = Array.isArray(raw.providers) ? raw.providers : []
204
207
  const entry = raw.providers.find((p) => p?.name === name)
@@ -207,8 +210,10 @@ export function setProviderKey(name, key) {
207
210
  return conflictError(r)
208
211
  }
209
212
 
210
- /** 移除一个渠道的 key(保留渠道条目)。冲突提示同 setProviderKey。 */
213
+ /** 移除一个渠道的 key(保留渠道条目)。渠道不在册 ⇒ `Provider "<name>" not found`(零写——#1179);冲突提示同 setProviderKey。 */
211
214
  export function removeProviderKeyFromConfig(name) {
215
+ const existing = loadRaw()
216
+ if (!Array.isArray(existing.providers) || !existing.providers.some((p) => p && p.name === name)) return `Provider "${name}" not found`
212
217
  const r = persistRaw((raw) => {
213
218
  const entry = Array.isArray(raw.providers) ? raw.providers.find((p) => p?.name === name) : null
214
219
  if (entry) delete entry.apiKey