@thincoder/core 0.9.5 → 0.10.2

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 (216) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +12 -12
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +22 -12
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +54 -296
  20. package/agent/family-tools.mjs +5 -5
  21. package/agent/helpers.mjs +53 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +15 -11
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +89 -29
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +9 -9
  36. package/agent-tools/advisor-settle.mjs +12 -12
  37. package/agent-tools/advisor.mjs +36 -41
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +21 -18
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +57 -18
  43. package/agent-tools/batch-paths.mjs +52 -8
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +12 -65
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +56 -56
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +16 -11
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +2 -2
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +21 -265
  66. package/agent-tools/subagent-async.mjs +25 -20
  67. package/agent-tools/subagent-panel.mjs +33 -4
  68. package/agent-tools/subagent-run.mjs +36 -21
  69. package/agent-tools/subagent-scheduler.mjs +28 -27
  70. package/agent-tools/subagent-spawn.mjs +45 -14
  71. package/agent-tools/subagent.mjs +33 -53
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +18 -355
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +11 -6
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +29 -12
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/history-window.mjs +17 -2
  95. package/i18n.mjs +2 -0
  96. package/index-discover.mjs +7 -7
  97. package/ledger-cmd.mjs +34 -126
  98. package/ledger-db.mjs +10 -2
  99. package/ledger-tools.mjs +188 -0
  100. package/ledger.mjs +9 -4
  101. package/log.mjs +12 -7
  102. package/manifest-discovery.mjs +131 -0
  103. package/manifest-schema.mjs +166 -0
  104. package/manifest.mjs +18 -217
  105. package/mcp.mjs +4 -1
  106. package/memory/code-search.mjs +149 -0
  107. package/memory/code-sync.mjs +89 -230
  108. package/memory/core.mjs +47 -32
  109. package/memory/docs.mjs +63 -262
  110. package/memory/file-list.mjs +89 -0
  111. package/memory/file-walk.mjs +7 -4
  112. package/memory/memory-tool.mjs +229 -0
  113. package/memory/schema.mjs +18 -3
  114. package/memory/sweep.mjs +289 -0
  115. package/memory/sync-tail.mjs +81 -0
  116. package/memory-status.mjs +69 -0
  117. package/memory.mjs +3 -0
  118. package/model-ref.mjs +96 -3
  119. package/model-specs.mjs +16 -7
  120. package/notify-policy.mjs +51 -0
  121. package/package.json +1 -1
  122. package/peer-claims.mjs +2 -2
  123. package/peer-domains.mjs +7 -11
  124. package/peer-instances.mjs +9 -10
  125. package/process-probe-exec.mjs +187 -0
  126. package/process-probe.mjs +27 -175
  127. package/prompt-overlays.mjs +11 -10
  128. package/prompts/advisor-design.md +2 -2
  129. package/prompts/advisor-round2.md +1 -1
  130. package/prompts/advisor-round3.md +1 -1
  131. package/prompts/common.md +16 -8
  132. package/prompts/discipline-engineering.md +57 -16
  133. package/prompts/discipline-normal.md +6 -3
  134. package/prompts/persona-coder.md +0 -1
  135. package/prompts/persona-eng-coder.md +1 -5
  136. package/prompts/persona-eng-designer.md +2 -3
  137. package/prompts/persona-engineering.md +32 -11
  138. package/prompts/persona-explore.md +0 -1
  139. package/prompts/persona-normal.md +1 -1
  140. package/prompts/persona-plan.md +3 -4
  141. package/prompts/subagent-base.md +14 -0
  142. package/provider/core.mjs +11 -48
  143. package/provider/list-models.mjs +3 -0
  144. package/provider/responses-request.mjs +237 -0
  145. package/provider/responses.mjs +6 -228
  146. package/provider-flows.mjs +248 -0
  147. package/queued.mjs +90 -0
  148. package/rules.mjs +209 -2
  149. package/session-guard.mjs +27 -7
  150. package/session-lifecycle.mjs +108 -27
  151. package/session-rename.mjs +5 -1
  152. package/session-slot-claims.mjs +230 -0
  153. package/session-slot-scan.mjs +299 -0
  154. package/session-slot-verify.mjs +304 -0
  155. package/session-slot-write.mjs +74 -8
  156. package/session-slots-manifest.mjs +163 -241
  157. package/session-slots.mjs +99 -71
  158. package/session.mjs +30 -10
  159. package/shell-candidates.mjs +78 -0
  160. package/text-budget.mjs +2 -2
  161. package/think-off.mjs +49 -0
  162. package/token-ttl.mjs +15 -4
  163. package/tool-args.mjs +73 -0
  164. package/tool-docs/advisor.md +9 -0
  165. package/tool-docs/batch.md +1 -0
  166. package/tool-docs/code_search.md +1 -0
  167. package/tool-docs/consult_start.md +5 -0
  168. package/tool-docs/consult_stop.md +3 -0
  169. package/tool-docs/context.md +7 -0
  170. package/tool-docs/doc_search.md +1 -0
  171. package/tool-docs/edit.md +2 -13
  172. package/tool-docs/eng.md +1 -0
  173. package/tool-docs/execute.md +1 -9
  174. package/tool-docs/git.md +25 -51
  175. package/tool-docs/goal.md +6 -0
  176. package/tool-docs/ledger_add.md +1 -0
  177. package/tool-docs/ledger_close.md +1 -0
  178. package/tool-docs/ledger_count.md +1 -0
  179. package/tool-docs/ledger_query.md +1 -0
  180. package/tool-docs/ledger_update.md +1 -0
  181. package/tool-docs/main_history.md +1 -0
  182. package/tool-docs/memory.md +10 -0
  183. package/tool-docs/notify_parent.md +9 -0
  184. package/tool-docs/peer_instances.md +1 -0
  185. package/tool-docs/plan.md +5 -0
  186. package/tool-docs/process.md +7 -3
  187. package/tool-docs/read_history.md +7 -0
  188. package/tool-docs/recent_changes.md +1 -0
  189. package/tool-docs/repo_outline.md +1 -0
  190. package/tool-docs/settings.md +6 -0
  191. package/tool-docs/skill.md +5 -0
  192. package/tool-docs/subagent.md +31 -0
  193. package/tool-docs/task.md +7 -0
  194. package/tool-docs/timer.md +6 -0
  195. package/tool-docs/verify.md +1 -0
  196. package/tool-docs/wait_for.md +2 -1
  197. package/tools/bash.mjs +28 -4
  198. package/tools/edit-batch.mjs +4 -4
  199. package/tools/edit-diff.mjs +4 -23
  200. package/tools/exec-run.mjs +102 -4
  201. package/tools/execute.mjs +5 -2
  202. package/tools/file.mjs +4 -9
  203. package/tools/git-run.mjs +1 -1
  204. package/tools/git.mjs +0 -24
  205. package/tools/index.mjs +2 -1
  206. package/tools/linter.mjs +2 -1
  207. package/tools/node-child.mjs +21 -0
  208. package/tools/ops.mjs +50 -13
  209. package/tools/patch.mjs +4 -2
  210. package/tools/repomap.mjs +20 -9
  211. package/tools/shared.mjs +8 -6
  212. package/tools/web.mjs +5 -2
  213. package/tools/write-path.mjs +1 -1
  214. package/traces/trace-store.mjs +17 -14
  215. package/vision-reader.mjs +76 -0
  216. package/agent-tools/batch-segment.mjs +0 -18
@@ -44,6 +44,8 @@
44
44
  import { openSync, readSync, closeSync, readFileSync, existsSync, statSync } from "node:fs"
45
45
  import { isAbsolute, resolve } from "node:path"
46
46
  import { listSlots, slotPath } from "../session-slots.mjs"
47
+ // #15 描述外置:描述文本单点 = tool-docs/read_history.md(DESC 单解析面,缺档抛错语义不变)
48
+ import { DESC } from "../tools/shared.mjs"
47
49
 
48
50
  const DEFAULT_LIMIT = 50
49
51
  const MAX_LIMIT = 200
@@ -63,9 +65,6 @@ export const READ_HISTORY_MAX_MESSAGES = 50_000
63
65
  /** 超限错误文案(SESSION.md §6.13——逐字定稿——T-R19.7 断言)。 */
64
66
  const TOO_LARGE_ERROR = JSON.stringify({ error: "session too large — refine keyword or since/until" })
65
67
 
66
- /** 检索/记忆族消歧总纲(SESSION.md §6.13 D-R19b——逐字定稿——read_history 描述尾段——T-R19.5 锚)。 */
67
- const SEARCH_FAMILY_GUIDE =
68
- "检索/记忆族选哪个:查**本会话**说过/裁定过 → read_history(默认);查**别的会话/项目**旧对话 → read_history 带 path/cwd 参数;查**本 run 改过哪些文件** → recent_changes;查**跨会话已存知识/约定**(memory)→ memory search;查**项目设计文档** → doc_search;查**代码实现** → code_search;查 git 历史快照 → checkpoint cat/versions。read_history 只查会话消息——文件级改动用 recent_changes——知识与约定用 memory——互相不替代。"
69
68
 
70
69
  /** Message text for keyword matching + output: strings pass through; multimodal content arrays → text parts joined (never crashes, empty parts skipped, images ignored). */
71
70
  function messageText(m) {
@@ -219,7 +218,8 @@ function querySessionFile(pathArg, { role, kwRe, tool, since, until, direction,
219
218
 
220
219
  /** Discovery surface (path = "cwd:<dir>"): list every slot stored for that directory, one line
221
220
  * per slot — slot number + FULL session file path + title/message count/updatedAt(§6.13 D-R19a
222
- * ——评审 #2:摘要必须含寻址字段——模型第二步深查 = 复制行内文件路径重调 path=)。 */
221
+ * ——评审 #2:摘要必须含寻址字段——模型第二步深查 = 复制行内文件路径重调 path=)。
222
+ * 计数不可得 = `null` ⇒ `messages: —`(§6.25 判据句 4——禁显示数值 0:真 0 与未知可分)。 */
223
223
  function discoverCwd(raw, baseCwd) {
224
224
  const dir = resolve(baseCwd ?? process.cwd(), raw)
225
225
  let st = null
@@ -235,7 +235,7 @@ function discoverCwd(raw, baseCwd) {
235
235
  }
236
236
  const lines = slots.map((s) => {
237
237
  const title = s.title ? `"${s.title}"` : "(untitled)"
238
- return `slot ${s.slot}: ${slotPath(dir, s.slot)} — title: ${title}, messages: ${s.messageCount}, updatedAt: ${s.updatedAt}`
238
+ return `slot ${s.slot}: ${slotPath(dir, s.slot)} — title: ${title}, messages: ${s.messageCount ?? "—"}, updatedAt: ${s.updatedAt}`
239
239
  })
240
240
  return `Session slots for cwd: ${dir} (newest first):\n${lines.join("\n")}`
241
241
  }
@@ -310,34 +310,18 @@ async function queryAllSessions(filters, current = null) {
310
310
 
311
311
  export const readHistoryTool = {
312
312
  name: "read_history",
313
- description:
314
- "Query message history — THIS session by default, any session on disk with `path`. " +
315
- "Default (no path): THIS session's full record (never compacted, audit-complete) — recall what " +
316
- "was said or done earlier: design decisions, tool-call timing, past rulings. " +
317
- "Filters combine with AND: role / keyword (case-insensitive substring of message text) / " +
318
- "tool (tool result messages by name AND the assistant messages that declared the call — pair with tool_call_id / ts for timing) / " +
319
- "since-until (epoch ms time window; only messages with ts can match) / limit (default 50, clamped to 200) / direction (which end of the matches to take). " +
320
- "Returns a JSON array in chronological order: [{ts, role, name?, tool_call_id?, content (≈500 chars, truncated marker), tool_calls ([{name, arguments}] — arguments capped at 300 chars)}]. " +
321
- "Messages without ts return ts:null. Content is truncated — the full text is in the session file. " +
322
- "Cross-session (path, optional): a session file path deep-queries THAT session's history with the same filters " +
323
- "(relative paths resolve against the project cwd) — answered from the derived session index when it covers that session, " +
324
- "otherwise from the session file itself (a file over 50,000 messages or 200,000 lines is refused as \"session too large\"). " +
325
- "\"cwd:<dir>\" lists every session slot stored for that directory — one line per slot: slot number + full session file path + " +
326
- "title + message count + updatedAt; copy a listed file path into path= to deep-query it. " +
327
- "\"all\" searches EVERY INDEXED session at once (cross-project; keyword = FTS words / phrases; each row carries session.file for the follow-up deep query) — " +
328
- "`all` covers the derived index only: sessions never indexed yet are invisible there (query them with path=<file>, or index everything first with the CLI `thincoder session index --rebuild`).\n" +
329
- SEARCH_FAMILY_GUIDE,
313
+ description: DESC("read_history"),
330
314
  parameters: {
331
315
  type: "object",
332
316
  properties: {
333
317
  role: { type: "string", enum: ["user", "assistant", "tool"], description: "Only messages with this role." },
334
- keyword: { type: "string", description: "Case-insensitive substring of the message text (multimodal messages match on their text parts); with path=\"all\" it is matched as FTS words / phrases over the indexed text." },
335
- tool: { type: "string", description: "Only messages for this tool: role=tool messages with name=tool, plus assistant messages that declared a call to it." },
336
- since: { type: "integer", description: "Earliest ts to match, epoch ms, INCLUSIVE. Messages without ts never match a time window." },
337
- until: { type: "integer", description: "Latest ts to match, epoch ms, INCLUSIVE. since > until yields an empty result." },
338
- limit: { type: "integer", description: "Maximum messages to return (default 50; larger values are clamped to 200)." },
318
+ keyword: { type: "string", description: "Case-insensitive substring of the message text (multimodal matches on text parts); with path=\"all\" matched as FTS words / phrases." },
319
+ tool: { type: "string", description: "Only messages for this tool: role=tool messages with name=tool, plus assistant messages that declared a call to it (pair with tool_call_id / ts for timing)." },
320
+ since: { type: "integer", description: "Earliest ts to match, epoch ms, INCLUSIVE; messages without ts never match." },
321
+ until: { type: "integer", description: "Latest ts to match, epoch ms, INCLUSIVE; since > until yields empty." },
322
+ limit: { type: "integer", description: "Maximum messages to return (default 50; clamped to 200)." },
339
323
  direction: { type: "string", enum: ["oldest", "newest"], description: "Take the limit window from the oldest or newest end of the matched set (default newest)." },
340
- path: { type: "string", description: "Optional — query another session instead of this one: a session file path (as listed by a \"cwd:<dir>\" call) deep-queries that session; \"cwd:<dir>\" lists that directory's session slots (slot number + full file path + title + message count + updatedAt); \"all\" searches every indexed session across projects (FTS words / phrases; rows carry session.file)." },
324
+ path: { type: "string", description: "Optional — another session instead of this one: a session file path deep-queries that session; \"cwd:<dir>\" lists that directory's session slots (slot + file path + title + count + updatedAt); \"all\" searches every indexed session across projects (FTS words / phrases; rows carry session.file)." },
341
325
  },
342
326
  },
343
327
  readonly: true,
@@ -3,13 +3,12 @@
3
3
  * More precise than git status — only looks at this session's changes, independent of git tracking.
4
4
  * Helps the model recall what it already modified during long tasks.
5
5
  */
6
+ // 描述面 = `tool-docs/recent_changes.md`(#15 描述外置统一——DESC 单一解析面,与内置工具族同径)
7
+ import { DESC } from "../tools/shared.mjs"
8
+
6
9
  export const recentChangesTool = {
7
10
  name: "recent_changes",
8
- description:
9
- "Show files modified in this agent run (write/edit/insert_after/delete). " +
10
- "Use when you need to remember which files you've already touched — during long multi-file tasks, " +
11
- "it's easy to lose track. This is scoped to the current run, unlike git status which shows all uncommitted changes. " +
12
- "For session-level history (what was said in a session), use read_history.",
11
+ description: DESC("recent_changes"),
13
12
  parameters: {
14
13
  type: "object",
15
14
  properties: {},
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { join } from "node:path"
15
15
 
16
- /** ABS 归一(cwd 相对 → cwd 拼接)——陈旧判定 / 冻结拦截 / 评审范围键同源(§14.14 E-4)。 */
16
+ /** ABS 归一(cwd 相对 → cwd 拼接)——陈旧判定 / 冻结拦截 / 评审范围键同源(ADVISOR-GUARDS.md §5 E-4)。 */
17
17
  export function normAbs(p, cwd) {
18
18
  const s = String(p)
19
19
  return /^[a-zA-Z]:[\\/]/.test(s) || s.startsWith("/") || s.startsWith("\\\\") ? s : join(cwd, s)
@@ -3,20 +3,27 @@
3
3
  * 用户裁定:全量 config.json 任意键(点分路径)+ 单工具多动作 list/get/set + 双端同批。
4
4
  * 语义:set = 写盘(config.json——磁盘真相最小化:只写被设的键——默认值不固化)+
5
5
  * 热应用(ctx.agent.config 内存对象立即更新——回合边界键下回合生效)。
6
- * 护栏:敏感键(路径段命中词表 apiKey/key/token/secret/password/authorization/auth/cookie/credential,
7
- * 或段名 headers/env 整族)回显/错误文本永不出现明文
6
+ * `null` = **显式清除 ⇒ 删键**(盘删键 + 内存回填 `DEFAULTS` 默认值——全端同形;SETTINGS-TOOL.md §2.3)。
7
+ * 护栏:敏感键(路径段命中词表 apiKey/key/token/secret/password/authorization/auth/cookie/credential
8
+ * (含**段内复合名**——`refreshToken` ∕ `clientSecret` ∕ `privateKey` 类 camelCase ∕ 前缀复合;
9
+ * #677 · I13),或段名 headers/env 整族)回显/错误文本永不出现明文
8
10
  * (••••(masked)——防密钥泄漏进会话历史/trace);已知键类型校验——非 null 叶子类型表
9
11
  * 自动派生自 config.mjs DEFAULTS(不手写防漂移——F-S1.5/N-S1.5);null 默认值键与同族键
10
12
  * 走显式形状表(_NULL_LEAF_SHAPES 相等面 / _SIBLING_SHAPES 存在性面——按各自真实消费形态
11
13
  * 校验,完备性机械锁 + 一次性警告;SETTINGS-TOOL.md §8);set 侧效走审批门(dispatch 动作级分类)。
12
14
  */
13
15
  import { DEFAULTS, configPath, writeConfigAtomic } from "../config.mjs"
16
+ import { DESC } from "../tools/shared.mjs" // #15 描述外置:文本单点 = tool-docs/settings.md
14
17
 
15
- /** 敏感键段判定(完整点分路径的段级匹配——两句取或:词表段 ∨ 开口键族段;SETTINGS-TOOL.md §2.4) */
16
- const SENSITIVE_SEGMENT = /(^|[._-])(api[_-]?key|key|token|secret|password|authorization|auth|cookie|credential)($|[._-])/i
18
+ /** 敏感键段判定(完整点分路径的段级匹配——两句取或:词表段 ∨ 开口键族段;SETTINGS-TOOL.md §2.4)。
19
+ * #677 · I13(C2 裁定 · D-ST17):段内复合敏感名入遮——词表句左界扩 `[a-z0-9]`(camelCase 复合,
20
+ * `refreshToken` ∕ `clientSecret` ∕ `privateKey` 类);右界仍须段界 ⇒ `maxTokens`(复数)∕
21
+ * `tokenCount`(敏感词非末位)不误遮;假阳方向安全(只丢值、键名可见)。 */
22
+ const SENSITIVE_SEGMENT = /(^|[._-]|[a-z0-9])(api[_-]?key|key|token|secret|password|authorization|auth|cookie|credential)($|[._-])/i
17
23
  /** 开口键族段判定(headers / env——段名命中 ⇒ 其下全部子键整族遮罩:头名 / 变量名不可枚举) */
18
24
  const SENSITIVE_FAMILY = /(^|[._-])(headers|env)($|[._-])/i
19
- const MASKED = "••••(masked)"
25
+ /** 敏感键遮罩字面(**导出单源**——工具回显 ∕ 桌面 `settings-values.mjs` ∕ VSC `settings.mjs` 三端同源;B10 S13 转 export) */
26
+ export const MASKED = "••••(masked)"
20
27
 
21
28
  /** #58(hygiene-sweep 批):谓词/标记导出——CLI `/mcp` 表单现值脱敏同源(`cmd-mcp-form.mjs`)。 */
22
29
  export function isSensitiveKey(path) {
@@ -61,6 +68,10 @@ function _buildShapeTable(obj, patch = null) {
61
68
  }
62
69
  const TYPE_MAP = _buildShapeTable(DEFAULTS)
63
70
 
71
+ /** #645 放行面扩族:`null`(显式清除——删键回退默认)可接受者 = `object`(既有)∪ `number` ∕
72
+ * `boolean`(本批扩);`string` 类维持拒(消费面无「未设置」态——如 `memory.dbPath`)。 */
73
+ const _NULL_CLEARABLE = new Set(["object", "number", "boolean"])
74
+
64
75
  /** null 叶子路径枚举(数组不递归——与派生表同域)。 */
65
76
  function _nullLeafPaths(obj, prefix = "", out = []) {
66
77
  for (const [k, v] of Object.entries(obj)) {
@@ -129,7 +140,8 @@ function _checkShape(path, spec, value) {
129
140
  * 已知键值校验(set 写盘前;违例抛错——磁盘/内存零变化):
130
141
  * ① 形状表命中键(`_NULL_LEAF_SHAPES` / `_SIBLING_SHAPES` **两表都查**——表值 = 形状规格)→ 形状校验
131
142
  * (`null` = 显式清除,放行);② 非 null 叶子(派生表值 = `typeof` 串)→ 既有语义逐字保留
132
- * (`want === "array"` 跳过 / `null` + `want === "object"` 放行);③ 未知键 → 原样通过(全量域)。
143
+ * (`want === "array"` 跳过 / `null` + `want ∈ _NULL_CLEARABLE` 放行——#645 扩 number/boolean);
144
+ * ③ 未知键 → 原样通过(全量域)。
133
145
  */
134
146
  function _checkKnownKeyValue(path, value) {
135
147
  const spec = _NULL_LEAF_SHAPES[path] ?? _SIBLING_SHAPES[path]
@@ -140,7 +152,7 @@ function _checkKnownKeyValue(path, value) {
140
152
  const want = TYPE_MAP[path]
141
153
  if (want && want !== "array") {
142
154
  const got = value === null ? "null" : typeof value
143
- if (got !== want && !(value === null && want === "object")) throw _expectsError(path, want, value)
155
+ if (got !== want && !(value === null && _NULL_CLEARABLE.has(want))) throw _expectsError(path, want, value)
144
156
  }
145
157
  }
146
158
 
@@ -173,6 +185,17 @@ function setKeyPath(obj, path, value) {
173
185
  return old
174
186
  }
175
187
 
188
+ /** 点分路径删键(#645:`null` = 显式清除 ⇒ 删键——沿桌面 `settings-values.mjs` 同形;缺段 ⇒ 无操作)。 */
189
+ function deleteKeyPath(obj, path) {
190
+ const segs = String(path).split(".")
191
+ let cur = obj
192
+ for (let i = 0; i < segs.length - 1; i++) {
193
+ if (cur === null || typeof cur !== "object") return
194
+ cur = cur[segs[i]]
195
+ }
196
+ if (cur !== null && typeof cur === "object") delete cur[segs[segs.length - 1]]
197
+ }
198
+
176
199
  /** 递归展平 config 对象(数组下标段形态)→ 排序的 { path, value } 列表 */
177
200
  function flatten(obj, prefix = "", out = []) {
178
201
  for (const [k, v] of Object.entries(obj)) {
@@ -209,13 +232,7 @@ export function settingsTool(opts = {}) {
209
232
 
210
233
  return {
211
234
  name: "settings",
212
- description:
213
- "Adjust ThinCoder runtime configuration — persisted to config.json AND hot-applied to the live agent config.\n" +
214
- "Actions: list (all keys + values, flattened) | get <key> | set <key> <value> — dot paths into config.json (agent.maxTurns, traces.enabled, providers.0.model, any nesting).\n" +
215
- "set persists to disk (only the set key is written — defaults are never baked in) and takes effect in the running session immediately (turn-boundary keys apply next turn); the value survives restarts.\n" +
216
- "Known keys are type-checked: scalar keys against the built-in defaults (agent.maxTurns a number, traces.enabled a boolean); keys whose default is null against their real consumption shape — defaultModel \"provider:model\", agent.subagentModel / shell non-empty string, memory.team object with a repo — so a value the app would silently drop is refused (null clears the key). Unknown keys under a known section are stored as given. Values parse as JSON first (true/false/numbers/objects/arrays), else stay strings.\n" +
217
- "SENSITIVE keys (path segment matching apiKey/key/token/secret/password/authorization/auth/cookie/credential, or any key under a headers/env segment) are NEVER echoed in plaintext — list/get/set replies show ••••(masked); setting a sensitive key is allowed and stored, but never echoed back.\n" +
218
- "list/get are read-only (planMode ok, no approval); set is a side effect (approval gate). The /config TUI command is the human equivalent.",
235
+ description: DESC("settings"), // #15 外置:文本单点 = tool-docs/settings.md
219
236
  parameters: {
220
237
  type: "object",
221
238
  properties: {
@@ -250,15 +267,25 @@ export function settingsTool(opts = {}) {
250
267
  const value = parseValue(args.value)
251
268
  // 已知键校验(形状表两表都查 + 派生类型表——SETTINGS-TOOL.md §8.3;未知键 JSON 原样)
252
269
  _checkKnownKeyValue(String(args.key), value)
253
- // 写盘(D-F5b:磁盘真相最小化——writeConfigAtomic 磁盘新鲜读 + 只改被设键 + mtime
254
- // 门控——默认不固化;冲突/畸形抛错,内存不热应用(零虚假成功))+ 热应用(内存对象)
270
+ // 写盘(D-F5b:磁盘真相最小化——默认不固化;冲突/畸形抛错,内存不热应用(零虚假成功))+
271
+ // 热应用(内存对象)。#645 写形归一(全端同形):`null` = 显式清除 ⇒ **盘删键**(回退
272
+ // DEFAULTS)+ 内存删键后回填该键 DEFAULTS 默认值(等价 loadConfig 重载;未登记键 ⇒ 零回填)。
255
273
  const r = await writeConfigAtomic(cfgPath, (disk) => {
256
- setKeyPath(disk, args.key, value)
274
+ if (value === null) deleteKeyPath(disk, args.key)
275
+ else setKeyPath(disk, args.key, value)
257
276
  })
258
277
  if (!r.ok) throw new Error("config changed on disk concurrently — retry (settings set not applied)")
259
- setKeyPath(config, args.key, value)
260
- const shown = isSensitiveKey(String(args.key)) ? MASKED : value
261
- return `settings set: ${args.key} = ${shown} (${Array.isArray(value) ? "array" : typeof value})${isSensitiveKey(String(args.key)) ? " — stored(值不回显)" : " — persisted + hot-applied(运行中已生效)"}`
278
+ if (value === null) {
279
+ deleteKeyPath(config, args.key)
280
+ const d = resolvePath(DEFAULTS, String(args.key))
281
+ if (d.ok) setKeyPath(config, args.key, d.value) // 回填默认(形状表键默认 null ⇒ 与今日同形)
282
+ } else setKeyPath(config, args.key, value)
283
+ const sensitive = isSensitiveKey(String(args.key))
284
+ const shown = sensitive ? MASKED : value
285
+ const suffix = value === null
286
+ ? " — key removed(键已删除——回退默认)"
287
+ : sensitive ? " — stored(值不回显)" : " — persisted + hot-applied(运行中已生效)"
288
+ return `settings set: ${args.key} = ${shown} (${_kindOf(value)})${suffix}`
262
289
  },
263
290
  }
264
291
  }
@@ -1,5 +1,6 @@
1
1
  import { loadSkills, readSkill } from "../skills.mjs"
2
2
  import { escapeXml } from "../agent.mjs"
3
+ import { DESC } from "../tools/shared.mjs"
3
4
 
4
5
  // ─── skill loader 注入缝(#88——「同步 loader 面按核内结构归一」的端侧覆盖面)──────────
5
6
  /**
@@ -17,14 +18,11 @@ export function resetSkillLoader() { injectedLoader = null }
17
18
 
18
19
  /**
19
20
  * skill tool: load project skill files on demand (.thincoder/skills/*.md).
20
- * After loading, skill content is injected into the conversation wrapped in <skill-loaded> for subsequent reference.
21
- * Use action="list" to see all available skills.
21
+ * 描述面 = `tool-docs/skill.md`(DESC() 加载);加载内容经 <skill-loaded> 注入会话。
22
22
  */
23
23
  export const skillTool = {
24
24
  name: "skill",
25
- description:
26
- "Load a project skill from .thincoder/skills/. Skills contain reusable instructions, workflows, or reference material. Use this when the user references a skill by name, or when a task matches a known skill's description. Call with action='list' to see available skills; call with action='load' and name=<skill> to activate one. " +
27
- "Returns the skill list ('list'), the load confirmation ('load' — instructions arrive in the next message), or Error: ... with the available skills.",
25
+ description: DESC("skill"),
28
26
  parameters: {
29
27
  type: "object",
30
28
  properties: {
@@ -68,7 +68,7 @@ export function validateTaskBookFields(args) {
68
68
  }
69
69
 
70
70
  /**
71
- * F6 过程档 basename 族(父侧维护文件——AGENT-LOOP.md §28 R26 F-R26b 同族扩展):
71
+ * F6 过程档 basename 族(父侧维护文件——AGENT-LOOP-SUBAGENT.md §6.9 R26 同族扩展):
72
72
  * CHANGELOG.md。(台账 = 用户数据目录键控库——项目内无文件,不再入族;老台账族 todo.md /
73
73
  * todo-archive.md / checklist 族随 M2 SQLite 化 + M7 checklist 废除退役。)
74
74
  * 大小写不敏感全名匹配(路径任意层——归一化在调用点 normalizeFileList 已做)。
@@ -76,7 +76,7 @@ export function validateTaskBookFields(args) {
76
76
  const PROCESS_BASENAMES = new Set(["changelog.md"])
77
77
 
78
78
  /**
79
- * 工程模式写入面 basename(#33 二道防线——设计 docs/core/design/MANIFEST.md §2.5 / KD-M1-14):
79
+ * 写域 basename(**模式无关**——#33 二道防线;`PROJECT-MANIFEST.json` 是项目级状态档,其写门对任何会话生效;设计 docs/core/design/MANIFEST.md §2.5 / KD-M1-14):
80
80
  * PROJECT-MANIFEST.json = M1 项目状态档——写门唯主 agent(`manifest.mjs` `writeManifest` 的
81
81
  * `writer` 闸,fail-closed);本常量 = `files` 声明面**第二道防线**(子代理无法把该路径声明为
82
82
  * 写域)。字面量(匹配前已大小写归一;**不** import `MANIFEST_REL`——保本模块「叶子·零 import」
@@ -98,7 +98,7 @@ export function rejectEngineeringFilePaths(files) {
98
98
  const segs = String(f).replace(/\\/g, "/").split("/").filter((s) => s)
99
99
  const base = (segs[segs.length - 1] ?? "").toLowerCase()
100
100
  if (segs.some((s) => s.toLowerCase() === "scripts")) {
101
- violations.push(`engineering tool path ${f} must not be listed in files — scripts/** is the engineering-tool face, not a content product (files carries source/test/design-doc paths only)`)
101
+ violations.push(`engineering-tools face path ${f} must not be listed in files — scripts/** is the engineering-tools face, not a content product (applies to every session, not engineering-mode-scoped; files carries source/test/design-doc paths only)`)
102
102
  } else if (PROCESS_BASENAMES.has(base)) {
103
103
  violations.push(`Parent-side maintained file ${f} must not be listed in files — reconciliation is the parent's duty; use the design-doc path if you need to edit a design doc`)
104
104
  } else if (base === MANIFEST_BASENAME) {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * subagent-actions-query.mjs — subagent 查询面执行器 + 摘要助手族(2026-09-28 拆分批 · R3——
3
+ * 自 `subagent-actions.mjs`(498/499 贴 500 硬限)外提查询面:status ∕ observe 两执行器 +
4
+ * 留痕文本构造(`touchedSummary` / `shortTouchedPath`);各函数**逐字迁移**、零行为改)。
5
+ * 原档 re-export `executeStatusAction` / `executeObserveAction` 保 `subagent.mjs` 与各消费档
6
+ * 的既有 import 面(路径与名面不动——subagent-panel 尾部 re-export 先例)。
7
+ * 依赖 = `subagent-scheduler.mjs`(describeBlockers / detectStall / STALL_NOTE)·
8
+ * `async-settle.mjs`(getAsyncPool accessor)· `checkpoint.mjs`(turnCapTrace)——三者均
9
+ * 不回引本档(无环)。
10
+ */
11
+ import { isAbsolute, relative } from "node:path"
12
+ import { describeBlockers, detectStall, STALL_NOTE } from "./subagent-scheduler.mjs"
13
+ // ASYNC-RESULT-CONTAINER.md D1:池 accessor(absorb 双池——advisor 独立池无队列)
14
+ import { getAsyncPool } from "./async-settle.mjs"
15
+ import { turnCapTrace } from "./checkpoint.mjs"
16
+
17
+ /**
18
+ * subagent action:"status" (AGENT-LOOP-SUBAGENT.md §6.7 D-M2, new): NON-BLOCKING async-pool query —
19
+ * returns immediately and never consumes a result (results belong to the auto
20
+ * channel — AGENT-LOOP-SUBAGENT.md §6.7.5: no check action any more). Source of truth = the pool
21
+ * (_asyncSubagents): entries moved to _pendingAsyncResults during a suspension
22
+ * (AGENT-LOOP-ASYNC-POOL.md §6.8 D-S3 ② — injected at the next run start) are no longer in the pool and
23
+ * are NOT counted as done-waiting.
24
+ * - id given → { id, role, status, model?, elapsedSec?, turn?, maxTurns?,
25
+ * touchedFiles?/touchedMore?/touched? ... } for that entry; unknown id → error
26
+ * (T12 semantics — unknown async-subagent id error, same wording as the pool).
27
+ * - id omitted → { overview: { running: [{id, role, model, elapsedSec, turn,
28
+ * maxTurns, touchedFiles?/touched?}], queued: [{id, role, position, touched?}],
29
+ * done: [{id, role}] } } — live queue positions (index in _asyncQueue + 1).
30
+ * AGENT-LOOP-SUBAGENT.md §6.7.2: running 条目带 touched files 摘要(touchedFiles 前 5 + touchedMore 超出
31
+ * 计数——相对查询方 cwd;0 改动 → touched 占位);queued 条目带 touched 占位
32
+ * "—(未启动)";done/error/取消条目无 touched 字段(round3 #9)。
33
+ * A settled-but-unconsumed entry (settled during a NORMAL turn) reports done
34
+ * with a "not yet consumed" note — the auto channel (turn-end collection / the
35
+ * suspension digest) still delivers it afterwards (AGENT-LOOP-SUBAGENT.md §6.7.5: sole consumption path).
36
+ */
37
+ /** AGENT-LOOP-SUBAGENT.md §6.7.2 D-M5 decision-field assembly (F9): running entries report
38
+ * {id, role, model, elapsedSec, turn, maxTurns} — the data needed to decide
39
+ * WHO to cancel. Model is recorded at spawn (childProvider), startedAt at
40
+ * ACTUAL start (queued waits don't count), turn/maxTurns mirrored from the
41
+ * child's ⟦ev⟧turn events at the callbacks-wrap layer (subagent.mjs tracker).
42
+ * elapsedSec computed at call time from startedAt. */
43
+ /** AGENT-LOOP-SUBAGENT.md §6.7.2 D-SF2/N-SF1 摘要形态:status 调用时实时读 entry.childAgent._touchedFiles
44
+ * (绝对路径——per-run 记账)→ 相对查询方 cwd;cwd 之外保留绝对形态 +
45
+ * "../" 前缀;>80 字符截尾(不超行);前 5 个 + 独立截断字段 touchedMore(超出
46
+ * 计数——不混入数组——消费方按类型区分)。占位:running 0 改动 → "—(尚无改动)"
47
+ * (T-SF2a);queued 未启动 → "—(未启动)"(T-SF2b);done/error/取消条目不含
48
+ * 本摘要(round3 #9 明示——本批只做 running/queued)。 */
49
+ function touchedSummary(entry, cwd) {
50
+ if (entry.status !== "running") return { touched: "—(未启动)" }
51
+ const files = entry.childAgent?._touchedFiles ?? []
52
+ if (files.length === 0) return { touched: "—(尚无改动)" }
53
+ const shown = files.slice(0, 5).map((f) => shortTouchedPath(f, cwd))
54
+ const out = { touchedFiles: shown }
55
+ if (files.length > 5) out.touchedMore = files.length - 5
56
+ return out
57
+ }
58
+
59
+ /** N-SF1 单路径显示形态:cwd 内 → 相对路径;cwd 外 → "../" + 绝对路径;>80 截尾。 */
60
+ function shortTouchedPath(f, cwd) {
61
+ let p = f
62
+ if (cwd) {
63
+ const rel = relative(cwd, f)
64
+ p = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : `../${f}`
65
+ }
66
+ return p.length > 80 ? `${p.slice(0, 79)}…` : p
67
+ }
68
+
69
+ function statusFields(entry, cwd) {
70
+ // AGENT-LOOP-ASYNC-POOL.md §6.11 #1(第 10 批——双池并表):评审池条目专属字段面——role 区分两池;子代理条目
71
+ // 字段面零变化(NFR-B2)。reviewType(design|code) / round / elapsedSec——与子代理
72
+ // 的 turn/maxTurns 而非(评审无子回合预算语义)。
73
+ if (entry.role === "advisor") {
74
+ const base = { id: String(entry.id), role: entry.role, reviewType: entry.reviewType ?? null, round: entry.round ?? entry.run?.round ?? null }
75
+ if (entry.model != null) base.model = entry.model
76
+ if (entry.startedAt) base.elapsedSec = Math.max(0, Math.round((Date.now() - entry.startedAt) / 1000))
77
+ return base
78
+ }
79
+ const base = { id: String(entry.id), role: entry.role }
80
+ if (entry.status === "running") {
81
+ base.model = entry.model ?? null
82
+ base.elapsedSec = entry.startedAt ? Math.max(0, Math.floor((Date.now() - entry.startedAt) / 1000)) : 0
83
+ base.turn = entry.turn ?? 0
84
+ base.maxTurns = entry.maxTurns ?? 0
85
+ // AGENT-LOOP-SUBAGENT.md §6.7.2:touched files 摘要(T-SF1..4——新字段追加——既有字段零破坏)
86
+ Object.assign(base, touchedSummary(entry, cwd), { turnCap: turnCapTrace(entry) })
87
+ } else if (entry.status === "queued") {
88
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 T-SF2b:未启动——确定性占位(不崩;无对象可读)
89
+ base.touched = "—(未启动)"
90
+ }
91
+ return base
92
+ }
93
+
94
+ export function executeStatusAction(args, ctx) {
95
+ // #46(批 TOOLFACE-FIXES ①——设计 AGENT-LOOP-SUBAGENT.md §6.25 ①):七动作族 depth 门收口——
96
+ // status 与 observe/send/escalate/cancel/panel freeze 同为 depth-0 专有(子代无自有异步池);
97
+ // 缺门时子代静默空 overview(把「不可用」读成「无在飞」)。族锚取现行可解析节号(§6.7.2)。
98
+ if ((ctx.depth ?? 0) > 0) {
99
+ return JSON.stringify({ status: "error", error: "status is only available at depth 0 — a child agent has no async pool of its own" })
100
+ }
101
+ const agent = ctx.agent
102
+ const map = getAsyncPool(agent, "subagent") ?? new Map()
103
+ const advisors = getAsyncPool(agent, "advisor") ?? new Map()
104
+ const queue = agent._asyncQueue ?? []
105
+ const queuedPosition = (id) => {
106
+ const i = queue.findIndex((e) => String(e.id) === id)
107
+ return i >= 0 ? i + 1 : undefined
108
+ }
109
+ const { id } = args ?? {}
110
+ if (id !== undefined && id !== null && String(id) !== "") {
111
+ const key = String(id)
112
+ // AGENT-LOOP-ASYNC-POOL.md §6.10 D-24b: by-id queries fall through to the advisor pool (shared counter —
113
+ // ids are unique across both pools; role identifies the kind).
114
+ const entry = map.get(key) ?? advisors.get(key)
115
+ if (!entry) {
116
+ // Wording kept exact — locked by subagent-async/tool tests (unknown-id error
117
+ // semantics); an unknown id simply names neither pool (the pools share the
118
+ // id counter, so the message stays unambiguous).
119
+ return JSON.stringify({ id: key, status: "error", error: `unknown async subagent id: ${key}` })
120
+ }
121
+ const target = statusFields(entry, agent.cwd)
122
+ if (entry.status === "running") return JSON.stringify({ ...target, status: "running" })
123
+ if (entry.status === "queued") {
124
+ // AGENT-LOOP-SUBAGENT.md §6.9 F-SD4/D-SD3b:waiting 语义对模型可见——排队原因(冲突对象/依赖对象)随
125
+ // status 返回;纯槽满等位(kind slot)无 waiting 字段(position 已足够)。
126
+ const blk = describeBlockers(agent, entry)
127
+ const out = { ...target, status: "queued", position: queuedPosition(key) ?? entry.position }
128
+ if (blk.kind !== "slot") {
129
+ out.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
130
+ out.reason = blk.detail
131
+ }
132
+ // §6.9 P-SL2(D-SL2):停滞机械标记——池停滞时本任务所在闭环阻塞链随 status
133
+ // 返回(F-SL2——status 视图可显示停滞——模型可见——零正常路径字段变化)。
134
+ const stall = detectStall(agent)
135
+ if (stall) {
136
+ const chain = stall.chains.find((c) => c.task === entry)?.text
137
+ if (chain) out.stall = { chain, note: STALL_NOTE }
138
+ }
139
+ return JSON.stringify(out)
140
+ }
141
+ // done = settled during this turn and not yet consumed — the auto channel still
142
+ // delivers it (AGENT-LOOP-SUBAGENT.md §6.7.5: turn-end collection / the suspension digest — no fetch action).
143
+ target.status = "done"
144
+ target.done = true
145
+ if (entry.error) target.error = entry.error
146
+ target.note = "settled, not yet consumed — delivered by the auto channel (turn-end collection or the suspension digest injects it)"
147
+ return JSON.stringify(target)
148
+ }
149
+ const overview = { running: [], queued: [], done: [] }
150
+ const mapEntries = [...map.values(), ...advisors.values()]
151
+ for (const entry of mapEntries) {
152
+ if (entry.status === "running") overview.running.push(statusFields(entry, agent.cwd))
153
+ else if (entry.status === "queued") {
154
+ // §6.9:queued 条目补 waiting/reason(F-SD4——依赖/冲突原因模型可见);
155
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 T-SF2b:未启动占位(确定性——不崩)。
156
+ const blk = describeBlockers(agent, entry)
157
+ const row = statusFields(entry, agent.cwd)
158
+ row.position = queuedPosition(String(entry.id)) ?? entry.position
159
+ if (blk.kind !== "slot") {
160
+ row.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
161
+ row.reason = blk.detail
162
+ }
163
+ overview.queued.push(row)
164
+ }
165
+ else if (entry.done) overview.done.push({ id: String(entry.id), role: entry.role })
166
+ }
167
+ // §6.9 P-SL2(D-SL2):停滞机械标记——overview 级标注(可显示停滞——F-SL2)——
168
+ // 链文本随视图返回(逐条——每 queued 条目一行——闭环可破环引导同挂)。
169
+ const stall = detectStall(agent)
170
+ if (stall) {
171
+ overview.stall = {
172
+ chains: stall.chains.map((c) => c.text),
173
+ note: STALL_NOTE,
174
+ }
175
+ }
176
+ return JSON.stringify({ overview })
177
+ }
178
+
179
+ // ═══════════════════════════════════════════════════════════════════════════
180
+ // SUBAGENT-OBSERVE-SEND(SUBAGENT-OBSERVE-SEND.md——CLI 端)
181
+ // observe(D1——readonly 查询)+ send(D2——控制类豁免注入引导)——动作执行器。
182
+ // 目标 = 父自身 spawn 的异步子代理池条目(_asyncSubagents——非 advisor/escalate——
183
+ // 后者共享 id 计数但非"读写子代理",错误路径明示)。
184
+ // ═══════════════════════════════════════════════════════════════════════════
185
+
186
+ /** D1/N2 摘要形态:从子代理 _fullHistory(real 消息——pushReal 累积)倒序收最近 N 个
187
+ * assistant 回合的一行描述(newest-first;assistant 消息 = 一回合——带 tool_calls =
188
+ * 工具轮、独立 content = 回复首行)。返回纯摘要非全量(N2 隔离——不把子代理噪音灌父)。 */
189
+ function recentTurnLines(child, limit) {
190
+ const hist = child?._fullHistory
191
+ if (!Array.isArray(hist)) return []
192
+ const out = []
193
+ for (let i = hist.length - 1; i >= 0 && out.length < limit; i--) {
194
+ const m = hist[i]
195
+ if (m?.role !== "assistant") continue
196
+ const tcs = Array.isArray(m.tool_calls)
197
+ ? m.tool_calls.filter((t) => typeof t?.function?.name === "string")
198
+ : []
199
+ if (tcs.length) out.push(`tools: ${tcs.map((t) => t.function.name).join(", ")}`)
200
+ else if (typeof m.content === "string" && m.content.trim()) {
201
+ const first = m.content.trim().split(/\r?\n/).find((l) => l.trim())
202
+ out.push((first ?? m.content.trim()).slice(0, 120))
203
+ } else out.push("(assistant — no content)")
204
+ }
205
+ return out
206
+ }
207
+
208
+ function queuedPositionOf(agent, key) {
209
+ const i = (agent?._asyncQueue ?? []).findIndex((e) => String(e.id) === key)
210
+ return i >= 0 ? i + 1 : undefined
211
+ }
212
+
213
+ /** recent 参数钳制(默认 ~5 条摘要——可参数;钳到 1..20 防超长 N2 失控)。 */
214
+ function clampRecent(n) {
215
+ const v = Number.parseInt(n, 10)
216
+ if (!Number.isFinite(v)) return 5
217
+ return Math.max(1, Math.min(20, v))
218
+ }
219
+
220
+ /**
221
+ * subagent action:"observe"(D1——READONLY 查询,父回合内):按 id 拉运行中异步子代理的
222
+ * 最近活动快照——从 entry.childAgent(subagent-run.mjs start() 绑定)读**已落回合**摘要
223
+ * (_fullHistory 最近 N 条)+ _touchedFiles + turn/maxTurns + status + **in-flight 当前
224
+ * 工具**(评审 #1——从 dispatch runOne 维护的 child._inflightTools 读——非只读已落
225
+ * history:卡在长工具调用时 history 无新回合、恰是 observe 要检测的卡死态)。返摘要非
226
+ * 全量(N2)。observe = readonly——running/queued/done 均可查(done 终报 / queued 占位)。
227
+ */
228
+ export function executeObserveAction(args, ctx) {
229
+ if ((ctx.depth ?? 0) > 0) {
230
+ return JSON.stringify({ status: "error", error: "observe is only available at depth 0 — a child agent has no async pool of its own" })
231
+ }
232
+ const agent = ctx.agent
233
+ const id = args?.id
234
+ if (id === undefined || id === null || String(id) === "") {
235
+ return JSON.stringify({ status: "error", error: "observe requires the id of the async subagent to inspect (from the async spawn return) — pass the id; omitting it observes nothing" })
236
+ }
237
+ const key = String(id)
238
+ const entry = getAsyncPool(agent, "subagent")?.get(key)
239
+ if (!entry) {
240
+ if (getAsyncPool(agent, "advisor")?.has(key)) {
241
+ return JSON.stringify({ status: "error", error: `id ${key} is an async ADVISOR review — observe is for async subagents (read/edit children); track an advisor with action:'status' or wait for its auto-delivered report` })
242
+ }
243
+ return JSON.stringify({ status: "error", error: `unknown async subagent id: ${key}` })
244
+ }
245
+ const child = entry.childAgent
246
+ const out = { id: key, role: entry.role, status: entry.status }
247
+ if (entry.status === "running") {
248
+ out.turn = entry.turn ?? 0
249
+ out.maxTurns = entry.maxTurns ?? 0
250
+ Object.assign(out, touchedSummary(entry, agent.cwd), { turnCap: turnCapTrace(entry) })
251
+ // in-flight 当前工具(评审 #1):child._inflightTools Set——dispatch runOne 在工具
252
+ // 执行前后维护——LLM 生成/工具间空隙为空;子代理 await 长工具调用时父回合可见它。
253
+ const inflight = child?._inflightTools
254
+ if (inflight instanceof Set && inflight.size > 0) out.currentTool = [...inflight]
255
+ const n = clampRecent(args?.recent)
256
+ out.recentTurns = recentTurnLines(child, n)
257
+ out.note = `observing running ${entry.role}#${key} — recentTurns newest-first (cap ${n}); currentTool shown only while a tool is executing (stuck detection); sent directions consume at the next turn boundary`
258
+ } else if (entry.status === "queued") {
259
+ out.position = queuedPositionOf(agent, key) ?? entry.position ?? null
260
+ out.note = "queued — not started yet; no activity to observe (starts when the slot frees / dependencies clear; action:'send' targets running only)"
261
+ } else {
262
+ // done = settled this turn, not yet consumed — final report rides the auto channel.
263
+ out.done = true
264
+ out.turn = entry.turn ?? 0
265
+ out.maxTurns = entry.maxTurns ?? 0
266
+ out.recentTurns = child ? recentTurnLines(child, clampRecent(args?.recent)) : []
267
+ out.note = "settled — the final report is delivered by the auto channel (turn-end collection or the suspension digest); observe returns the activity summary, not the full report (N2)"
268
+ }
269
+ return JSON.stringify(out)
270
+ }