@thincoder/core 0.10.7 → 0.10.9

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 (149) hide show
  1. package/CHANGELOG.md +21 -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 +13 -8
  9. package/agent/helpers.mjs +12 -10
  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/post-turn.mjs +34 -0
  14. package/agent/record-results.mjs +9 -1
  15. package/agent/run-stages.mjs +45 -10
  16. package/agent/setup-reminders.mjs +14 -13
  17. package/agent/setup.mjs +72 -48
  18. package/agent/spawn-child.mjs +1 -1
  19. package/agent/subagent-event.mjs +1 -1
  20. package/agent/suspension.mjs +2 -1
  21. package/agent/timers.mjs +8 -8
  22. package/agent/turn-loop.mjs +7 -1
  23. package/agent-tools/advisor.mjs +1 -1
  24. package/agent-tools/async-discard.mjs +40 -5
  25. package/agent-tools/async-settle.mjs +3 -1
  26. package/agent-tools/audit-block.mjs +5 -3
  27. package/agent-tools/batch-gate.mjs +198 -0
  28. package/agent-tools/batch-lifecycle.mjs +216 -36
  29. package/agent-tools/batch-paths.mjs +20 -20
  30. package/agent-tools/batch-skeleton.mjs +87 -0
  31. package/agent-tools/batch.mjs +11 -3
  32. package/agent-tools/checkpoint.mjs +1 -1
  33. package/agent-tools/context.mjs +28 -5
  34. package/agent-tools/design-slots.mjs +20 -7
  35. package/agent-tools/doc-check.mjs +75 -0
  36. package/agent-tools/eng.mjs +5 -3
  37. package/agent-tools/manifest.mjs +18 -18
  38. package/agent-tools/parent-channel.mjs +3 -3
  39. package/agent-tools/review-facts.mjs +2 -2
  40. package/agent-tools/settings.mjs +1 -1
  41. package/agent-tools/spawn-gates.mjs +108 -47
  42. package/agent-tools/ssh-session.mjs +486 -0
  43. package/agent-tools/ssh-transport.mjs +324 -0
  44. package/agent-tools/ssh-vault.mjs +291 -0
  45. package/agent-tools/subagent-scheduler.mjs +6 -5
  46. package/agent-tools/subagent-spawn.mjs +18 -11
  47. package/agent-tools/subagent.mjs +17 -9
  48. package/agent-tools/task.mjs +1 -1
  49. package/agent-tools/timer.mjs +8 -2
  50. package/agent-tools/zero-write-watch.mjs +2 -2
  51. package/agent-tools.mjs +3 -2
  52. package/attachments.mjs +4 -4
  53. package/browser/cdp.mjs +2 -2
  54. package/browser/launch.mjs +50 -4
  55. package/browser/snapshot.mjs +1 -1
  56. package/config-defaults.mjs +92 -0
  57. package/config-io.mjs +7 -2
  58. package/config-normalize.mjs +109 -0
  59. package/config-watch.mjs +2 -2
  60. package/config.mjs +88 -190
  61. package/declaration.mjs +5 -1
  62. package/doc-check/anchors.mjs +142 -0
  63. package/doc-check/index.mjs +210 -0
  64. package/doc-check/version.mjs +84 -0
  65. package/doc-check/width.mjs +132 -0
  66. package/file-refs.mjs +6 -6
  67. package/generate-title.mjs +1 -1
  68. package/hooks.mjs +1 -1
  69. package/i18n.mjs +13 -1
  70. package/ledger-cmd.mjs +314 -62
  71. package/ledger-db.mjs +122 -31
  72. package/ledger-migrate.mjs +18 -12
  73. package/ledger-read.mjs +6 -7
  74. package/ledger-tools.mjs +138 -77
  75. package/ledger.mjs +34 -62
  76. package/log.mjs +17 -3
  77. package/manifest-discovery.mjs +21 -18
  78. package/manifest-schema.mjs +17 -14
  79. package/manifest.mjs +37 -22
  80. package/mcp/transport-stdio.mjs +1 -1
  81. package/mcp.mjs +2 -2
  82. package/memory/core.mjs +1 -1
  83. package/memory/scan.mjs +1 -1
  84. package/memory/sweep.mjs +1 -1
  85. package/model-specs.mjs +7 -7
  86. package/notify-policy.mjs +6 -6
  87. package/package.json +2 -1
  88. package/permission.mjs +21 -1
  89. package/prompts/advisor-design.md +53 -23
  90. package/prompts/advisor-round1.md +34 -14
  91. package/prompts/advisor-round2.md +32 -19
  92. package/prompts/advisor-round3.md +34 -21
  93. package/prompts/common.md +148 -79
  94. package/prompts/consult-base.md +14 -8
  95. package/prompts/discipline-engineering.md +70 -66
  96. package/prompts/discipline-normal.md +86 -77
  97. package/prompts/persona-coder.md +6 -6
  98. package/prompts/persona-eng-coder.md +19 -18
  99. package/prompts/persona-eng-designer.md +40 -31
  100. package/prompts/persona-engineering.md +124 -92
  101. package/prompts/persona-explore.md +2 -2
  102. package/prompts/persona-normal.md +20 -17
  103. package/prompts/persona-plan.md +7 -7
  104. package/prompts/subagent-base.md +4 -4
  105. package/provider/anthropic.mjs +4 -2
  106. package/provider/core.mjs +4 -2
  107. package/provider/errors.mjs +8 -0
  108. package/provider/google.mjs +8 -1
  109. package/provider/normalize.mjs +42 -12
  110. package/provider/responses-request.mjs +4 -3
  111. package/provider/responses.mjs +2 -2
  112. package/provider/wait-status.mjs +1 -1
  113. package/provider-flows.mjs +1 -1
  114. package/queued.mjs +2 -2
  115. package/rules.mjs +4 -4
  116. package/session-gc.mjs +1 -1
  117. package/session-lifecycle.mjs +7 -7
  118. package/session-slot-write.mjs +2 -2
  119. package/session-slots-manifest.mjs +1 -1
  120. package/session.mjs +1 -1
  121. package/shell-candidates.mjs +2 -2
  122. package/shell-identity.mjs +1 -1
  123. package/team.mjs +63 -5
  124. package/think-off.mjs +3 -3
  125. package/token-ttl.mjs +1 -1
  126. package/token-window.mjs +1 -1
  127. package/tool-docs/bash.md +1 -1
  128. package/tool-docs/batch.md +1 -1
  129. package/tool-docs/browser.md +1 -1
  130. package/tool-docs/doc_check.md +54 -0
  131. package/tool-docs/fetch.md +3 -2
  132. package/tool-docs/ledger.md +7 -5
  133. package/tool-docs/manifest.md +14 -2
  134. package/tool-docs/ssh.md +51 -0
  135. package/tool-docs/subagent.md +6 -9
  136. package/tool-docs/timer.md +2 -1
  137. package/tool-docs/websearch.md +3 -1
  138. package/tools/browser.mjs +1 -1
  139. package/tools/execute.mjs +1 -1
  140. package/tools/git-run.mjs +2 -2
  141. package/tools/git.mjs +8 -8
  142. package/tools/index.mjs +3 -0
  143. package/tools/ops.mjs +1 -1
  144. package/tools/process-tree.mjs +2 -2
  145. package/tools/ssh-askpass.mjs +35 -0
  146. package/tools/ssh.mjs +105 -0
  147. package/tools/web-deepseek.mjs +172 -0
  148. package/tools/web.mjs +29 -4
  149. package/vision-reader.mjs +4 -4
@@ -0,0 +1,324 @@
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 { homedir } from "node:os"
21
+ import { fileURLToPath } from "node:url"
22
+ import { configDir } from "../config.mjs"
23
+ import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
24
+ import { nodeChildEnv } from "../tools/node-child.mjs"
25
+
26
+ /** 静默窗(§6.22 ③):到点无新输出 ⇒ 判定就绪。 */
27
+ export const SSH_SETTLE_MS = 800
28
+ /** 硬超时(§6.22 ③/⑥):到点返回部分输出 + 可读句。 */
29
+ export const SSH_TIMEOUT_MS = 10_000
30
+ /** 单次返回帽(§6.22 ⑥):保尾 + 截断注记;全量恒在 log。 */
31
+ export const SSH_RETURN_CAP = 8000
32
+ /** 保活缺省(§6.22 ④:`ServerAliveInterval=30`;测试态时间缝按 ms 传入后换算)。 */
33
+ export const SSH_KEEPALIVE_MS = 30_000
34
+ /** 秘密环境变量名(§6.22 ②「秘密传递」行)。 */
35
+ export const SECRET_ENV = "TC_SSH_SECRET"
36
+ /** 内存缓冲保留量(仅覆盖返回帽;全量恒在 log)。 */
37
+ export const BUF_KEEP_CHARS = 64 * 1024
38
+
39
+ /** 测试缝(单元测试沙箱——生产从不调用;`_setBgTaskLogDirForTest` 同式:一缝管三落点
40
+ * (log 目录 ∥ askpass 包装目录 ∥ 预填档)。`prefixArgs` = 夹具替身用(`<node> <夹具脚本> <ssh 参数…>`)。 */
41
+ let _sshBinary = null
42
+ let _sshArgPrefix = []
43
+ let _sshConfigDir = null
44
+ export function _setSshBinaryForTest(cmd, prefixArgs = []) {
45
+ _sshBinary = cmd
46
+ _sshArgPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
47
+ }
48
+ export function _setSshConfigDirForTest(dir) { _sshConfigDir = dir }
49
+ export const sshBinary = () => _sshBinary ?? "ssh"
50
+ export const sshArgPrefix = () => _sshArgPrefix
51
+ export const sshConfigDir = () => _sshConfigDir ?? configDir
52
+
53
+ // ─── askpass 链(包装档 ∥ 环境 ∥ 命令形)────────────────────────────────────
54
+
55
+ /** 包装档运行期写入(内容零秘密——仅路径;Win `.cmd` ∥ POSIX `.sh` 0700)。 */
56
+ export function writeAskpassWrapper(id) {
57
+ const dir = `${sshConfigDir()}/ssh-askpass`
58
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
59
+ const helper = fileURLToPath(new URL("../tools/ssh-askpass.mjs", import.meta.url))
60
+ const node = process.execPath
61
+ const win = process.platform === "win32"
62
+ const file = `${dir}/askpass-${process.pid}-${id}.${win ? "cmd" : "sh"}`
63
+ // `.cmd` 首行 `@echo off`:cmd 中介默认回显命令行——回显会占掉 ssh 读回的**首行**
64
+ // (`readpass.c` 只取首行 ⇒ 口令错位为空串);POSIX `.sh` 无此面。
65
+ writeFileSync(file, win ? `@echo off\r\n"${node}" "${helper}" %*\r\n` : `#!/bin/sh\nexec "${node}" "${helper}" "$@"\n`, { mode: 0o700 })
66
+ try { chmodSync(file, 0o700) } catch { /* win32 无 POSIX mode——尽力面 */ }
67
+ return file
68
+ }
69
+
70
+ /** 包装档清理(关闭 / 终结时尽力面——内容零秘密,残留无实害)。 */
71
+ export function unlinkAskpass(entry) {
72
+ try { if (entry.askpassFile) unlinkSync(entry.askpassFile) } catch { /* 尽力面 */ }
73
+ entry.askpassFile = null
74
+ }
75
+
76
+ /** ssh 子进程环境:askpass 链 + 秘密(无秘密时**显式清除**——防父进程残留变量误喂)。 */
77
+ export function buildSshEnv({ wrapper, secret }) {
78
+ const env = { ...process.env, SSH_ASKPASS: wrapper, SSH_ASKPASS_REQUIRE: "force" }
79
+ // 占位 DISPLAY:OpenSSH < 8.4 无 `SSH_ASKPASS_REQUIRE`(RHEL 8 / CentOS 8 代)——无 DISPLAY 时
80
+ // askpass 不被放行(`readpass.c` allow_askpass 判据);占位后新旧两代同走 askpass(§6.22 NS1 注)。
81
+ if (!env.DISPLAY) env.DISPLAY = ":0"
82
+ if (secret != null) env[SECRET_ENV] = secret
83
+ else delete env[SECRET_ENV]
84
+ return nodeChildEnv(env)
85
+ }
86
+
87
+ /** `~` 展开(`identityFile` 记录 / 参数——`~/.ssh/id_ed25519` 同式;非 `~` 开头原样)。 */
88
+ function expandHome(file) {
89
+ const raw = String(file ?? "")
90
+ if (!/^~(?=$|[\\/])/.test(raw)) return raw
91
+ return `${homedir()}${raw.slice(1)}`
92
+ }
93
+
94
+ /** 会话命令形(§6.22 ⑤):`-tt` 强制远端 PTY + 保活三件 + 单提示;`port`/`user` 未给 ⇒ 不下发。
95
+ * `identityFile` 显式给出 / 记录有值 ⇒ `-i <路径>`(追加候选身份,不夺本机配置与 agent 的既有尝试序列)。
96
+ * 预探 = `NumberOfPasswordPrompts=0`(password ∥ keyboard-interactive 两径同被短路——
97
+ * `sshconnect2.c` `userauth_passwd` / `userauth_kbdint` 两处同判 `attempt++ >= n`;私钥口令提示
98
+ * 亦被短路:`load_identity_file` 循环 `i <= number_of_password_prompts`);带密 = 1(单提示)。 */
99
+ export function buildSshArgs({ host, user, port, probe, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
100
+ const keepAliveS = Math.max(1, Math.round((Number(keepAliveMs) || SSH_KEEPALIVE_MS) / 1000))
101
+ return [
102
+ "-tt",
103
+ "-o", "ConnectTimeout=10",
104
+ "-o", `ServerAliveInterval=${keepAliveS}`,
105
+ "-o", "ServerAliveCountMax=4",
106
+ "-o", `NumberOfPasswordPrompts=${probe ? 0 : 1}`,
107
+ ...(port ? ["-p", String(port)] : []),
108
+ ...(identityFile ? ["-i", expandHome(identityFile)] : []),
109
+ user ? `${user}@${host}` : host,
110
+ ]
111
+ }
112
+
113
+ // ─── 遮蔽(§6.22 ⑦)────────────────────────────────────────────────────────
114
+
115
+ /** 秘密遮蔽过滤器:字面移除 + `len-1` 尾缓冲(跨 chunk 切分不泄;尾至沉降冲涮)。 */
116
+ export function createMask() {
117
+ const secrets = []
118
+ let tail = ""
119
+ let keep = 0
120
+ return {
121
+ add(secret) {
122
+ const s = String(secret ?? "")
123
+ if (!s || secrets.includes(s)) return
124
+ secrets.push(s)
125
+ keep = Math.max(keep, s.length - 1)
126
+ },
127
+ push(text) {
128
+ let out = tail + String(text ?? "")
129
+ for (const s of secrets) out = out.split(s).join("")
130
+ if (out.length <= keep) { tail = out; return "" }
131
+ const emit = out.slice(0, out.length - keep)
132
+ tail = out.slice(out.length - keep)
133
+ return emit
134
+ },
135
+ /** 沉降时冲涮尾缓冲:**只放行非秘密前缀段**(尾内最长可疑后缀一律扣下——整尾 ∥ 移位前缀
136
+ * 都不出返回面;扣下的至多 `秘密长-1` 字符,代价 < 前缀外泄)。 */
137
+ flush() {
138
+ const t = tail
139
+ tail = ""
140
+ if (!t) return ""
141
+ let cut = 0
142
+ for (const s of secrets) {
143
+ const max = Math.min(t.length, s.length - 1)
144
+ for (let L = 1; L <= max; L++) if (t.endsWith(s.slice(0, L))) cut = Math.max(cut, L)
145
+ }
146
+ return cut ? t.slice(0, t.length - cut) : t
147
+ },
148
+ }
149
+ }
150
+
151
+ // ─── 子进程(起跑 ∥ 双流汇入 ∥ 退出)────────────────────────────────────────
152
+
153
+ /** 悬挂 chunk 双流汇入(遮蔽 → log ∥ 缓冲 ∥ 面板 → 唤醒沉降等待者)。 */
154
+ function handleChunk(entry, text) {
155
+ if (!text) return
156
+ entry.bytes += text.length
157
+ try { entry.logStream?.write(text) } catch { /* log 写失败不进语义——尽力面 */ }
158
+ entry.buf += text
159
+ if (entry.buf.length > BUF_KEEP_CHARS) {
160
+ entry.buf = entry.buf.slice(entry.buf.length - BUF_KEEP_CHARS)
161
+ entry.bufDropped = true
162
+ }
163
+ entry._notify?.(text)
164
+ entry.lastDataAt = Date.now()
165
+ for (const w of [...entry._waiters]) w("data")
166
+ }
167
+
168
+ /** 子进程挂载(双流解码 ∥ 退出 ∥ spawn 错误——探连与会话同径)。 */
169
+ export function attachChild(entry, child) {
170
+ entry.child = child
171
+ const outDecoder = makeDecoder()
172
+ const errDecoder = makeDecoder()
173
+ const feed = (d) => handleChunk(entry, entry.mask.push(sanitizeOutput(d)))
174
+ const flush = () => {
175
+ const rest = sanitizeOutput(outDecoder(Buffer.alloc(0), true)) + sanitizeOutput(errDecoder(Buffer.alloc(0), true))
176
+ if (rest) feed(Buffer.from(rest, "utf8"))
177
+ }
178
+ child.stdout.on("data", (d) => feed(outDecoder(d)))
179
+ child.stderr.on("data", (d) => feed(errDecoder(d)))
180
+ // stdin 无 error 监听 ⇒ 对端刚死时写入的异步 EPIPE 会以未处理 'error' 事件逸出(uncaughtException)。
181
+ // 同族先例:CLI `tui/clipboard.mjs` ∥ `mcp/transport-stdio.mjs` 同式吞 EPIPE。
182
+ child.stdin?.on("error", () => { /* 写失败由 send ∥ secret_input 的调用面回读;此处仅防未处理事件 */ })
183
+ child.on("error", (error) => {
184
+ if (entry.child !== child) return // 旧进程(已被重起替换)——判定面不采
185
+ entry.spawnError = error?.message ?? String(error)
186
+ for (const w of [...entry._waiters]) w("exit")
187
+ })
188
+ child.on("exit", (code, exitSignal) => {
189
+ if (entry.child !== child) return // 旧进程(探连 → 带密会话的重起面)——事件不污染新连接判定
190
+ entry.exitInfo = { code, signal: exitSignal }
191
+ flush()
192
+ // 已就绪(会话态)⇒ 远端断开:终态 + 墓碑(E5);探连期的退出由 connect 判定径处置(不在此收尾)。
193
+ if (entry.state === "ready" && entry.closing !== true) entry.onRemoteExit?.(entry)
194
+ for (const w of [...entry._waiters]) w("exit")
195
+ })
196
+ }
197
+
198
+ /** 起跑一条 ssh(探连 ∥ 带密会话同径)。重起(探连 → 带密会话)⇒ 判定面复位:exitInfo ∥
199
+ * spawnError 属**上一次**进程(残留会让新连接的成功判定被误读成「已退出」)。 */
200
+ export function spawnSession(entry, { user, port, secret, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
201
+ entry.exitInfo = null
202
+ entry.spawnError = null
203
+ const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null, identityFile, keepAliveMs })], {
204
+ stdio: ["pipe", "pipe", "pipe"],
205
+ windowsHide: true,
206
+ detached: process.platform !== "win32",
207
+ env: buildSshEnv({ wrapper: entry.askpassFile, secret }),
208
+ })
209
+ attachChild(entry, child)
210
+ return child
211
+ }
212
+
213
+ // ─── 沉降(§6.22 ③)────────────────────────────────────────────────────────
214
+
215
+ function promptHit(entry) {
216
+ return Boolean(entry.promptRe && entry.promptRe.test(entry.buf))
217
+ }
218
+
219
+ /** 等到「就绪判据」成立:提示符命中 ∥ 静默窗到点 ∥ 硬超时 ∥ 退出 ∥ 中止(返回 reason)。 */
220
+ export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null } = {}) {
221
+ return new Promise((resolve) => {
222
+ let done = false
223
+ let quietTimer = null
224
+ const deadline = Date.now() + Math.max(1, Number(timeoutMs) || SSH_TIMEOUT_MS)
225
+ const finish = (reason) => {
226
+ if (done) return
227
+ done = true
228
+ clearTimeout(quietTimer)
229
+ clearTimeout(deadlineTimer)
230
+ signal?.removeEventListener("abort", onAbort)
231
+ entry._waiters.delete(onEvent)
232
+ resolve(reason)
233
+ }
234
+ const arm = () => {
235
+ clearTimeout(quietTimer)
236
+ quietTimer = setTimeout(() => finish(promptHit(entry) ? "prompt" : "silence"), SSH_SETTLE_MS)
237
+ }
238
+ const onEvent = (kind) => {
239
+ if (kind === "exit") return finish("exited")
240
+ if (promptHit(entry)) return finish("prompt")
241
+ arm()
242
+ }
243
+ const onAbort = () => finish("aborted")
244
+ const deadlineTimer = setTimeout(() => finish("timeout"), Math.max(0, deadline - Date.now()))
245
+ if (entry.state === "dead" || entry.state === "closed") return finish("exited")
246
+ if (signal?.aborted) return finish("aborted")
247
+ signal?.addEventListener("abort", onAbort, { once: true })
248
+ entry._waiters.add(onEvent)
249
+ onEvent("data") // 起点:先查提示符,否则起静默窗(「send 后 800 ms 无新输出」从本刻起算)
250
+ })
251
+ }
252
+
253
+ /** 就绪结论注记(§6.22 ③:静默 ∥ 提示符 ∥ 超时——超时带 id / host / 已等时长,不静默挂起)。 */
254
+ export function settleNote(entry, reason, waitedMs) {
255
+ if (reason === "prompt") return "settled (prompt matched)"
256
+ if (reason === "silence") return "settled (silence window)"
257
+ 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)`
258
+ if (reason === "aborted") return `(interrupted — partial output above; the session stays open: ${idLabel(entry)} @ ${entry.host})`
259
+ if (reason === "exited") return `${idLabel(entry)} @ ${entry.host} ${entry.state === "ready" ? "ended (the remote closed the connection)" : "has stopped"}`
260
+ return String(reason)
261
+ }
262
+
263
+ // ─── 输出面(缓冲 ∥ 帽 ∥ 状态行)────────────────────────────────────────────
264
+
265
+ export const idLabel = (entry) => `ssh#${entry.id}`
266
+
267
+ /** 取出未读输出(遮蔽尾缓冲先冲涮)。 */
268
+ export function drain(entry) {
269
+ const flushed = entry.mask.flush()
270
+ if (flushed) entry.buf += flushed
271
+ const text = entry.buf
272
+ entry.buf = ""
273
+ const dropped = entry.bufDropped === true
274
+ entry.bufDropped = false
275
+ return { text, dropped }
276
+ }
277
+
278
+ /** 返回帽(保尾 + 截断注记——§6.22 ⑥)。 */
279
+ export function capText(text, max = SSH_RETURN_CAP, entry = null) {
280
+ if (text.length <= max) return text
281
+ const omitted = text.length - max
282
+ const where = entry?.logPath ? ` — full output in the log: ${entry.logPath}` : ""
283
+ return `[... first ${omitted} chars omitted (return cap ${max})${where}]\n${text.slice(text.length - max)}`
284
+ }
285
+
286
+ /** 状态行冠形(§6.22 动作表末行):每返回首行。 */
287
+ export function statusLine(entry) {
288
+ const idle = Math.max(0, Math.round((Date.now() - entry.lastActivityAt) / 1000))
289
+ return `[ssh (${idLabel(entry)}) ${entry.host} — ${entry.state} · idle ${idle}s]`
290
+ }
291
+
292
+ /** 体形(空 ⇒ `(no output)`;非空转义——`bash-async.mjs renderDigest` 同式)。 */
293
+ export function bodyText(text) {
294
+ return text.trim() ? escapeXmlLite(text.replace(/\s+$/u, "")) : "(no output)"
295
+ }
296
+
297
+ /** XML 转义(`agent/helpers.mjs escapeXml` 同式;本档为零依赖注入面自持小实现,避免
298
+ * transport → agent 反向依赖)。 */
299
+ function escapeXmlLite(s) {
300
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;")
301
+ }
302
+
303
+ // ─── 失败分类(照服务端分类族:auth ∥ unreachable ∥ fingerprint_changed)─────
304
+
305
+ export function classifyFailure(stderr) {
306
+ const text = String(stderr ?? "")
307
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|host key .* revoked|requested strict checking/i.test(text)) {
308
+ 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" }
309
+ }
310
+ if (/Permission denied|Too many authentication failures|Authentication failed/i.test(text)) {
311
+ return { kind: "auth", message: "authentication rejected" }
312
+ }
313
+ 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)) {
314
+ return { kind: "unreachable", message: "host unreachable or timed out" }
315
+ }
316
+ return null
317
+ }
318
+
319
+ /** 失败句(含 id ∥ host;尾附探测现场末段——诊断面)。 */
320
+ export function failureText(entry, cls, extra = "") {
321
+ const at = `(${idLabel(entry)} @ ${entry.host})`
322
+ const tail = entry.buf.trim() ? `\n${escapeXmlLite(entry.buf.trim().slice(-600))}` : ""
323
+ return `Error: ssh ${cls ? cls.message : "connection failed"} ${at}${extra}${tail}`
324
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * ssh-vault.mjs — SSH 保存凭据单点(保存件读写 ∥ 系统级加密后端 ∥ 清单 / 删除 ∥ 可用性判定 ∥ 凭据窗获取链)。
3
+ * 设计 = `docs/core/design/TOOLS.md` §6.22 ⑪(D-TO19 · 用户裁定 A+B);台账 #1602。
4
+ *
5
+ * 档:`configDir/ssh-credentials.json`(**私档**——用户域,仓库零落点;与 ⑨ 非密档 `ssh-hosts.json`
6
+ * 分档)。形 = `{ version: 1, entries: { "<host[:port]>": { user, scheme, blob, savedAt, lastUsedAt? } } }`
7
+ * ——值只以**密文 blob** 落盘(明文零落盘,NS6)。
8
+ *
9
+ * 加密 = Windows DPAPI(经系统 PowerShell 宿主):加密 `ConvertTo-SecureString -AsPlainText -Force`
10
+ * → `ConvertFrom-SecureString`(无 `-Key` ⇒ DPAPI;Microsoft 文档逐字);解密反之,Marshal 取明文。
11
+ * 通道纪律(§6.22 ⑦):脚本 = 常量字面(**值不入命令行**);值以 base64 封包经子进程 **stdin**,
12
+ * 密文 ∥ 解出明文经 **stdout**(hex ∥ base64——交换面全 ASCII);子进程 env 删 `TC_SSH_SECRET`;
13
+ * 超时有界(到点杀子进程 ⇒ 按解密失败走 fail-soft)。
14
+ *
15
+ * fail-soft(§6.22 ⑪,逐形分判):档缺失 ⇒ 视同无(静默);结构损坏(JSON 不可解析)⇒ 视同无 +
16
+ * **原档原位不动**(下次成功保存整体覆盖重建);不可解密 / 后端抛 ⇒ 条目保留(可能是暂时态)。
17
+ * 删除面只涉本档(⑨ 非密记录不随删)。
18
+ *
19
+ * 观测缝(A 面四格判据落形——S1/S6/N6/B7):模块级读次计数 = **读盘入口调用数**;
20
+ * `_vaultReadsForTest()` / `_resetVaultReadsForTest()`——缺省生产行为不变,用例 `finally` 复位
21
+ * (同族先例 = §6.14 测试态缝)。
22
+ * 模块图:→ ssh-transport.mjs(`sshConfigDir` 测试缝单点)· ../i18n.mjs(窗词面单源)·
23
+ * node:child_process / node:fs——叶子向、无环。
24
+ */
25
+ import { spawn } from "node:child_process"
26
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
27
+ import { join } from "node:path"
28
+ import { t } from "../i18n.mjs"
29
+ import { sshConfigDir } from "./ssh-transport.mjs"
30
+
31
+ /** 保存件档名(与 ⑨ `ssh-hosts.json` 分档)。 */
32
+ export const VAULT_FILE = "ssh-credentials.json"
33
+ /** 档形版本(结构损坏判据之一)。 */
34
+ export const VAULT_VERSION = 1
35
+ /** 后端子进程超时(到点杀子进程 ⇒ 按解密失败走 fail-soft)。 */
36
+ export const VAULT_TIMEOUT_MS = 15_000
37
+
38
+ /** 加密脚本(常量字面——值经 stdin;`ConvertFrom-SecureString` 无 `-Key` ⇒ DPAPI)。 */
39
+ const PS_ENCRYPT = [
40
+ "$ErrorActionPreference = 'Stop'",
41
+ "$b64 = [Console]::In.ReadToEnd().Trim()",
42
+ "$plain = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))",
43
+ "$sec = ConvertTo-SecureString -String $plain -AsPlainText -Force",
44
+ "[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))",
45
+ ].join("\n")
46
+ /** 解密脚本(常量字面——hex 经 stdin;解出明文 base64 经 stdout)。 */
47
+ const PS_DECRYPT = [
48
+ "$ErrorActionPreference = 'Stop'",
49
+ "$hex = [Console]::In.ReadToEnd().Trim()",
50
+ "$sec = ConvertTo-SecureString -String $hex",
51
+ "$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)",
52
+ "try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }",
53
+ "[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain)))",
54
+ ].join("\n")
55
+
56
+ // ─── 观测缝(读次 = 读盘入口调用数)──────────────────────────────────────────
57
+
58
+ let _reads = 0
59
+ export function _vaultReadsForTest() { return _reads }
60
+ export function _resetVaultReadsForTest() { _reads = 0 }
61
+
62
+ // ─── 后端(DPAPI 经系统 PowerShell;测试缝只换命令,生产缺省不变)────────────
63
+
64
+ let _backendCommand = null
65
+ let _backendPrefix = []
66
+ export function _setVaultBackendForTest(command, prefixArgs = []) {
67
+ _backendCommand = command
68
+ _backendPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
69
+ }
70
+ export function _resetVaultBackendForTest() {
71
+ _backendCommand = null
72
+ _backendPrefix = []
73
+ }
74
+
75
+ const backendCommand = () => _backendCommand ?? "powershell.exe"
76
+ /** 可用性单点(§6.22 裁决 ④:本批仅 Windows DPAPI;缺席 ⇒ 保存不可用,连接照常)。 */
77
+ export const vaultAvailable = () => _backendCommand !== null || process.platform === "win32"
78
+
79
+ /** 后端往返(脚本常量 + 输入 stdin → stdout;超时杀进程)。 */
80
+ function runBackend(script, input) {
81
+ return new Promise((resolve, reject) => {
82
+ const env = { ...process.env }
83
+ delete env.TC_SSH_SECRET // 防残留(新增链路零 env;§6.22 ⑦)
84
+ const child = spawn(backendCommand(), [..._backendPrefix, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
85
+ stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env,
86
+ })
87
+ let out = ""
88
+ let err = ""
89
+ let done = false
90
+ const finish = (fn, value) => { if (done) return; done = true; clearTimeout(timer); fn(value) }
91
+ const timer = setTimeout(() => {
92
+ try { child.kill() } catch { /* 尽力面 */ }
93
+ finish(reject, new Error("vault backend timed out"))
94
+ }, VAULT_TIMEOUT_MS)
95
+ timer.unref?.()
96
+ child.stdout.on("data", (d) => { out += d })
97
+ child.stderr.on("data", (d) => { err += d })
98
+ child.on("error", (e) => finish(reject, new Error(`vault backend unavailable: ${e?.message ?? String(e)}`)))
99
+ child.on("exit", (code) => {
100
+ if (code !== 0) return finish(reject, new Error(`vault backend failed (${code}): ${err.trim().slice(0, 200)}`))
101
+ finish(resolve, out.trim())
102
+ })
103
+ child.stdin.on("error", () => { /* 退出竞态——判定面 = exit code */ })
104
+ child.stdin.end(input)
105
+ })
106
+ }
107
+
108
+ /** 加密:值 → hex 密文(值只经 stdin)。 */
109
+ async function encryptValue(value) {
110
+ const b64 = Buffer.from(String(value), "utf8").toString("base64")
111
+ const hex = await runBackend(PS_ENCRYPT, b64)
112
+ if (!/^[0-9a-f]+$/i.test(hex)) throw new Error("vault backend returned an unexpected ciphertext")
113
+ return hex
114
+ }
115
+
116
+ /** 解密:hex 密文 → 明文(坏密文 / 后端抛 ⇒ reject——调用面走 fail-soft)。 */
117
+ async function decryptBlob(blob) {
118
+ const b64 = await runBackend(PS_DECRYPT, String(blob ?? "").trim())
119
+ return Buffer.from(b64, "base64").toString("utf8")
120
+ }
121
+
122
+ // ─── 档(读 / 写 / 结构判定)────────────────────────────────────────────────
123
+
124
+ const vaultFile = () => join(sshConfigDir(), VAULT_FILE)
125
+ /** 读键单源(`host[:port]`,缺省 22;⑨ 记录面同引用)。 */
126
+ export const hostKey = (host, port) => (port && Number(port) !== 22 ? `${host}:${port}` : String(host))
127
+
128
+ /** 读盘入口(**唯一读次计数点**)。三态 = ok ∥ missing(静默视同无)∥ damaged(视同无,原档不动)。 */
129
+ function readVaultFile() {
130
+ _reads += 1
131
+ let raw
132
+ try { raw = readFileSync(vaultFile(), "utf8") } catch (e) {
133
+ return e?.code === "ENOENT" ? { state: "missing" } : { state: "damaged", unreadable: true }
134
+ }
135
+ try {
136
+ const parsed = JSON.parse(raw)
137
+ const entries = parsed?.entries
138
+ if (parsed?.version !== VAULT_VERSION || !entries || typeof entries !== "object" || Array.isArray(entries)) {
139
+ return { state: "damaged" }
140
+ }
141
+ return { state: "ok", entries }
142
+ } catch { return { state: "damaged" } }
143
+ }
144
+
145
+ /** 写盘单点(结构损坏的重建 = 调用面给空表整体覆盖——原档在此之前不动)。 */
146
+ function writeVaultFile(entries) {
147
+ mkdirSync(sshConfigDir(), { recursive: true })
148
+ const body = JSON.stringify({ version: VAULT_VERSION, entries }, null, 2)
149
+ writeFileSync(vaultFile(), body, { encoding: "utf8", mode: 0o600 })
150
+ try { chmodSync(vaultFile(), 0o600) } catch { /* win32 无 POSIX mode——尽力面 */ }
151
+ }
152
+
153
+ /**
154
+ * 查条目(单次读盘):条目 `user` 与「本次有效 `user`」不符 ⇒ 视同无(⑪)。
155
+ * @returns {{ state: "ok", key: string, entry: object } | { state: "missing"|"damaged"|"unreadable" }}
156
+ */
157
+ export function getEntry(host, port, user) {
158
+ const read = readVaultFile()
159
+ if (read.state !== "ok") return { state: read.state }
160
+ const key = hostKey(host, port)
161
+ const entry = read.entries[key]
162
+ if (!entry || typeof entry !== "object") return { state: "missing" }
163
+ if (!user || entry.user !== user) return { state: "missing" }
164
+ return { state: "ok", key, entry }
165
+ }
166
+
167
+ /**
168
+ * 复用终段:读条目 → 解密。失败形分判(调用面各自回落窗 / 可读句):
169
+ * `missing`(静默)· `damaged`(可读句)· `unavailable`(后端缺席)· `undecryptable`(条目保留)。
170
+ * @returns {Promise<{ state: "ok", key: string, value: string } | { state: string }>}
171
+ */
172
+ export async function resolveSecret(host, port, user) {
173
+ const got = getEntry(host, port, user)
174
+ if (got.state !== "ok") return { state: got.state }
175
+ if (!vaultAvailable()) return { state: "unavailable" }
176
+ try {
177
+ const value = await decryptBlob(got.entry.blob)
178
+ if (!value.length) return { state: "undecryptable" } // 解出空值 = 不可用(不当空口令提交)
179
+ return { state: "ok", key: got.key, value }
180
+ } catch { return { state: "undecryptable" } }
181
+ }
182
+
183
+ /**
184
+ * 保存(窗内勾选且认证成功后调用):加密 → 整体写档(结构损坏的旧档在此被覆盖重建)。
185
+ * @returns {Promise<{ ok: true, key: string } | { ok: false, reason: string }>}
186
+ */
187
+ export async function saveEntry(host, port, { user, value } = {}) {
188
+ if (!vaultAvailable()) return { ok: false, reason: "unavailable" }
189
+ if (!String(value ?? "").length) return { ok: false, reason: "empty" }
190
+ let blob
191
+ try { blob = await encryptValue(value) } catch { return { ok: false, reason: "backend" } }
192
+ try {
193
+ const read = readVaultFile()
194
+ const entries = read.state === "ok" ? { ...read.entries } : {}
195
+ const key = hostKey(host, port)
196
+ entries[key] = { user: user ?? null, scheme: "dpapi", blob, savedAt: new Date().toISOString() }
197
+ writeVaultFile(entries)
198
+ return { ok: true, key }
199
+ } catch { return { ok: false, reason: "write" } }
200
+ }
201
+
202
+ /** 删除(`forget`):条目不存在 ⇒ 幂等(existed:false);结构损坏 ⇒ 原档不动(可读句)。 */
203
+ export function deleteEntry(host, port) {
204
+ const key = hostKey(host, port)
205
+ const read = readVaultFile()
206
+ if (read.state === "missing") return { ok: true, existed: false }
207
+ if (read.state !== "ok") return { ok: false, reason: "damaged" }
208
+ if (!Object.hasOwn(read.entries, key)) return { ok: true, existed: false }
209
+ const entries = { ...read.entries }
210
+ delete entries[key]
211
+ try { writeVaultFile(entries) } catch { return { ok: false, reason: "write" } }
212
+ return { ok: true, existed: true }
213
+ }
214
+
215
+ /** `lastUsedAt` = 复用成功时更新;写失败 = 增益面静默(⑪)。 */
216
+ export function touchEntry(host, port) {
217
+ const read = readVaultFile()
218
+ if (read.state !== "ok") return
219
+ const key = hostKey(host, port)
220
+ const entry = read.entries[key]
221
+ if (!entry || typeof entry !== "object") return
222
+ try { writeVaultFile({ ...read.entries, [key]: { ...entry, lastUsedAt: new Date().toISOString() } }) } catch { /* 增益面静默 */ }
223
+ }
224
+
225
+ // ─── 凭据获取链(§6.22 ⑧:`ctx.onSecretInput` 窗)────────────────────────────
226
+
227
+ /** 凭据窗(§6.22 ⑧):无 UI ⇒ 可读错(不挂起、不静默——`question.mjs` 无 UI 抛例同旨);词面 = i18n 键。
228
+ * 端侧回调自身拒绝(CLI `refuseSecretInput()` 形:抛可读句)⇒ 同族直出(`noUi` 标记 = 失败句不另加前缀)。
229
+ * `offerSave` ⇒ `fields` += `save`(`checkbox: true`——缺省不勾(opt-in);提交值 = 布尔;核侧严格判 `=== true`)。
230
+ * 拆档由 = 会话面超 500 顾问线(拆前 ≈513 行;设计 490 再拆线 ⇒ 按落点表候选拆分「凭据获取链迁入 `ssh-vault.mjs`」)。 */
231
+ export async function askCredential(ctx, { host, user, port, sessionId, offerSave = false }) {
232
+ if (typeof ctx?.onSecretInput !== "function") {
233
+ const err = new Error(t("ssh.no_ui"))
234
+ err.noUi = true // 可读句直出(catch 面不另加前缀——失败句单源 = 核字典)
235
+ throw err
236
+ }
237
+ const fields = [
238
+ { name: "user", masked: false, required: false, prefill: user ?? "" },
239
+ { name: "password", masked: true, required: true, prefill: "" },
240
+ ]
241
+ if (offerSave) fields.push({ name: "save", masked: false, required: false, prefill: false, checkbox: true })
242
+ let res
243
+ try {
244
+ res = await ctx.onSecretInput({
245
+ kind: "ssh-credential",
246
+ sessionId: sessionId != null ? `ssh#${sessionId}` : null,
247
+ host, user: user ?? null, port: port ?? null,
248
+ fields,
249
+ keys: { title: "ssh.credentialTitle", prompt: "ssh.credentialPrompt" },
250
+ })
251
+ } catch (e) {
252
+ // 端侧拒绝(无交互面 / 载荷成形外)⇒ 同族可读句直出(不裹「connect failed」前缀)。
253
+ const err = new Error(e?.message ?? String(e))
254
+ err.noUi = true
255
+ throw err
256
+ }
257
+ if (!res || typeof res !== "object") return null
258
+ return res
259
+ }
260
+
261
+ // ─── 保存凭据管理面(§6.22 ⑫:`credentials` 只读 ∥ `forget` 写——核单点,三端同源)────────────
262
+
263
+ /** `credentials`(只读免审):host / user / 保存时间 / 最近使用——**值零出现**(清单不含 blob)。 */
264
+ export function credentialsSsh() {
265
+ const read = readVaultFile()
266
+ if (read.state === "missing") return { text: "(no saved ssh credentials)" }
267
+ if (read.state !== "ok") return { text: "saved ssh credentials: the store is damaged — the file was left untouched; delete it manually to reset the store" }
268
+ const rows = Object.entries(read.entries)
269
+ .map(([key, e]) => ({ key, user: e?.user ?? null, savedAt: e?.savedAt ?? null, lastUsedAt: e?.lastUsedAt ?? null }))
270
+ .sort((a, b) => a.key.localeCompare(b.key))
271
+ if (rows.length === 0) return { text: "(no saved ssh credentials)" }
272
+ const head = vaultAvailable()
273
+ ? "saved ssh credentials (encrypted with this Windows user account):"
274
+ : "saved ssh credentials (the credential store is not available on this platform):"
275
+ const lines = rows.map((r) => {
276
+ const saved = r.savedAt ? ` \u00b7 saved ${r.savedAt}` : ""
277
+ const used = r.lastUsedAt ? ` \u00b7 last used ${r.lastUsedAt}` : ""
278
+ return `[${r.key}]${r.user ? ` user ${r.user}` : ""}${saved}${used}`
279
+ })
280
+ return { text: `${head}\n${lines.join("\n")}` }
281
+ }
282
+
283
+ /** `forget`(写动作——过既有审批门):按 `host` + 可选 `port` 删条目;幂等;值零出现。
284
+ * 删除 ⇒ 下次连接回落凭据窗(S8);只涉保存档(⑨ 非密记录不随删)。 */
285
+ export function forgetSsh({ host, port } = {}) {
286
+ const key = hostKey(host, port)
287
+ const res = deleteEntry(host, port)
288
+ if (!res.ok) return { error: `could not update the saved-credential store (${res.reason}) — the file was left untouched` }
289
+ if (!res.existed) return { text: `no saved credential for ${key} — nothing to forget (the call is idempotent)` }
290
+ return { text: `saved credential for ${key} deleted — the next connect asks in the credential window` }
291
+ }
@@ -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)