@thincoder/core 0.10.12 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/advisor/messages.mjs +4 -2
  3. package/advisor/notice.mjs +1 -1
  4. package/advisor/project-context.mjs +121 -11
  5. package/agent/completion.mjs +13 -1
  6. package/agent/digest-account.mjs +73 -46
  7. package/agent/dispatch-run.mjs +1 -1
  8. package/agent/dispatch.mjs +29 -21
  9. package/agent/family-tools.mjs +9 -5
  10. package/agent/helpers.mjs +1 -94
  11. package/agent/offload.mjs +88 -0
  12. package/agent/record-results.mjs +1 -6
  13. package/agent/run-stages.mjs +74 -49
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +6 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +35 -3
  18. package/agent/write-face.mjs +99 -0
  19. package/agent/write-gate.mjs +354 -5
  20. package/agent-tools/advisor.mjs +127 -11
  21. package/agent-tools/async-settle.mjs +2 -2
  22. package/agent-tools/bash-async.mjs +8 -2
  23. package/agent-tools/batch-closeout.mjs +206 -0
  24. package/agent-tools/batch-gate.mjs +287 -73
  25. package/agent-tools/batch-ledger-bind.mjs +183 -0
  26. package/agent-tools/batch-ledger-link.mjs +73 -0
  27. package/agent-tools/batch-lifecycle.mjs +113 -354
  28. package/agent-tools/batch-paths.mjs +45 -40
  29. package/agent-tools/batch-read.mjs +1 -1
  30. package/agent-tools/batch-reopen.mjs +142 -0
  31. package/agent-tools/batch-skeleton.mjs +283 -80
  32. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  33. package/agent-tools/batch.mjs +243 -50
  34. package/agent-tools/context.mjs +6 -3
  35. package/agent-tools/design-slots.mjs +24 -24
  36. package/agent-tools/design-token.mjs +96 -15
  37. package/agent-tools/doc-check.mjs +18 -2
  38. package/agent-tools/eng.mjs +1 -1
  39. package/agent-tools/escalate-async.mjs +3 -3
  40. package/agent-tools/manifest.mjs +8 -5
  41. package/agent-tools/read-history.mjs +5 -5
  42. package/agent-tools/review-facts.mjs +31 -13
  43. package/agent-tools/spawn-gates.mjs +10 -14
  44. package/agent-tools/ssh-session.mjs +120 -111
  45. package/agent-tools/ssh-transfer.mjs +210 -0
  46. package/agent-tools/ssh-transport.mjs +277 -46
  47. package/agent-tools/subagent-actions-query.mjs +1 -1
  48. package/agent-tools/subagent-actions.mjs +1 -1
  49. package/agent-tools/subagent-async.mjs +3 -2
  50. package/agent-tools/subagent-scheduler.mjs +43 -9
  51. package/agent-tools/subagent-spawn.mjs +68 -9
  52. package/agent-tools/subagent.mjs +16 -14
  53. package/agent.mjs +1 -0
  54. package/client-identity.mjs +70 -0
  55. package/config-presets.mjs +5 -3
  56. package/config.mjs +19 -13
  57. package/context.mjs +39 -8
  58. package/declaration.mjs +2 -2
  59. package/doc-check/anchors.mjs +128 -28
  60. package/doc-check/index.mjs +93 -27
  61. package/doc-check/line-refs.mjs +112 -0
  62. package/doc-check/paragraph.mjs +24 -8
  63. package/embedding.mjs +2 -0
  64. package/explore-distill.mjs +1 -1
  65. package/generate-title.mjs +3 -2
  66. package/i18n.mjs +1 -1
  67. package/index-discover.mjs +4 -1
  68. package/ledger-close-proof.mjs +62 -0
  69. package/ledger-cmd.mjs +79 -194
  70. package/ledger-db.mjs +29 -28
  71. package/ledger-migrate.mjs +4 -4
  72. package/ledger-read.mjs +10 -8
  73. package/ledger-surface.mjs +1 -1
  74. package/ledger-tools.mjs +10 -10
  75. package/ledger-variant-notice.mjs +1 -1
  76. package/ledger-write-ops.mjs +373 -0
  77. package/ledger.mjs +2 -2
  78. package/manifest-discovery.mjs +2 -2
  79. package/manifest-schema.mjs +4 -0
  80. package/memory/memory-tool.mjs +5 -2
  81. package/memory/schema.mjs +5 -26
  82. package/model-specs.mjs +6 -5
  83. package/package.json +1 -1
  84. package/path-resolve.mjs +217 -0
  85. package/permission.mjs +4 -0
  86. package/prompt-overlays.mjs +5 -0
  87. package/prompts/advisor-design.md +9 -6
  88. package/prompts/advisor-round1.md +4 -3
  89. package/prompts/advisor-round2.md +6 -5
  90. package/prompts/advisor-round3.md +6 -5
  91. package/prompts/common.md +23 -10
  92. package/prompts/consult-base.md +2 -1
  93. package/prompts/discipline-engineering.md +29 -48
  94. package/prompts/discipline-normal.md +10 -5
  95. package/prompts/persona-eng-coder.md +8 -0
  96. package/prompts/persona-eng-designer.md +22 -12
  97. package/prompts/persona-engineering.md +81 -66
  98. package/prompts/persona-plan.md +2 -1
  99. package/prompts/persona-tester.md +55 -0
  100. package/provider/anthropic.mjs +12 -7
  101. package/provider/core.mjs +21 -5
  102. package/provider/errors.mjs +41 -0
  103. package/provider/google.mjs +25 -9
  104. package/provider/list-models.mjs +4 -3
  105. package/provider/rate.mjs +11 -3
  106. package/provider/responses.mjs +17 -7
  107. package/provider/sse.mjs +15 -4
  108. package/session-identity.mjs +21 -0
  109. package/session-lifecycle.mjs +12 -4
  110. package/session-slots.mjs +9 -14
  111. package/session.mjs +5 -0
  112. package/skip-dirs.mjs +48 -0
  113. package/team-ledger.mjs +4 -4
  114. package/text-budget.mjs +4 -0
  115. package/token-window.mjs +107 -10
  116. package/tool-docs/advisor.md +2 -2
  117. package/tool-docs/apply_patch.md +2 -0
  118. package/tool-docs/bash.md +4 -0
  119. package/tool-docs/batch.md +85 -1
  120. package/tool-docs/browser.md +3 -1
  121. package/tool-docs/code_search.md +3 -1
  122. package/tool-docs/consult_start.md +6 -2
  123. package/tool-docs/consult_stop.md +3 -1
  124. package/tool-docs/delete.md +3 -0
  125. package/tool-docs/doc_check.md +43 -8
  126. package/tool-docs/doc_search.md +5 -1
  127. package/tool-docs/edit.md +22 -1
  128. package/tool-docs/execute.md +7 -1
  129. package/tool-docs/fetch.md +3 -1
  130. package/tool-docs/file_ops.md +3 -1
  131. package/tool-docs/git.md +9 -1
  132. package/tool-docs/goal.md +2 -0
  133. package/tool-docs/grep.md +1 -1
  134. package/tool-docs/hashline_edit.md +2 -0
  135. package/tool-docs/insert_after.md +3 -1
  136. package/tool-docs/ledger.md +5 -5
  137. package/tool-docs/lint.md +1 -0
  138. package/tool-docs/manifest-read.md +2 -1
  139. package/tool-docs/manifest.md +9 -2
  140. package/tool-docs/memory.md +11 -1
  141. package/tool-docs/notify_parent.md +1 -1
  142. package/tool-docs/plan.md +2 -0
  143. package/tool-docs/question.md +1 -1
  144. package/tool-docs/read_history.md +15 -3
  145. package/tool-docs/read_image.md +3 -1
  146. package/tool-docs/recent_changes.md +3 -1
  147. package/tool-docs/repo_outline.md +3 -1
  148. package/tool-docs/settings.md +10 -2
  149. package/tool-docs/skill.md +2 -0
  150. package/tool-docs/ssh.md +30 -17
  151. package/tool-docs/subagent.md +50 -13
  152. package/tool-docs/task.md +2 -0
  153. package/tool-docs/team_ledger.md +1 -1
  154. package/tool-docs/timer.md +7 -0
  155. package/tool-docs/verify.md +13 -1
  156. package/tool-docs/websearch.md +3 -1
  157. package/tool-docs/write.md +2 -0
  158. package/tools/bash.mjs +12 -2
  159. package/tools/edit-batch.mjs +40 -33
  160. package/tools/edit-diff.mjs +69 -1
  161. package/tools/execute.mjs +10 -4
  162. package/tools/file.mjs +17 -27
  163. package/tools/git.mjs +27 -1
  164. package/tools/ops.mjs +2 -1
  165. package/tools/patch.mjs +12 -3
  166. package/tools/process-doc-mark.mjs +139 -0
  167. package/tools/search.mjs +15 -6
  168. package/tools/shared.mjs +24 -1
  169. package/tools/ssh.mjs +48 -7
  170. package/tools/tree.mjs +4 -1
  171. package/tools/write-path.mjs +64 -3
  172. package/traces/trace-store.mjs +4 -3
  173. package/agent/light-round.mjs +0 -63
  174. package/agent/unbooked-pen.mjs +0 -62
@@ -9,26 +9,36 @@
9
9
  *
10
10
  * - askpass 链:包装档(Win `.cmd` ∥ POSIX `.sh` 0700)→ `tools/ssh-askpass.mjs` → stdout;秘密只经
11
11
  * ssh 子进程环境 `TC_SSH_SECRET`(零入参 / 零落盘 / 零回显;环境经 `nodeChildEnv` 单点补 Electron 旗标)。
12
- * - 沉降:静默窗 800 ms ∥ 会话提示符正则命中 ⇒ 就绪;硬超时 10 s ⇒ 部分输出 + 可读句(不静默挂起)。
13
- * - 遮蔽:写入的秘密对**捕获面与日志面**做字面移除(跨 chunk 切分:保留 `len-1` 尾缓冲)。
12
+ * - 终端噪声:有状态剥离(CSI ∥ OSC ∥ 单字符控制三类 + CRLF 归一;状态跨 chunk 携带)——落点 = 传输入口,
13
+ * **先剥离后遮蔽**;日志面 = 模型面同源(D-TO23)。
14
+ * - 就绪判据:四门(待答标记 ∥ 提示符 ∥ 仍在吐 ∥ 硬超时 60 s);静默窗只作去抖(D-TO21)。
15
+ * - 遮蔽:写入的秘密对**捕获面与日志面**做字面移除(比较面 = 同一归一 + 按不可见处切段 ≥ 4)。
14
16
  * - 失败分类族(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
+ * 模块图:→ config.mjs(configDir 缺省)· tools/shared.mjs(解码)· text-budget.mjs(截断本体)·
18
+ * tools/node-child.mjs(nodeChildEnv)· expand-home.mjs(`~` 展开单源——`identityFile` 用)——叶子向、无环
19
+ * (本档不 import `ssh-session.mjs`)。
17
20
  */
18
21
  import { chmodSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
19
22
  import { spawn } from "node:child_process"
20
- import { homedir } from "node:os"
21
23
  import { fileURLToPath } from "node:url"
22
24
  import { configDir } from "../config.mjs"
23
- import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
25
+ import { expandHome } from "../expand-home.mjs"
26
+ import { makeDecoder } from "../tools/shared.mjs"
24
27
  import { nodeChildEnv } from "../tools/node-child.mjs"
28
+ import { capText as capBudgetText, fillMarker } from "../text-budget.mjs"
25
29
 
26
- /** 静默窗(§6.22 ③):到点无新输出 ⇒ 判定就绪。 */
30
+ /** 静默窗(§6.22 ③):**只作去抖**——判尾部这半条标记到没到齐;不再单独构成「已就绪」。 */
27
31
  export const SSH_SETTLE_MS = 800
28
- /** 硬超时(§6.22 ③/⑥):到点返回部分输出 + 可读句。 */
29
- export const SSH_TIMEOUT_MS = 10_000
30
- /** 单次返回帽(§6.22 ⑥):保尾 + 截断注记;全量恒在 log。 */
32
+ /** 硬超时(§6.22 ③/⑥):`send` / `read` / `secret_input` 的本次等待;到点返回部分输出 + 可读句。 */
33
+ export const SSH_TIMEOUT_MS = 60_000
34
+ /** `connect` 首屏等待(§6.22 ③「适用范围」拆两常量——慢设备才拖不动连接)。 */
35
+ export const SSH_CONNECT_SETTLE_MS = 10_000
36
+ /** 单次返回帽(§6.22 ⑥):**返回窗口** = 保头 + 注记 + 保尾;省略中段回填未读缓冲。 */
31
37
  export const SSH_RETURN_CAP = 8000
38
+ /** 帽窗头尾比例(§6.22 ⑥:头 60% ∥ 尾 40%——头段装接口 / ACL,尾段装结束证据)。 */
39
+ const CAP_HEAD_RATIO = 0.6
40
+ /** 遮蔽分段下限(§6.22 ③「`≥ 4` 为选定策略值」:3 字符以下与正常输出撞面过高)。 */
41
+ const MASK_SEG_MIN = 4
32
42
  /** 保活缺省(§6.22 ④:`ServerAliveInterval=30`;测试态时间缝按 ms 传入后换算)。 */
33
43
  export const SSH_KEEPALIVE_MS = 30_000
34
44
  /** 秘密环境变量名(§6.22 ②「秘密传递」行)。 */
@@ -84,13 +94,6 @@ export function buildSshEnv({ wrapper, secret }) {
84
94
  return nodeChildEnv(env)
85
95
  }
86
96
 
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
97
  /** 会话命令形(§6.22 ⑤):`-tt` 强制远端 PTY + 保活三件 + 单提示;`port`/`user` 未给 ⇒ 不下发。
95
98
  * `identityFile` 显式给出 / 记录有值 ⇒ `-i <路径>`(追加候选身份,不夺本机配置与 agent 的既有尝试序列)。
96
99
  * 预探 = `NumberOfPasswordPrompts=0`(password ∥ keyboard-interactive 两径同被短路——
@@ -110,19 +113,111 @@ export function buildSshArgs({ host, user, port, probe, identityFile = null, kee
110
113
  ]
111
114
  }
112
115
 
116
+ // ─── 终端噪声(§6.22 ③「终端噪声」· D-TO23)────────────────────────────────
117
+
118
+ /** 不可见处(遮蔽比较面的切段器——与剥离同一类目:CSI ∥ ESC+1 ∥ 任一控制字符)。 */
119
+ const INVISIBLE_SPLIT = /\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1b[^\x1b]?|[\x00-\x1f\x7f]/
120
+
121
+ /** 有状态剥离器(§6.22 ③):三类序列 CSI ∥ OSC ∥ 单字符控制 + `\r\n`∥裸 `\r` 归 `\n`。
122
+ * 状态跨 chunk 携带(序列被 chunk 边界切开不残留成字面);三类之外的序列(DCS / APC 等)按
123
+ * 单字符控制处理——头字节剥掉,体内字节当可见文本如实交出(设计「表外序列的出口」)。
124
+ * `push` = 返回边界(状态保留);`end` = 收场(硬超时 ∥ close ∥ 会话终结:尾部半条序列丢弃并计一次)。 */
125
+ export function createStripper() {
126
+ let state = "ground" // ground ∥ esc ∥ csi ∥ osc ∥ oscEsc ∥ charset
127
+ let pendingCr = false // chunk 尾的 `\r`(下一片首字为 `\n` ⇒ 仍归一条——跨 chunk 的 CRLF)
128
+ const scan = (text) => {
129
+ const s = String(text ?? "")
130
+ let out = ""
131
+ let i = 0
132
+ while (i < s.length) {
133
+ const ch = s[i]
134
+ if (pendingCr) {
135
+ pendingCr = false
136
+ out += "\n"
137
+ if (ch === "\n") { i += 1; continue }
138
+ }
139
+ if (state === "esc") {
140
+ state = "ground"
141
+ if (ch === "[") state = "csi"
142
+ else if (ch === "]") state = "osc"
143
+ else if (ch === "(" || ch === ")" || ch === "*" || ch === "+") state = "charset"
144
+ else if (ch === "\x1b") state = "esc"
145
+ i += 1
146
+ continue
147
+ }
148
+ if (state === "charset") { state = "ground"; i += 1; continue } // `ESC ( 0` 字符集指定(既有覆盖不回归)
149
+ if (state === "csi") {
150
+ const c = ch.charCodeAt(0)
151
+ if (c >= 0x20 && c <= 0x3f) { i += 1; continue } // 参数 ∥ 中间字节
152
+ if (c >= 0x40 && c <= 0x7e) { state = "ground"; i += 1; continue } // 终字节
153
+ state = "ground" // 类外字 ⇒ 序列作废,本字按常规面重判(不吞可见字节)
154
+ continue
155
+ }
156
+ if (state === "osc") {
157
+ if (ch === "\x07") { state = "ground"; i += 1; continue }
158
+ if (ch === "\x1b") { state = "oscEsc"; i += 1; continue }
159
+ i += 1
160
+ continue
161
+ }
162
+ if (state === "oscEsc") {
163
+ state = "ground"
164
+ if (ch === "\\") { i += 1; continue } // `ESC \` = ST
165
+ state = "osc" // 非 ST ⇒ 本字仍在 OSC 体内(ESC 已丢弃,体内字节本就要丢)
166
+ continue
167
+ }
168
+ if (ch === "\x1b") { state = "esc"; i += 1; continue }
169
+ if (ch === "\r") {
170
+ if (i + 1 >= s.length) { pendingCr = true; i += 1; continue }
171
+ out += "\n"
172
+ i += s[i + 1] === "\n" ? 2 : 1
173
+ continue
174
+ }
175
+ if (ch === "\n" || ch === "\t") { out += ch; i += 1; continue }
176
+ const code = ch.charCodeAt(0)
177
+ if (code < 0x20 || code === 0x7f) { i += 1; continue } // BEL ∥ 退格 ∥ 其余 C0(保留 `\n` / `\t`)
178
+ out += ch
179
+ i += 1
180
+ }
181
+ return out
182
+ }
183
+ return {
184
+ /** 喂一片(返回可交出文本;半条序列留给下一片)。 */
185
+ feed(text) { return scan(text) },
186
+ /** 收场:尾部半条序列丢弃并计一次;其前后的可见字节照常交出。 */
187
+ end(text = "") {
188
+ let out = scan(text)
189
+ if (pendingCr) { out += "\n"; pendingCr = false }
190
+ const dropped = state === "ground" ? 0 : 1
191
+ state = "ground"
192
+ return { text: out, dropped }
193
+ },
194
+ }
195
+ }
196
+
197
+ /** 同一归一(无状态旁路——遮蔽比较面用:秘密值先过控制字节剥离 + CRLF 归一)。 */
198
+ export function normalizeText(text) {
199
+ return createStripper().end(String(text ?? "")).text
200
+ }
201
+
113
202
  // ─── 遮蔽(§6.22 ⑦)────────────────────────────────────────────────────────
114
203
 
115
- /** 秘密遮蔽过滤器:字面移除 + `len-1` 尾缓冲(跨 chunk 切分不泄;尾至沉降冲涮)。 */
204
+ /** 秘密遮蔽过滤器:字面移除 + `len-1` 尾缓冲(跨 chunk 切分不泄;尾至沉降冲涮)。
205
+ * 比较面 = 与剥离同一归一(§6.22 ③):秘密值先归一、再按不可见处切段(段长 ≥ 4)——
206
+ * 裸字面匹配会漏掉夹控制字节 ∥ 被 chunk 切开的回显。 */
116
207
  export function createMask() {
117
208
  const secrets = []
118
209
  let tail = ""
119
210
  let keep = 0
120
211
  return {
121
212
  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)
213
+ const raw = String(secret ?? "")
214
+ if (!raw) return
215
+ const table = [normalizeText(raw), ...raw.split(INVISIBLE_SPLIT).filter((seg) => seg.length >= MASK_SEG_MIN)]
216
+ for (const s of table) {
217
+ if (!s || secrets.includes(s)) continue
218
+ secrets.push(s)
219
+ keep = Math.max(keep, s.length - 1)
220
+ }
126
221
  },
127
222
  push(text) {
128
223
  let out = tail + String(text ?? "")
@@ -132,6 +227,8 @@ export function createMask() {
132
227
  tail = out.slice(out.length - keep)
133
228
  return emit
134
229
  },
230
+ /** 尾缓冲现行内容(**判据面用**:门①② 要看到已达字节——尾在 drain 时释放,判据不得被它推迟)。 */
231
+ peek() { return tail },
135
232
  /** 沉降时冲涮尾缓冲:**只放行非秘密前缀段**(尾内最长可疑后缀一律扣下——整尾 ∥ 移位前缀
136
233
  * 都不出返回面;扣下的至多 `秘密长-1` 字符,代价 < 前缀外泄)。 */
137
234
  flush() {
@@ -150,10 +247,9 @@ export function createMask() {
150
247
 
151
248
  // ─── 子进程(起跑 ∥ 双流汇入 ∥ 退出)────────────────────────────────────────
152
249
 
153
- /** 悬挂 chunk 双流汇入(遮蔽 → log ∥ 缓冲 ∥ 面板 → 唤醒沉降等待者)。 */
250
+ /** 悬挂 chunk 双流汇入(已剥离已遮蔽 → log ∥ 缓冲 ∥ 面板 → 唤醒沉降等待者)。 */
154
251
  function handleChunk(entry, text) {
155
252
  if (!text) return
156
- entry.bytes += text.length
157
253
  try { entry.logStream?.write(text) } catch { /* log 写失败不进语义——尽力面 */ }
158
254
  entry.buf += text
159
255
  if (entry.buf.length > BUF_KEEP_CHARS) {
@@ -165,18 +261,33 @@ function handleChunk(entry, text) {
165
261
  for (const w of [...entry._waiters]) w("data")
166
262
  }
167
263
 
168
- /** 子进程挂载(双流解码 ∥ 退出 ∥ spawn 错误——探连与会话同径)。 */
264
+ /** 子进程挂载(双流解码 ∥ 剥离 → 遮蔽 ∥ 退出 ∥ spawn 错误——探连与会话同径)。
265
+ * 顺序 = **先剥离后遮蔽**(§6.22 ③):剥离只删字节、不插入 ⇒ 切不断秘密字面;每条流一份剥离状态
266
+ * (两个流各自成序列,不共用一个半条状态)。到达计数在这之前记(§6.22 ③ 门④ 口径 = 「回了没有」,
267
+ * 不随遮蔽尾缓冲的后扣而变)。 */
169
268
  export function attachChild(entry, child) {
170
269
  entry.child = child
171
270
  const outDecoder = makeDecoder()
172
271
  const errDecoder = makeDecoder()
173
- const feed = (d) => handleChunk(entry, entry.mask.push(sanitizeOutput(d)))
272
+ const outStrip = createStripper()
273
+ const errStrip = createStripper()
274
+ entry.strippers = [outStrip, errStrip]
275
+ const route = (decoder, strip) => (d, final = false) => {
276
+ const visible = strip.feed(decoder(d, final))
277
+ if (!visible) return
278
+ entry.lastDataAt = Date.now()
279
+ entry.bytes += visible.length // 到达量(剥离后、遮蔽前)——门④ 判「回了没有」用
280
+ handleChunk(entry, entry.mask.push(visible))
281
+ }
282
+ const outRoute = route(outDecoder, outStrip)
283
+ const errRoute = route(errDecoder, errStrip)
174
284
  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"))
285
+ outRoute(Buffer.alloc(0), true)
286
+ errRoute(Buffer.alloc(0), true)
287
+ entry.escDropped = (entry.escDropped ?? 0) + flushEscapes(entry) // 收场:尾部半条序列丢弃并计一次
177
288
  }
178
- child.stdout.on("data", (d) => feed(outDecoder(d)))
179
- child.stderr.on("data", (d) => feed(errDecoder(d)))
289
+ child.stdout.on("data", (d) => outRoute(d))
290
+ child.stderr.on("data", (d) => errRoute(d))
180
291
  // stdin 无 error 监听 ⇒ 对端刚死时写入的异步 EPIPE 会以未处理 'error' 事件逸出(uncaughtException)。
181
292
  // 同族先例:CLI `tui/clipboard.mjs` ∥ `mcp/transport-stdio.mjs` 同式吞 EPIPE。
182
293
  child.stdin?.on("error", () => { /* 写失败由 send ∥ secret_input 的调用面回读;此处仅防未处理事件 */ })
@@ -210,17 +321,52 @@ export function spawnSession(entry, { user, port, secret, identityFile = null, k
210
321
  return child
211
322
  }
212
323
 
213
- // ─── 沉降(§6.22 ③)────────────────────────────────────────────────────────
324
+ // ─── 就绪判据(§6.22 ③ 四门 · D-TO21)─────────────────────────────────────
325
+
326
+ /** 待答标记(门①——设备正等我发键;`--More--` 走空格、确认走 y/n)。 */
327
+ const ANSWER_MARKERS = ["--More--", "(y/n)", "Password:"]
328
+ /** 缺省设备提示符形(门②——行尾 `#` `>` `$` `%`,前接非空白词)。 */
329
+ const DEVICE_PROMPT = /[^\s][#$>%]\s*$/u
330
+
331
+ /** 末行(门① 只看末行——陈旧标记被后续输出顶走就不命中,B18)。 */
332
+ const tailLine = (text) => {
333
+ const s = String(text ?? "")
334
+ return s.slice(s.lastIndexOf("\n") + 1)
335
+ }
336
+
337
+ /** 判据面文本 = **新到字节**(`base` 之后) + 遮蔽尾缓冲(尾在 drain 时释放,门不得被它推迟)。
338
+ * `base` 撇掉本轮之前就在缓冲里的存量(回填的中段不得充当本轮判据面——§6.22 ③「四门只判新到字节」)。 */
339
+ const watchText = (entry, base = 0) => `${entry.buf.slice(base)}${entry.mask?.peek?.() ?? ""}`
214
340
 
215
- function promptHit(entry) {
216
- return Boolean(entry.promptRe && entry.promptRe.test(entry.buf))
341
+ /** 门① 待答标记(**末行锚定**——只看最后一个换行之后的部分;"出现"故用 `includes`:`(y/n)?` ∥ `[y/n]` 同类形都能命中)。 */
342
+ export function markerHit(entry, base = 0) {
343
+ const line = tailLine(watchText(entry, base))
344
+ return ANSWER_MARKERS.some((m) => line.includes(m))
217
345
  }
218
346
 
219
- /** 等到「就绪判据」成立:提示符命中 ∥ 静默窗到点 ∥ 硬超时 ∥ 退出 ∥ 中止(返回 reason)。 */
220
- export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null } = {}) {
347
+ /** 门② 提示符:agent 所设 `promptPattern` 命中 ∥ 缺省设备形。 */
348
+ export function promptHit(entry, base = 0) {
349
+ const text = watchText(entry, base)
350
+ if (entry.promptRe && entry.promptRe.test(text)) return true
351
+ return DEVICE_PROMPT.test(tailLine(text))
352
+ }
353
+
354
+ /** 硬超时失败标签三枚(门④——与门③ 两条子情形同文;计的是**到达字节**:剥离后、遮蔽前)。 */
355
+ function timeoutLabel(entry, startBytes) {
356
+ if (entry.bytes <= startBytes) return "not a single byte came back"
357
+ return Date.now() - entry.lastDataAt < SSH_SETTLE_MS ? "still producing output" : "no prompt detected"
358
+ }
359
+
360
+ /** 等到就绪(返回 reason):`marker` ∥ `prompt` ∥ `timeout` ∥ `exited` ∥ `aborted`(`connect` 另有 `silence`)。
361
+ * 门①② 在**每个到达 chunk** 上求值;静默窗只判「尾部这半条标记到没到齐」(去抖)——不参与求值、不推迟
362
+ * 判断时点,也不再单独构成「已就绪」(D-TO21)。`settleOnSilence` = `connect` 首屏沿用旧口径
363
+ * (§6.22 ③「适用范围」:四门只判 `send` / `read` / `secret_input`)。 */
364
+ export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null, settleOnSilence = false } = {}) {
221
365
  return new Promise((resolve) => {
222
366
  let done = false
223
367
  let quietTimer = null
368
+ const startBytes = entry.bytes
369
+ const baseLen = entry.buf.length // 门①② 只判**新到字节**(存量中段不当本轮判据面)
224
370
  const deadline = Date.now() + Math.max(1, Number(timeoutMs) || SSH_TIMEOUT_MS)
225
371
  const finish = (reason) => {
226
372
  if (done) return
@@ -229,15 +375,28 @@ export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null }
229
375
  clearTimeout(deadlineTimer)
230
376
  signal?.removeEventListener("abort", onAbort)
231
377
  entry._waiters.delete(onEvent)
378
+ if (reason === "timeout") {
379
+ entry.settleFailure = timeoutLabel(entry, startBytes)
380
+ entry.escDropped = (entry.escDropped ?? 0) + flushEscapes(entry) // 硬超时 = 收场边界(§6.22 ③ flush②)
381
+ }
232
382
  resolve(reason)
233
383
  }
384
+ const evaluate = () => {
385
+ if (markerHit(entry, baseLen)) { finish("marker"); return true }
386
+ if (promptHit(entry, baseLen)) { finish("prompt"); return true }
387
+ return false
388
+ }
234
389
  const arm = () => {
235
390
  clearTimeout(quietTimer)
236
- quietTimer = setTimeout(() => finish(promptHit(entry) ? "prompt" : "silence"), SSH_SETTLE_MS)
391
+ quietTimer = setTimeout(() => {
392
+ if (done) return
393
+ if (settleOnSilence) return finish(promptHit(entry, baseLen) ? "prompt" : "silence")
394
+ evaluate() // 去抖:尾部半条标记到齐后再求值一次;未命中 ⇒ 继续等到硬超时
395
+ }, SSH_SETTLE_MS)
237
396
  }
238
397
  const onEvent = (kind) => {
239
398
  if (kind === "exit") return finish("exited")
240
- if (promptHit(entry)) return finish("prompt")
399
+ if (evaluate()) return undefined
241
400
  arm()
242
401
  }
243
402
  const onAbort = () => finish("aborted")
@@ -246,15 +405,21 @@ export function waitSettled(entry, { timeoutMs = SSH_TIMEOUT_MS, signal = null }
246
405
  if (signal?.aborted) return finish("aborted")
247
406
  signal?.addEventListener("abort", onAbort, { once: true })
248
407
  entry._waiters.add(onEvent)
249
- onEvent("data") // 起点:先查提示符,否则起静默窗(「send 后 800 ms 无新输出」从本刻起算)
408
+ onEvent("data") // 起点:先查门①②,否则起去抖窗
250
409
  })
251
410
  }
252
411
 
253
- /** 就绪结论注记(§6.22 ③:静默 ∥ 提示符 ∥ 超时——超时带 id / host / 已等时长,不静默挂起)。 */
412
+ /** 就绪结论注记(§6.22 ③):提示符 ∥ 待答标记 ∥ 硬超时(**点名失败门**)∥ 中断 ∥ 退出。 */
254
413
  export function settleNote(entry, reason, waitedMs) {
255
414
  if (reason === "prompt") return "settled (prompt matched)"
415
+ if (reason === "marker") return "settled (the device is waiting for input — the marker is shown above)"
256
416
  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)`
417
+ if (reason === "timeout") {
418
+ const label = entry.settleFailure ?? "no prompt detected"
419
+ entry.settleFailure = null
420
+ const hint = label === "no prompt detected" ? "; pass promptPattern on connect if this device's prompt does not look like a shell prompt" : ""
421
+ return `not settled after ${Math.round(waitedMs / 100) / 10}s (${idLabel(entry)} @ ${entry.host}) — partial output above (${label}${hint})`
422
+ }
258
423
  if (reason === "aborted") return `(interrupted — partial output above; the session stays open: ${idLabel(entry)} @ ${entry.host})`
259
424
  if (reason === "exited") return `${idLabel(entry)} @ ${entry.host} ${entry.state === "ready" ? "ended (the remote closed the connection)" : "has stopped"}`
260
425
  return String(reason)
@@ -275,12 +440,78 @@ export function drain(entry) {
275
440
  return { text, dropped }
276
441
  }
277
442
 
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)}`
443
+ /** 回填未读缓冲(丢最旧、保住尾证据;§6.22 ⑥)。 */
444
+ function backfill(entry, middle) {
445
+ if (!middle || !entry) return
446
+ entry.buf = middle + entry.buf // 中段先于更新字节(回填顺序 = 原序)
447
+ if (entry.buf.length > BUF_KEEP_CHARS) {
448
+ entry.buf = entry.buf.slice(entry.buf.length - BUF_KEEP_CHARS)
449
+ entry.bufDropped = true
450
+ }
451
+ }
452
+
453
+ /** 返回帽窗(§6.22 ⑥ · D-TO22):**返回总长 ≤ 帽(注记计入帽内)**——头 60% ∥ 注记 ∥ 尾 40%,
454
+ * 切点按行边界取整、取整只在头段(头段可少不可多);省略中段**回填未读缓冲**(下一次 `read` 先交出)
455
+ * ⇒ 长配置按窗口连续取全,不靠翻日志。截断本体 = `text-budget.mjs` `capText`(零新截断实现)。 */
456
+ export function capWindow(entry, text, max = SSH_RETURN_CAP) {
457
+ const t = String(text ?? "")
458
+ if (!(max > 0) || t.length <= max) return t
459
+ if (max <= windowMarker(t.length, 0, 0).length) {
460
+ // 帽小到放不下注记 ⇒ 无法成窗:按帽交出前缀、余量回填缓冲(「总长 ≤ 帽」不变式先行;每次 read 都有进展)
461
+ backfill(entry, t.slice(max))
462
+ return t.slice(0, max)
463
+ }
464
+ let marker = windowMarker(t.length, 0, 0)
465
+ let head = 0
466
+ let tail = 0
467
+ for (let round = 0; round < 6; round++) {
468
+ const budget = Math.max(0, max - marker.length)
469
+ const nextHead = alignHead(t, Math.floor(budget * CAP_HEAD_RATIO))
470
+ const nextTail = Math.max(0, budget - Math.floor(budget * CAP_HEAD_RATIO))
471
+ const nextMarker = windowMarker(t.length - nextHead - nextTail, nextHead, nextTail)
472
+ const same = nextHead === head && nextTail === tail && nextMarker === marker
473
+ head = nextHead
474
+ tail = nextTail
475
+ marker = nextMarker
476
+ if (same) break
477
+ }
478
+ while (head > 0 && head + marker.length + tail > max) { // 收敛后的边角(省略数位数增长)——头段再收
479
+ head = Math.max(0, head - (head + marker.length + tail - max))
480
+ marker = windowMarker(t.length - head - tail, head, tail)
481
+ }
482
+ backfill(entry, t.slice(head, t.length - tail))
483
+ const markerOf = (omitted) => windowMarker(omitted, head, tail)
484
+ return capBudgetText(t, { max, keepHead: head, keepTail: tail, marker: markerOf })
485
+ }
486
+
487
+ /** 注记形(§6.22 ⑥):`N` 走 `text-budget.mjs` `fillMarker` 占位约定;`H` ∥ `T` = 实保头 ∥ 尾长。 */
488
+ const WINDOW_MARKER = "[… N chars omitted (head H / tail T) — the omitted middle is kept; call read again to collect it …]\n"
489
+ const windowMarker = (omitted, head, tail) =>
490
+ fillMarker(WINDOW_MARKER, omitted).replace("H", String(head)).replace("T", String(tail))
491
+
492
+ /** 头段切点按行边界取整(不切半行;找不到换行则保持原位——头段可少不可多)。 */
493
+ const alignHead = (text, n) => {
494
+ const nl = text.lastIndexOf("\n", Math.max(0, n - 1))
495
+ return nl >= 0 ? nl + 1 : n
496
+ }
497
+
498
+ /** 半条序列的收场(§6.22 ③ flush②):尾部半条丢弃并计一次;其前后的可见字节照常交出。 */
499
+ export function flushEscapes(entry) {
500
+ let dropped = 0
501
+ for (const strip of entry.strippers ?? []) {
502
+ const res = strip.end()
503
+ if (res.text) handleChunk(entry, entry.mask.push(res.text))
504
+ dropped += res.dropped
505
+ }
506
+ return dropped
507
+ }
508
+
509
+ /** 半条序列收场注记(§6.22 ③ flush②)——报告一次即清零。 */
510
+ export function escapeNote(entry) {
511
+ const n = entry.escDropped ?? 0
512
+ if (!n) return ""
513
+ entry.escDropped = 0
514
+ return `\n— ${n} incomplete escape sequence dropped`
284
515
  }
285
516
 
286
517
  /** 状态行冠形(§6.22 动作表末行):每返回首行。 */
@@ -30,7 +30,7 @@ import { unsettledRow, unsettledRows } from "../agent/digest-account.mjs"
30
30
  * - id omitted → { overview: { running: [...], queued: [...], done: [...],
31
31
  * unsettled: [{id, role, state, attempts, preview?}] } } — live queue positions (index in
32
32
  * _asyncQueue + 1); the unsettled section lists every entry still awaiting settlement
33
- * (awaiting-digest / retrying) plus the upgraded ledger (§6.31.6).
33
+ * (awaiting-digest / unaccounted) plus the upgraded ledger (§6.31.6).
34
34
  * AGENT-LOOP-SUBAGENT.md §6.7.2: running 条目带 touched files 摘要(touchedFiles 前 5 + touchedMore 超出
35
35
  * 计数——相对查询方 cwd;0 改动 → touched 占位);queued 条目带 touched 占位
36
36
  * "—(未启动)";done/error/取消条目无 touched 字段(round3 #9)。
@@ -245,7 +245,7 @@ function touchedFilesNote(child, cwd) {
245
245
  const touched = child?._touchedFiles ?? []
246
246
  if (touched.length === 0) return ""
247
247
  const shown = touched.map((f) => {
248
- const r = relative(cwd ?? process.cwd(), f)
248
+ const r = relative(cwd, f)
249
249
  return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
250
250
  })
251
251
  return `\nTouched files: ${shown.join(", ")}`
@@ -63,8 +63,9 @@ export const ASYNC_POOL_LIMITS = { engCoder: 4, other: 4 }
63
63
 
64
64
  /**
65
65
  * Role → pool domain (single source of truth — §6.10 修正 #8): the CLI role
66
- * whitelist is subagent.mjs's ROLES = { explore, plan, coder, eng-coder, eng-designer }
67
- * (mode-filtered: normal → explore/plan/coder, engineering → explore/eng-designer/eng-coder).
66
+ * whitelist is subagent.mjs's ROLES = { explore, plan, coder, eng-coder, eng-designer, tester }
67
+ * (mode-filtered: normal → explore/plan/coder/tester, engineering → explore/eng-designer/eng-coder/tester —
68
+ * tester is dispatchable in both modes).
68
69
  * eng-coder → engCoder pool; every other role (including unknown roles — fail-safe)
69
70
  * → other pool. escalate spawns its expert internally (role "coder" — other pool).
70
71
  */
@@ -3,19 +3,24 @@
3
3
  * subagent-async.mjs 拆分——Module Split Policy——纯迁移零行为变化——
4
4
  * AGENT-LOOP-SUBAGENT.md §6.9 D-SD1..SD5 + D-SL1 环形死锁修正)。
5
5
  * 内容:normalizeFileList / filesOverlap / effectiveFiles(SCHEDULER-DYNAMIC-DOMAIN
6
- * 动态域——声明 ∪ running touched)/ depInfo / describeBlockers / detectStall(
6
+ * 动态域——声明 ∪ running touched)/ depInfo / describeBlockers / batchDocInFlight(
7
+ * AGENT-LOOP-SUBAGENT.md §6.58 批次档复用护栏——按档查在飞占用者)/ detectStall(
7
8
  * P-SL2 停滞机械检测)/ queueRunnable / assertNoDepCycle / dependentLabels /
8
9
  * refreshQueuedTokens / maybeRefillAsync。
9
10
  * ASYNC_POOL_LIMITS/poolLimitsFor/runningPoolCount 回引自 subagent-async.mjs(主体保有——
10
11
  * 仅调用期使用——与主体对此处调度符号的回引构成惰性环——无求值期依赖)。
11
12
  */
12
- import { isAbsolute, relative, resolve } from "node:path"
13
+ import { isAbsolute, relative } from "node:path"
13
14
  import { existsSync, statSync } from "node:fs"
14
15
  // zero-block 批 P1(TUI.md §6.8.3.2②):排队刷新 relay 异常留痕(禁静默——池状态仍不破坏)。
15
16
  import { logEvent } from "../log.mjs"
16
17
  // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):files 声明面拦截谓词本体落
17
18
  // spawn-gates.mjs(纯谓词零依赖——本处只加 import 调用,无环)。
18
- import { rejectEngineeringFilePaths } from "./spawn-gates.mjs"
19
+ import { rejectProcessDocPaths } from "./spawn-gates.mjs"
20
+ // `files` 落点单源 = 统一解析层(`docs/core/design/PATH-RESOLUTION.md` §2.5 第 4 行:`kind:"none"` /
21
+ // `order:"near-first"` ⇒ 恒解析出、只做调度、不校验存在性)。门判(目录声明 / 过程档)
22
+ // 仍吃**原始声明形状**——判据在转绝对之前跑,顺序零改。
23
+ import { resolvePathTarget } from "../path-resolve.mjs"
19
24
  import { poolLimitsFor, runningPoolCount, ASYNC_POOL_LIMITS } from "./subagent-async.mjs"
20
25
  // ASYNC-RESULT-CONTAINER.md D1:池 accessor——async-settle.mjs 反向 import 本模块
21
26
  // (dependentLabels)构成同款惰性环(函数级绑定——无求值期依赖)。
@@ -43,7 +48,7 @@ import { carrierField, getAsyncPool, tombstoneOf } from "./async-settle.mjs"
43
48
  * 大小写不敏感(todo.md/changelog.md 精确——路径任意层含 docs/、根、.thincoder/)——
44
49
  * 命中 → throw(fail-closed——先于调度器准入——无排队残留——错误即工具结果);
45
50
  * 提示列出全部违规条目——英文模板逐字定稿(AGENT-LOOP-SUBAGENT.md §6.9「父侧文件拦截(R26)」)。
46
- * M5 F6(2026-09-17——2026-10-15 口径随正):黑名单随同迁入 spawn-gates.mjs(rejectEngineeringFilePaths——族 = 过程档(CHANGELOG)+ 工程工具面(`scripts/*`——层级锚定:仅仓根与直接子层;目录名在深层不携带 face 信息);老台账 md 族随 M2 退役)——本函数只留调用(错误文案逐字保留)。 */
51
+ * M5 F6(2026-09-17——2026-10-15 口径随正):黑名单随同迁入 spawn-gates.mjs(族 = 过程档(CHANGELOG)+ 项目状态档(PROJECT-MANIFEST.json);老台账 md 族随 M2 退役)——本函数只留调用(错误文案逐字保留)。 */
47
52
  export function normalizeFileList(files, cwd) {
48
53
  const out = []
49
54
  for (const f of Array.isArray(files) ? files : []) {
@@ -51,17 +56,19 @@ export function normalizeFileList(files, cwd) {
51
56
  if (f.trimEnd().endsWith("/") || f.trimEnd().endsWith("\\")) { // 2.7 批:尾随空格目录声明("test/ ")同拒
52
57
  throw new Error(`files must be file-level paths — directory declarations are not supported: ${f}`)
53
58
  }
54
- const abs = resolve(cwd ?? process.cwd(), f)
59
+ // 落点由统一解析层给(`kind:"none"` / `order:"near-first"` ⇒ 恒解析出、只做调度);
60
+ // **门判仍吃原始声明 `f`**:目录声明的尾分隔符判据按声明形状判——转成绝对路径后再判会漏。
61
+ const abs = resolvePathTarget(cwd, f, { kind: "none", order: "near-first" }).abs
55
62
  if (existsSync(abs) && statSync(abs).isDirectory()) {
56
63
  throw new Error(`files must be file-level paths — directory declarations are not supported: ${f}`)
57
64
  }
58
65
  if (!out.includes(abs)) out.push(abs)
59
66
  }
60
- // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2——2026-10-15 口径随正):`scripts/*`(仅仓根/直接子层)+ 过程档拦截——
61
- // 谓词本体落 spawn-gates.mjs(rejectEngineeringFilePaths 收集全部违规 + 抛合并错误);
67
+ // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2——2026-10-15 口径随正):过程档 / 项目状态档拦截——
68
+ // 谓词本体落 spawn-gates.mjs(收集全部违规 + 抛合并错误);
62
69
  // 循环内目录声明先行(错误序不变——目录错误先于过程档错误)。父侧维护文件
63
70
  // basename 族(CHANGELOG——`ledger.db` 随 M2 退役)随同迁入谓词——错误文案逐字保留。
64
- if (files !== undefined && files !== null) rejectEngineeringFilePaths(files)
71
+ if (files !== undefined && files !== null) rejectProcessDocPaths(files)
65
72
  return out
66
73
  }
67
74
 
@@ -79,7 +86,7 @@ export function filesOverlap(a, b) {
79
86
 
80
87
  /** 冲突文件的显示形态(优先相对 cwd——面板/返回文本可读)。 */
81
88
  function showFile(parent, key) {
82
- const cwd = parent.cwd ?? process.cwd()
89
+ const cwd = parent.cwd
83
90
  const rel = relative(cwd, key)
84
91
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : key
85
92
  }
@@ -176,6 +183,33 @@ export function describeBlockers(parent, entry) {
176
183
  return { kind: "slot", detail: "" }
177
184
  }
178
185
 
186
+ /**
187
+ * AGENT-LOOP-SUBAGENT.md §6.58 批次档复用护栏(2026-10-28 batchdoc-reuse-guard)——
188
+ * 按档查在飞占用者:池内条目 `_batchDoc` 与 `batchDocAbs` 按 `fileKey` 相等且**在飞**
189
+ * ⇒ `{ id, role, status }`;无 ⇒ `null`。池派生事实单源在此(与 `depInfo` /
190
+ * `describeBlockers` 同族),消费方单点 = `buildSpawnChild` 的 `batchDoc` 门。
191
+ * 三条判据各防一个具体的错:① 池**经访问器读**(`getAsyncPool`,读法同 `depInfo`)——
192
+ * 裸读 `parent._asyncSubagents` 在只携 `history` 的合成 parent 上读到空池 ⇒ 护栏不报错、
193
+ * 不生效、没人知道;② **按 status 过滤**(`done` / `cancelled` 皆假)——正常 settle 的条目
194
+ * 留在池里(`async-settle.mjs:295`)⇒ 只看「池里有同档条目」会把单席顺序交接全部误拒;
195
+ * ③ **占用判据只认 `_batchDoc`、不分角色**(普通面 coder 携档也算),而被检面只有工程角色的
196
+ * spawn、sync spawn 不入池也不是占用者(同档 §6.58.7 第 7 条)——**这个不对称勿「顺手统一」**。
197
+ * 入参 = 已解析的绝对路径(唯一消费方 `buildSpawnChild` 的工程分支,路径由 `resolveBatchReadPath`
198
+ * 保证非空);空入参返 `null` = 「无路径可比」——本谓词不替调用方判「该不该传路径」。
199
+ */
200
+ export function batchDocInFlight(parent, batchDocAbs) {
201
+ if (!batchDocAbs) return null
202
+ const key = fileKey(batchDocAbs)
203
+ for (const e of getAsyncPool(parent, "subagent")?.values() ?? []) {
204
+ if (e.status !== "running" && e.status !== "queued") continue
205
+ if (e.done === true || e.cancelled === true) continue
206
+ if (!e._batchDoc || fileKey(e._batchDoc) !== key) continue
207
+ return { id: e.id, role: e.role, status: e.status }
208
+ }
209
+ return null
210
+ }
211
+
212
+
179
213
  /** P-SL2(D-SL2——混合边环形等待停滞机械检测——AGENT-LOOP-SUBAGENT.md §6.9):
180
214
  * 判据(收窄——零误报优先——宁可漏报不可误打断):池内 running = 0 && queued ≥ 2 &&
181
215
  * 无 dependency-cancelled 标记条目 && 每 queued 的 blocker(files 冲突者 + 未 settle