thincoder 0.12.58 → 0.12.59

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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -1,62 +1,120 @@
1
1
  import {
2
- createAgent, runAgent,
3
- readonlyToolNames, collectGitContext, escapeXml,
2
+ createAgent,
3
+ readonlyToolNames, escapeXml,
4
4
  EXPLORE_OVERLAY, CODER_OVERLAY, PLAN_OVERLAY, ENG_CODER_OVERLAY,
5
- MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
6
5
  } from "../agent.mjs"
7
- import { makeRelay, wrapChildCallbacks, runWithContinue, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
6
+ import { makeRelay, wrapChildCallbacks, gateEngCoderSpawn, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
8
7
  import { validateDesignToken } from "./advisor.mjs"
8
+ import { pushReal } from "../context.mjs"
9
+ import { logEvent, errText } from "../log.mjs"
10
+ import {
11
+ runChildPipeline, resolveChildProvider, ASYNC_SUBAGENT_LIMIT,
12
+ buildChildRunOpts, executeCheckAction, executeCancelAction,
13
+ } from "./subagent-async.mjs"
14
+ import { executeStatusAction, executeEscalateAction, executePanelAction } from "./subagent-actions.mjs"
15
+ import {
16
+ // §20 调度器(AGENT-LOOP.md §20——D-SD1..SD5):文件域归一化/等待态派生/环防御/
17
+ // 排队态面板刷新/依赖者标注/补位——2026-09-05 拆分轮后独立模块。
18
+ normalizeFileList, describeBlockers, queueRunnable, refreshQueuedTokens,
19
+ assertNoDepCycle, depInfo, dependentLabels, maybeRefillAsync,
20
+ } from "./subagent-scheduler.mjs"
9
21
 
10
- // Async subagent limits (AGENT-LOOP.md §15 D-A4): mechanical concurrency cap for
11
- // background spawns + the per-turn check budget (consult-style loop guard).
12
- export const ASYNC_SUBAGENT_LIMIT = 4
13
- export const MAX_ASYNC_CHECKS = 3
22
+ // 2026-09-03 拆分轮: subagent.mjs 500 硬顶——async 常量、共享 post-spawn 管线
23
+ //(runChildPipeline)与队列/注入/并账机械迁至 ./subagent-async.mjs。execute
24
+ //(async 分支 + 阻塞路径)原样保留于本文件;导出面由文末 re-export shim 兜住。
25
+ // 2026-09-03 §19 合体轮: subagent_check/escalate 工具退役——check/status/escalate
26
+ // 动作执行器并入 ./subagent-async.mjs,本文件只承载工具面(action schema)与
27
+ // spawn 路径 + 动作分流。
28
+ // 2026-09-05 拆分轮: status/escalate/panel 动作执行器 → ./subagent-actions.mjs;§20
29
+ // 调度器全套(normalizeFileList/describeBlockers/queueRunnable/refreshQueuedTokens/
30
+ // assertNoDepCycle/depInfo/dependentLabels/maybeRefillAsync)→ ./subagent-scheduler.mjs
31
+ // ——本文件 import 源随之改写;check/cancel 与机械/管线仍来自 ./subagent-async.mjs。
14
32
 
15
33
  /**
16
- * subagent tool: spawn a child agent to handle an independent subtask (isolated context, only the report is returned).
17
- * - role: "explore" read-only tools, search/read/analyze (suitable for codebase exploration)
18
- * - role: "coder" full tool set, self-contained implementation tasks (suitable for isolated coding)
34
+ * §18.7 D-TS5 (A2): mechanically summarize the parent spawn task book for the
35
+ * audit spawnthe three audit-relevant elements VERBATIM (design doc paths /
36
+ * affected-file list / acceptance criteria); verbose context/background is
37
+ * dropped (the auditor can read the design docs themselves — they stay
38
+ * available outside this input). Independence preserved: the input is
39
+ * _engTaskInput (mechanically kept by the parent spawn) — never the
40
+ * eng-coder's self-report. Sections are located by header marker, prioritizing
41
+ * header lines (structured task books: "## 文件清单 …") and falling back to
42
+ * inline markers (flat one-line task books); a section runs to the next header
43
+ * of the SAME OR HIGHER level ("## 文件清单" survives a "### 修改" sub-header).
44
+ * Marker not found → the section is reported as missing (never fabricate).
45
+ */
46
+ function summarizeEngTaskBook(taskInput) {
47
+ if (!taskInput) return "(unavailable)"
48
+ const SECTIONS = [
49
+ { name: "Design docs involved", markers: [/Docs? involved/i, /涉及文档/] },
50
+ { name: "Affected-file list", markers: [/Files? (?:list|to (?:modify|change)|modified)/i, /受影响文件/, /文件清单/, /涉及文件/] },
51
+ { name: "Acceptance criteria", markers: [/Acceptance(?: criteria)?/i, /验收标准/] },
52
+ ]
53
+ const lines = taskInput.split("\n")
54
+ const headerLevel = (l) => {
55
+ const m = l.match(/^\s*(#{1,6})\s/)
56
+ return m ? m[1].length : 0
57
+ }
58
+ const headerIdx = lines.map((l, i) => (headerLevel(l) > 0 ? i : -1)).filter((i) => i >= 0)
59
+ const boundsFor = (from, level) => {
60
+ for (const j of headerIdx) {
61
+ if (j > from && (level === 0 || headerLevel(lines[j]) <= level)) return j
62
+ }
63
+ return lines.length
64
+ }
65
+ const out = []
66
+ for (const { name, markers } of SECTIONS) {
67
+ let from = -1
68
+ let level = 0
69
+ for (const i of headerIdx) {
70
+ if (markers.some((m) => m.test(lines[i]))) { from = i; level = headerLevel(lines[i]); break }
71
+ }
72
+ if (from === -1) {
73
+ for (let i = 0; i < lines.length; i++) {
74
+ if (markers.some((m) => m.test(lines[i]))) { from = i; level = 0; break }
75
+ }
76
+ }
77
+ if (from === -1) { out.push(`${name}: (not found in the parent task book)`); continue }
78
+ const body = lines.slice(from, boundsFor(from, level)).join("\n").trim()
79
+ out.push(body || `${name}: (empty section)`)
80
+ }
81
+ return out.join("\n\n")
82
+ }
83
+
84
+ /**
85
+ * subagent tool — ONE tool, SIX actions (AGENT-LOOP.md §19/§19.5/§19.6): spawn
86
+ * (default) / check (fetch async results, blocking + consuming) / status
87
+ * (non-blocking pool query) / escalate (飞刀 — hand implementation to a
88
+ * stronger model) / cancel (stop ONE background subagent — §19.5) / panel
89
+ * (view + fix the live subagent panel — §19.6).
90
+ * - action:"spawn" roles: "explore" — read-only tools, search/read/analyze
91
+ * (suitable for codebase exploration); "coder" — full tool set, self-contained
92
+ * implementation tasks; "plan" — read-only planning; "eng-coder" —
93
+ * engineering-mode implementation (design-token gated).
19
94
  * - no role specified — invalid by design since the 2026-08-25 fail-closed gate
20
95
  * (role is mandatory; "no role → same tool set as parent" was removed with the
21
96
  * coder-leak fix and the header text above predates it)
22
- * - parallel subagent calls via the parallel channel (parallel: true)
23
97
  * - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
24
98
  */
25
99
 
26
100
  /**
27
101
  * Effective subagent model override for a role (CLI parity shared with VS Code):
28
102
  * priority — subagent tool `model` arg > config.agent.subagentModels[role] > config.agent.subagentModel > null (inherit parent).
103
+ * The literal "default" (case-insensitive) means "no tool-arg override → run the
104
+ * chain" — same as omitting the parameter (2026-09-05 user ruling).
29
105
  */
30
106
  export function effectiveSubagentModel(parent, role, modelArg) {
31
- if (modelArg) return modelArg
107
+ // "default" alias (2026-09-05 user ruling — ARCHITECTURE.md 子 agent 模型指定):
108
+ // the literal "default", matched case-insensitively, explicitly declares "no
109
+ // override at the tool-arg level → run the default priority chain" (type-level
110
+ // subagentModels[role] → global subagentModel → null = inherit parent). It is
111
+ // equivalent to omitting the parameter / passing ""/null/undefined (which fall
112
+ // through below). Any other value still overrides.
113
+ if (modelArg && String(modelArg).toLowerCase() !== "default") return modelArg
32
114
  const cfg = parent.config?.agent ?? {}
33
115
  return cfg.subagentModels?.[role] ?? cfg.subagentModel ?? null
34
116
  }
35
117
 
36
- /**
37
- * Resolve the sub-agent's provider from a model override string (shared with the
38
- * VS Code port). Forms accepted:
39
- * "provider:model" → the named provider with the named model
40
- * "provider" → the named provider's configured model
41
- * "model" → same provider as the parent, different model
42
- * null → parent's provider unchanged.
43
- * API keys come from config.json only (env vars are not a key source).
44
- */
45
- export function resolveChildProvider(parent, modelArg) {
46
- if (!modelArg) return { ...parent.provider }
47
- const providers = parent.config?.providersList ?? []
48
- const withKey = (p) => (p.apiKey?.trim() ? { ...p, apiKey: p.apiKey.trim() } : { ...p })
49
- if (modelArg.includes(":")) {
50
- const [pname, mname] = modelArg.split(":")
51
- const p = providers.find((x) => x.name === pname)
52
- if (!p) throw new Error(`subagent model: unknown provider "${pname}" (available: ${providers.map((x) => x.name).join(", ") || "none"})`)
53
- return { ...withKey(p), model: mname || p.model }
54
- }
55
- const byName = providers.find((x) => x.name === modelArg)
56
- if (byName) return withKey(byName)
57
- return { ...parent.provider, model: modelArg }
58
- }
59
-
60
118
  /**
61
119
  * Resolve the design-token slot for an eng-coder spawn (2026-09-01 multi-design, FR3):
62
120
  * - designId given → exact slot lookup (no match = explicit error, never a fuzzy guess)
@@ -92,15 +150,22 @@ export function resolveDesignSlot(parent, designIdArg) {
92
150
  export const subagentTool = {
93
151
  name: "subagent",
94
152
  description:
95
- "Spawn a sub-agent to handle an independent subtask in an isolated context. The sub-agent returns only its final report. Spawn MULTIPLE subagents in the SAME response for parallel work—they run concurrently.\n" +
153
+ "ONE tool, SIX actions (AGENT-LOOP.md §19/§19.5/§19.6) pick by what you need:\n" +
154
+ "- action:'spawn' (DEFAULT): spawn a sub-agent to handle an independent subtask in an isolated context; the sub-agent returns only its final report. Spawn MULTIPLE subagents in the SAME response for parallel work—they run concurrently.\n" +
155
+ "- action:'check': fetch the report of an async spawn (async:true). BLOCKS until the target finishes — this is the explicit consuming fetch, NOT a progress query. Multiple async children return in completion (arrival) order, first finished first. Pass n = 1 on the first check of the turn, 2 on the next... (loop guard; at most 3 per turn — the rest arrive at turn end).\n" +
156
+ "- action:'status': NON-BLOCKING progress query — returns immediately and consumes nothing. Give the spawn's id for one child ({id, role, status: running|queued|done, model, elapsedSec, turn, maxTurns, position?), or omit it for an overview of the whole pool ({overview: {running: [{id, role, model, elapsedSec, turn, maxTurns}], queued: [{id, role, position}], done: [{id, role}]}}). §19.5.6 touched-files summary: running entries also carry touchedFiles (first 5, relative to your cwd), touchedMore (count beyond 5) and, when nothing was touched yet, the placeholder touched (\"—(尚无改动)\"); queued (not yet started) entries carry the placeholder touched (\"—(未启动)\") — see what a running child has changed BEFORE deciding to cancel it. Use THIS to check progress — action:'check' blocks until the target finishes (checking progress with check is what hangs a parallel turn).\n" +
157
+ "- action:'escalate' (飞刀 — a flown-in expert): hand an implementation task to a STRONGER model from your consult models (agent.consultModels). It gets WRITE access and does the work itself — reads, edits, runs tests — then returns a post-op report (what changed, why, verification). Use it when YOU judge the task calls for stronger hands (complex multi-file refactoring, an intractable bug, intricate algorithm work — or work beyond your comfortable ability); escalate EARLY, not after burning attempts. model: pick a candidate as 'provider:model' (default = the first consult model). Not available in engineering mode (implementation goes through eng-coder spawns there).\n" +
158
+ "- action:'cancel': STOP one background subagent — pass the id from the async spawn return (REQUIRED — omitting it errors; a blanket cancel is unsupported, Ctrl+C stops everything). Running target aborts immediately ({id, status:'cancelled'}); a queued target is removed from the queue ({id, status:'cancelled', was:'queued'} and later queue positions shift forward). Other children and the session keep running — cancellation is targeted. Use it when a background child is going the wrong way (e.g. burning turns) and you must stop it before its report arrives. Cancel is a last resort: verify alarming signals with reliable checks (git/node — not guesses) first; prefer scoped recovery (restore a single affected file) over killing the child — a running child's in-flight work dies with it, partial changes stay unmerged and unaudited.\n" +
159
+ "- action:'panel': DIAGNOSE + fix the subagent panel — the collapsible blocks under the conversation the user sees (CLI TUI panel mirror; headless/VS Code degrade to a 'no panel' pool view). view (default — call it with no params or view:true): returns the live panel blocks [{key, role, status: running|done|awaitingDigest} — running entries also carry elapsedSec; awaitingDigest entries whose report is ALREADY digested carry digested:true (stuck blocks — the freezable ones — explain odd panel states here)] exactly as the user sees them. freeze: pass the block key of a digested-stuck block ({action:'panel', freeze:'role#N'}) to reclaim it into the conversation — the freeze ONLY passes for awaitingDigest blocks with no live pool entry and no pending report (gated); freezing a block whose report is still pending would break the digestion order and is refused with a clear error.\n\n" +
96
160
  "Why delegate? A sub-agent runs in its own isolated context — its reads, searches, tool calls and edits never enter your history or pollute your window; only its final report comes back. Delegation keeps your working context lean (you see the whole session, not the child's noise) and the child single-mindedly focused on one task. Parallel children run concurrently, saving wall-clock time. Every coder/eng-coder child carries its own verify + advisor self-review discipline — handed-off work is already verified before you read a word of it.\n\n" +
97
161
  "Available roles (which roles are exposed depends on the active mode — see Mode filtering below):\n" +
98
- "- explore — read-only search & analysis. Toolset: the read/search family (grep, read, glob, code_search, doc_search, repo_outline, lsp, tree...). Receives git context auto-injected (branch, recent commits, working-tree state) when the project is a git repo. Its report must list what it searched and what it did NOT find. Fast — specify thoroughness in the task: quick / medium / thorough (default medium).\n" +
162
+ "- explore — read-only search & analysis. Toolset: the read/search family (grep, read, glob, code_search, doc_search, repo_outline, lsp, tree...). No git context injected—evidence from read/glob/grep and the task book. Its report must list what it searched and what it did NOT find. Fast — specify thoroughness in the task: quick / medium / thorough (default medium).\n" +
99
163
  "- plan — read-only implementation planning. Same read/search toolset; NEVER edits files. Returns a step-by-step plan for the parent to execute.\n" +
100
164
  "- coder — full implementation. The parent's complete read/write/execute toolset plus verify and advisor for self-review. Its final report must include a delivery transparency table with one row per task requirement (Done / Simplified / Not done — no deferred column).\n" +
101
165
  "- eng-coder — engineering-mode coder (available only in engineering mode, replacing coder). Same full toolset as coder plus the design-driven methodology overlay; REQUIRES a valid designToken arg obtained from a passed advisor(type='design') review. The advisor's Approved reply also echoes a designId — pass it as the designId arg: required to pick between designs when several approved reviews are active, optional for a single design. The delivery report echoes the designId back for the audit fix round.\n" +
102
166
  "Mode filtering: normal mode exposes explore/plan/coder; engineering mode exposes explore/plan/eng-coder. The schema enum reflects the active mode.\n\n" +
103
- "Async spawn (AGENT-LOOP.md §15): pass async:true to spawn WITHOUT waiting — returns {id, role, status:\"running\"} immediately so you can keep working in your own turn (read/check files, run other tools) while the child runs in the background. Collect results with subagent_check — multiple async children return in completion (arrival) order, first finished first, so fast results are handled immediately. Use async when your own turn must keep moving; use the default blocking spawn when you must see the report before continuing. Async spawns are capped at 4 concurrent (further spawns queue with a position), and top-level only.\n\n" +
167
+ "Async spawn (AGENT-LOOP.md §15/§18): pass async:true to spawn WITHOUT waiting — returns {id, role, status:\"running\"} immediately so you can keep working in your own turn (read/check files, run other tools) while the child runs in the background. Fetch the report later with action:'check' — multiple async children return in completion (arrival) order, first finished first, so fast results are handled immediately. Query progress with action:'status' (non-blocking) — action:'check' BLOCKS until the target finishes. The DEFAULT is role-level: role='eng-coder' spawns async (its delivery protocol runs fully inside the child — implementation → audit → self-fix → advisor re-review → converged delivery; pass async:false only when you must handle the report synchronously); every other role defaults to blocking. Use async when your own turn must keep moving; use a blocking spawn when you must see the report before continuing. Async spawns are capped at 4 concurrent (further spawns queue with a position), and top-level only.\n\n" +
168
+ "Task scheduling (AGENT-LOOP.md §20): declare the scheduling metadata to let the SCHEDULER order your spawns — files: the file paths this task will modify, dependsOn: ids from prior async spawn returns whose outcome this task needs. Overlapping-file tasks are serialized and dependent tasks are started in order automatically: a spawn that would conflict, or whose dependencies have not settled, queues instead of running ({id, status:\"queued\", position, reason} — the waiting task auto-starts when the conflict clears / its dependency settles; cancel a queued task to drop it). A spawn whose dependency was cancelled or failed stays queued and marked \"dependency cancelled\" until you decide (cancel it) — in an AUTO session it starts by itself. Referencing an unknown id errors; an id already consumed by check counts as satisfied. Omit both parameters for the plain immediate spawn (no scheduler involvement).\n\n" +
104
169
  "Writing the prompt:\n" +
105
170
  "- The sub-agent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics.\n" +
106
171
  "- Put exact paths and commands in the prompt when you know them. The sub-agent should not search for things you already know.\n" +
@@ -109,22 +174,70 @@ export const subagentTool = {
109
174
  parameters: {
110
175
  type: "object",
111
176
  properties: {
112
- task: { type: "string", description: "Self-contained task description for the sub-agent" },
113
- context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation)" },
114
- role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder"], description: "The sub-agent role see the tool description for the role capability matrix. Exact spelling required." },
115
- model: { type: "string", description: "Provider/model override for this sub-agent: 'provider:model', a provider name from config, or a model name on the parent's provider. Defaults to the agent.subagentModel config, then the parent's provider. Useful for offloading heavy work to a cheaper model." },
177
+ action: { type: "string", enum: ["spawn", "check", "status", "escalate", "cancel", "panel"], description: "Which subagent-family action — spawn (default), check (fetch async results — BLOCKS until the target finishes and consumes the report), status (non-blocking progress query — never consumes), escalate (飞刀 — hand implementation to a stronger consult model), cancel (stop ONE background subagent — pass its id; never omit), panel (view the live subagent panel / freeze a digested-stuck block — §19.6). See the tool description for the full action matrix." },
178
+ view: { type: "boolean", description: "action:'panel' only: true (default) = return the live panel blocks (the mirror of what the user sees). false with no freeze = nothing to do — error. Mutually exclusive with freeze (freeze wins)." },
179
+ freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck awaitingDigest block (e.g. \"eng-coder#9\") to reclaim into the conversation via the gated done-freeze event. Refused when the block is running/done/unknown or its report is still pending digestion (would break the digestion order). Requires the CLI TUI panel mirror — headless/VS Code report the freeze unavailable." },
180
+ task: { type: "string", description: "Required for action:'spawn' (the self-contained task brief) and action:'escalate' (goal, constraints, entry files, acceptance criteria). Not used by check/status." },
181
+ context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
182
+ role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder"], description: "The sub-agent role — see the tool description for the role capability matrix. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
183
+ model: { type: "string", description: "action:'spawn': provider/model override for this sub-agent ('provider:model', a provider name, or a model name on the parent's provider — defaults to config.agent.subagentModels[role], then config.agent.subagentModel, then the parent's provider). pass \"default\" to explicitly inherit the default model — equivalent to omitting the parameter. action:'escalate': pick a consult candidate as 'provider:model' (default = the first consult model)." },
116
184
  designToken: { type: "string", description: "Required when role='eng-coder': the token returned by advisor(type='design') after the design review passed. Without a valid token, eng-coder cannot modify files." },
117
185
  designId: { type: "string", description: "Optional when role='eng-coder': the designId echoed with the approved token by advisor(type='design'). Required to pick between designs when several approved reviews are active in the session — each eng-coder carries its own designId+token pair so parallel implementations never overwrite each other. Optional for a single design." },
118
- async: { type: "boolean", description: "true = spawn without waiting — returns {id} immediately, fetch results later via subagent_check. Default false (blocking)." },
186
+ async: { type: "boolean", description: "true = spawn without waiting — returns {id, status:\"running\"} immediately, fetch results later via action:'check'. Default is role-level: role='eng-coder' → true (async; its internal delivery protocol runs in the background — pass async:false to force the blocking spawn when you must process the report before continuing); all other roles → false (blocking)." },
187
+ files: { type: "array", items: { type: "string" }, description: "action:'spawn' only: the file write-domain this task declares (cwd-relative or absolute paths). files must be file-level paths (one per file you will modify). Directory declarations are NOT supported — they bypass the conflict detector and are rejected with an error. Tasks with overlapping files are serialized automatically — a conflicting spawn queues ({id, status:\"queued\", position, reason}) instead of running concurrently and starts when the conflict clears. Omit to skip conflict detection (plain immediate spawn)." },
188
+ dependsOn: { type: "array", items: { type: "string" }, description: "action:'spawn' only: ids from prior async spawn returns whose outcome this task needs — the task queues ({id, status:\"queued\", position, reason}) until every dependency settles, then starts automatically. Ids consumed by action:'check' count as satisfied; a dependency cancelled or failed leaves the task queued marked 'dependency cancelled' until you decide (cancel it — AUTO sessions auto-start). Unknown ids error." },
189
+ id: { type: "string", description: "action:'check'/'status'/'cancel': the subagent id from the async spawn return. check: omit = the next completed child (arrival order); status: omit = overview of the whole pool; cancel: REQUIRED (never omit — a blanket cancel is unsupported)." },
190
+ n: { type: "number", description: "action:'check' (required): 1-based read counter — 1 for the first check of the turn, incrementing with each subsequent check (loop detector — consecutive checks must be distinct tool calls)." },
119
191
  },
120
- required: ["task"],
192
+ required: [],
121
193
  },
122
194
  readonly: false,
123
195
  sideEffectExempt: true, // child agent may write files; parent can't introspect its _mutatedThisRun
124
196
  parallel: true,
125
197
  async execute(args, ctx) {
198
+ // §19 action dispatch: default spawn keeps every legacy call unchanged
199
+ // (no action parameter → the spawn path below, byte-identical semantics).
200
+ const action = args?.action !== undefined && args?.action !== null && String(args.action) !== ""
201
+ ? String(args.action)
202
+ : "spawn"
203
+ if (action !== "spawn") {
204
+ // §19 restricted-variant action gate (round2 #3): the eng-coder audit
205
+ // channel (depth>0, role eng-coder) is spawn-only — escalate spawns a
206
+ // coder+WRITE child (violates explore-only intent) and check/status/panel
207
+ // have no async pool / panel mirror to query in a child context.
208
+ if ((ctx.depth ?? 0) > 0 && ctx.agent?._role === "eng-coder") {
209
+ throw new Error(`only action:'spawn' (sync explore audits) is available inside an eng-coder — escalate/check/status/cancel/panel are not (AGENT-LOOP.md §19 D-M3)`)
210
+ }
211
+ // §17 N3/D-S6 spawn gate (manual tier): auto-turn digests may not spawn —
212
+ // async OR blocking — the digest must stay organize-only. The escalate
213
+ // action spawns a write child too, so the same mechanical refusal applies
214
+ // (AUTO tier exempt — user authorized unattended continuation).
215
+ if (action === "escalate" && ctx.agent?._inAutoTurn && !ctx.agent?.autoApprove) {
216
+ return JSON.stringify({ status: "error", error: "cannot spawn subagents from a manual auto-turn — wait for user input" })
217
+ }
218
+ if (action === "check") return await executeCheckAction(args, ctx)
219
+ if (action === "status") return executeStatusAction(args, ctx)
220
+ if (action === "escalate") return await executeEscalateAction(args, ctx)
221
+ // §19.5 控制类动作:digest 内放行(D-S7 分类——控制/自省;dispatch 控制类
222
+ // 豁免同批生效——19.5.2b round2 #4;escalate 的 digest 拒绝在上一分支)
223
+ if (action === "cancel") return executeCancelAction(args, ctx)
224
+ // §19.6 panel 动作:view(readonly 面——digest 内放行——自省类)与 freeze
225
+ // (控制类——同 cancel——digest 内放行)。深度/门控检查在 executePanelAction 内。
226
+ if (action === "panel") return executePanelAction(args, ctx)
227
+ throw new Error(`Unknown subagent action: ${JSON.stringify(action)}. Valid actions: spawn, check, status, escalate, cancel, panel.`)
228
+ }
229
+
126
230
  const parent = ctx.agent
127
231
  const role = args.role
232
+ // Spawn requires a task brief — schema `required` is advisory (multi-action
233
+ // schema), so the mechanical check lives here: an absent task would otherwise
234
+ // flow downstream as `content: undefined` and surface as an obscure error.
235
+ if (typeof args.task !== "string" || !args.task.trim()) {
236
+ throw new Error("subagent action:'spawn' requires a task (the self-contained task brief).")
237
+ }
238
+ // §18 F1/D-E1 role-level async default: eng-coder spawns async unless the
239
+ // caller explicitly passes async:false; every other role stays blocking.
240
+ const wantAsync = args.async ?? role === "eng-coder"
128
241
 
129
242
  // Role normalization + whitelist (2026-08-25, coder-leak fix): exact-string gates let
130
243
  // variant roles ("Coder", " coder") bypass BOTH mode gates and fall through to
@@ -134,6 +247,13 @@ export const subagentTool = {
134
247
  if (!ROLES.has(role)) {
135
248
  throw new Error(`Unknown subagent role: ${JSON.stringify(role)}. Valid roles: explore, plan, coder, eng-coder (exact spelling).`)
136
249
  }
250
+ // §18 D-E3 internal-spawn gate: an eng-coder sub-agent may only spawn sync
251
+ // explore (audit) children — non-explore roles and async are refused here
252
+ // (mechanical), the audit budget is enforced (7th audit spawn refused), and
253
+ // the returned attempt number marks this spawn as an audit for the task-book
254
+ // augmentation below. Runs BEFORE the mode gates so the eng-coder-specific
255
+ // error (not the generic engineering-mode one) surfaces.
256
+ const engAuditAttempt = gateEngCoderSpawn(ctx.agent, ctx.depth, role, args.async)
137
257
  // Role is mutually exclusive per mode: normal mode → "coder", engineering mode → "eng-coder"
138
258
  if (parent.config?.agent?.engineering && role === "coder") {
139
259
  throw new Error("Engineering mode: use role='eng-coder' for implementation tasks.")
@@ -142,6 +262,60 @@ export const subagentTool = {
142
262
  throw new Error("Engineering mode is not active — use role='coder' for implementation tasks.")
143
263
  }
144
264
 
265
+ // §17 N3/D-S6 spawn gate (manual tier): auto-turn digests may not spawn — async
266
+ // OR blocking — the digest must stay organize-only. AUTO tier (autoApprove) is
267
+ // exempt (推进型 — user authorized unattended continuation). Mechanical refusal
268
+ // so the digest never pops a permission panel or chains new background work.
269
+ // (escalate 动作的同类拒绝在 action 分流处——本检查只管 spawn 路径。)
270
+ if (parent._inAutoTurn && !parent.autoApprove) {
271
+ return JSON.stringify({ status: "error", error: "cannot spawn subagents from a manual auto-turn — wait for user input" })
272
+ }
273
+
274
+ // ── §20 spawn 调度参数准入(AGENT-LOOP.md §20 D-SD1/D-SD3 + 20.4 round2 #5/#7)──
275
+ // files/dependsOn 声明即契约(v1:不做任务书文本自动解析——不可靠)。缺省(两者皆
276
+ // 缺)= 既有语义零改动(不参与冲突检测/无校验——legacy spawn 零开销直通)。
277
+ // 校验序:参数形态 → 依赖 unknown id(非 consumed 墓碑——T-SD10)→ 依赖环可达
278
+ // (T-SD5——防御断言:自然流程不可达)→ 等待态判定。判定结果:wait/depc 阻塞 →
279
+ // async 入 queued 等位(spawn 返回带 reason——D-SD3b);**sync spawn(async:false)
280
+ // 命中阻塞 → 明确错误——不队列化 sync——sync 语义零变更(round2 #7——T-SD13)**。
281
+ const filesRaw = args.files
282
+ const dependsRaw = args.dependsOn
283
+ // §20.8 D-F1.1:目录声明 fail-closed——检测器 throw → catch → 错误即工具结果
284
+ // (模型可见"文件级明细"提示——不加静默——目录绕过冲突检测的通道闭合)。
285
+ let files = []
286
+ if (filesRaw !== undefined && filesRaw !== null) {
287
+ try {
288
+ files = normalizeFileList(filesRaw, parent.cwd)
289
+ } catch (e) {
290
+ return JSON.stringify({ status: "error", error: e.message })
291
+ }
292
+ }
293
+ if (filesRaw !== undefined && filesRaw !== null && !Array.isArray(filesRaw)) {
294
+ throw new Error("subagent files must be an array of file paths (the write domain this task declares)")
295
+ }
296
+ const dependsOn = []
297
+ if (dependsRaw !== undefined && dependsRaw !== null) {
298
+ if (!Array.isArray(dependsRaw)) throw new Error("subagent dependsOn must be an array of async subagent ids (from prior spawn returns)")
299
+ for (const d of dependsRaw) {
300
+ if (typeof d !== "string" && typeof d !== "number") {
301
+ throw new Error(`subagent dependsOn entries must be async subagent ids — got ${JSON.stringify(d)}`)
302
+ }
303
+ dependsOn.push(String(d))
304
+ }
305
+ }
306
+ if (files.length > 0 || dependsOn.length > 0) {
307
+ for (const d of dependsOn) {
308
+ if (depInfo(parent, d).state === "unknown") {
309
+ throw new Error(`subagent dependsOn: unknown async subagent id: ${d} — dependsOn references ids from prior async spawn returns; an id already consumed by action:'check' (or auto-injected) counts as satisfied, anything else is a mistake (AGENT-LOOP.md §20 D-SD5)`)
310
+ }
311
+ }
312
+ assertNoDepCycle(parent, dependsOn)
313
+ const block = describeBlockers(parent, { _files: files, _dependsOn: dependsOn })
314
+ if (!wantAsync && block.kind !== "slot") {
315
+ throw new Error(`sync spawn (async:false) cannot queue behind a scheduling conflict: ${block.detail} — pass async:true to queue the task (the scheduler starts it when the blockers clear), or wait for them to finish first (AGENT-LOOP.md §20 round2 #7)`)
316
+ }
317
+ }
318
+
145
319
  // Provider/model override: tool `model` arg > subagentModels[role] > subagentModel > parent provider
146
320
  const childProvider = resolveChildProvider(parent, effectiveSubagentModel(parent, role, args.model))
147
321
 
@@ -207,6 +381,12 @@ export const subagentTool = {
207
381
 
208
382
  // Token-verified design review → child is authorized to modify files without re-reviewing
209
383
  if (role === "eng-coder") child._engDesignReviewed = true
384
+ // §18 D-E3 task-domain authorization: approved design + spawn task = authorization.
385
+ // The child's OWN tools skip ONLY the onPermissionRequest ask (autoApprove
386
+ // equivalent — dispatch.mjs permission stage); every other gate (JSON parse /
387
+ // unknown tool / planMode / design-token) still applies (T-E14). Non-eng-coder
388
+ // children keep the manual per-write parent approval (human in the loop).
389
+ if (role === "eng-coder") child._engTaskAuthorized = true
210
390
  // designId+token ride the child bookkeeping: the delivery report carries the designId
211
391
  // so the divergence-audit fix round re-spawns with the SAME slot (2026-09-01 FR3).
212
392
  if (role === "eng-coder" && issuedToken) {
@@ -214,12 +394,61 @@ export const subagentTool = {
214
394
  child._engDesignToken = issuedToken
215
395
  }
216
396
 
217
- // explore/plan: inject git context (branch/recent commits/working tree state) — exploration and planning both relate to current repo state (inspired by kimi-code's promptPrefix)
397
+ // §18.5 子代理零 git(D-AG1——2026-09-04 用户裁定):explore/plan 一律不注入
398
+ // git 上下文——子代理证据链 = 任务书 ∪ 磁盘当前状态(read/glob/grep)∪(审计时)
399
+ // _touchedFiles,无一项来自 git;注入的全工作区脏状态快照与任务域无关,会误导
400
+ // 审计/探索("status 里这个文件算不算超清单?")。注入分支整体删除(B 方案
401
+ // git 只读变体亦随裁定废弃——D-AG5)。顶层主 agent 注入保留(§3 prepareRun——
402
+ // setup.mjs depth===0——D-AG7 范围边界)。
218
403
  let input = args.context ? `Context:\n${args.context}\n\nTask:\n${args.task}` : args.task
219
- if (role === "explore" || role === "plan") {
220
- const gitCtx = collectGitContext(parent.cwd)
221
- if (gitCtx) input = `<untrusted_git_context>\n${escapeXml(gitCtx)}\n</untrusted_git_context>\n\n${input}`
404
+ // §18 D-E2 ③ (round4 #4, T-E13/T-E15): an eng-coder audit spawn's task book is
405
+ // the eng-coder's OWN spawn task — mechanically kept as _engTaskInput by the
406
+ // parent spawn and injected as the D-TS5 A2 mechanical summary (design docs /
407
+ // affected-file list / acceptance criteria verbatim, verbose context dropped)
408
+ // — ∪ the mechanically tracked _touchedFiles — NEVER the eng-coder's
409
+ // self-written list: a self-report could omit exactly the out-of-scope file
410
+ // the audit must catch.
411
+ if (engAuditAttempt !== null) {
412
+ const touched = (ctx.agent._touchedFiles ?? []).map((f) => `- ${f}`).join("\n") || "- (none yet)"
413
+ input += `\n\n[Audit scope — mechanical context, independent of the eng-coder's self-report:]\n` +
414
+ // §18.7 D-TS4 A1:审计指令模板(四类偏差 + 范围限制 + 校验清单格式)——审计语义
415
+ // 不再靠模型自悟;范围限制是 §18.5 D-AG3 声明(下方 Zero-git scope authority)
416
+ // 的同源一句指注,不重复声明。
417
+ `[Audit instructions — mechanical template (AGENT-LOOP.md §18.7 D-TS4 A1):]\n` +
418
+ `You are auditing an eng-coder delivery against its approved design — audit for EXACTLY these four deviation categories:\n` +
419
+ `- PARTIAL: an acceptance criterion implemented partially or not at all;\n` +
420
+ `- SILENT-SIMPLIFICATION: a "simpler approximation" of a specified behavior substituted for the spec;\n` +
421
+ `- DOC-DRIFT: code changed without the owning design-doc section (module map / affected-files table) updated in the same delivery;\n` +
422
+ `- OUT-OF-LIST: changes outside the approved file list.\n` +
423
+ `Audit scope = _touchedFiles above UNION the files confirmed by the parent task book (single source — the Zero-git scope authority note below, AGENT-LOOP.md §18.5 D-AG3; NOT a second copy): ` +
424
+ `workspace changes not listed there are unrelated to this delivery and are NOT grounds for an out-of-list finding.\n` +
425
+ `Scope discipline (F-TS6 A1): read ONLY the audited files and the design-doc sections relevant to this delivery — do NOT re-read whole documents.\n` +
426
+ `Every deviation item MUST be fieldized: file:line + design reference (doc path + section/AC id) + severity + evidence (quoted code or doc text).\n` +
427
+ // §18.7 D-TS5 A2:任务书从全量 verbatim 改机械摘要块(三要素逐字——排除冗长上下文)。
428
+ `[Parent spawn task book — mechanical summary (AGENT-LOOP.md §18.7 D-TS5 A2): design docs + affected-file list + acceptance criteria verbatim; verbose context/background dropped — the design docs are still available for reading outside this input:]\n` +
429
+ `${summarizeEngTaskBook(ctx.agent._engTaskInput)}\n` +
430
+ `Files actually touched by the eng-coder (mechanical union — audit these against the file list):\n${touched}\n` +
431
+ // §18.5 D-AG3(2026-09-04):审计零 git 范围权威声明——本审计任务零 git(不注入
432
+ // git 上下文——§18.5 全角色零 git);_touchedFiles 为审计范围;工作区未列于
433
+ // _touchedFiles 的改动与本任务无关,不作超清单依据(VS Code auditTaskBook 同款措辞)。
434
+ "Zero-git scope authority (AGENT-LOOP.md §18.5 D-AG3): this audit task receives NO git context — nothing is injected. " +
435
+ "The evidence base is the design documents, the current disk state (read/glob/grep), and the _touchedFiles list above. " +
436
+ "Workspace changes NOT listed in _touchedFiles are unrelated to this delivery — they are NOT grounds for an out-of-file-list finding." +
437
+ // §18.13 D-A1.2:审计预算句——A1 指令模板 + A2 摘要块之后、A3 报告模板之前(定序——评审 #7)。
438
+ // 逐字设计锚(D-A1.2 代码块):只读该读的——10 轮机械预算——超时报 PROBLEM 下结论。
439
+ // 前导 \n 与 A3 同款块分隔约定(上一句 Zero-git 句末无换行——不触碰既有句)。
440
+ `\n[Audit budget — mechanical]: read ONLY the touched files listed above and the design-doc sections the parent task book names (affected-files table, acceptance criteria, status line). Do NOT read whole documents. Budget = 10 tool rounds max — if you cannot conclude within it, report PROBLEM (inconclusive) rather than continuing to explore.\n` +
441
+ // §18.7 D-TS6 A3:审计输出报告格式模板(三态——字段化行——不让模型自由发挥)。
442
+ `\n[Audit report format — mechanical template (AGENT-LOOP.md §18.7 D-TS6 A3):]\n` +
443
+ `Report EXACTLY one of three states:\n` +
444
+ `- CLEAN — no deviation across the four categories: reply the line "Four deviation categories: none found." (四类偏差均未发现);\n` +
445
+ `- DEVIATIONS — one row per deviation, every row fieldized: | category | file:line | design reference | severity | evidence |;\n` +
446
+ `- PROBLEM — the audit itself could not run / inconclusive: state what blocked it.\n`
222
447
  }
448
+ // The child's own task input rides the child object: an eng-coder's audit
449
+ // spawns reuse it as the task-book SOURCE — injected as the D-TS5 A2
450
+ // mechanical summary, not verbatim (see above).
451
+ if (role === "eng-coder") child._engTaskInput = input
223
452
 
224
453
  // Relay content/reasoning/tool/output to the parent TUI via the unified spawn-child
225
454
  // pipeline (AGENT-LOOP.md §7.2 D3). Prefix includes a unique id: parallel child agents
@@ -230,12 +459,15 @@ export const subagentTool = {
230
459
  // The [model] token (TUI block creation) is DEFERRED to actual start so queued
231
460
  // children don't paint an empty panel block ("queued 态不显示").
232
461
  let relayPrefix
233
- if (args.async === true) {
462
+ if (wantAsync) {
234
463
  parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
235
464
  relayPrefix = `${role}#${parent._subAgentCounter}/`
236
465
  } else {
237
466
  relayPrefix = makeRelay(parent, role ?? "sub", ctx.callbacks?.onToken, childProvider.model ?? "")
238
467
  }
468
+ // LOGGING(LOGGING.md):子代理内部事件(子内 llm:*/tool:*)以 childId 归属——
469
+ // agent._logId 随 runAgent 的 logCtx 透出(主文件单文件全记、按 childId grep)。
470
+ child._logId = relayPrefix.slice(0, -1)
239
471
  const childOpts = {
240
472
  onPermissionRequest: childPermission,
241
473
  ...wrapChildCallbacks(relayPrefix, ctx.callbacks),
@@ -259,10 +491,10 @@ export const subagentTool = {
259
491
  // The child runs the EXACT blocking pipeline (runChildPipeline below — relay /
260
492
  // turn-cap / permission / MIN_REPORT_CHARS / mergeChildMutations all unchanged),
261
493
  // but the parent does not await it: the promise is parked in _asyncSubagents and
262
- // consumed via subagent_check or the turn-end auto-wait. Slot queue: running
494
+ // consumed via action:'check' or the turn-end auto-wait. Slot queue: running
263
495
  // count < ASYNC_SUBAGENT_LIMIT → start now; ≥ limit → enqueue (status "queued",
264
496
  // position = queue index) — never rejected, never requiring the model to batch.
265
- if (args.async === true) {
497
+ if (wantAsync) {
266
498
  if ((ctx.depth ?? 0) > 0) {
267
499
  throw new Error("async spawn only available at the top level")
268
500
  }
@@ -272,181 +504,223 @@ export const subagentTool = {
272
504
  const id = parent._subAgentCounter
273
505
  const entry = {
274
506
  id, role, relayPrefix,
275
- status: running >= ASYNC_SUBAGENT_LIMIT ? "queued" : "running",
507
+ status: "queued", // 下面按等待态/槽位重定(避免两处判断漂移)
276
508
  position: undefined,
277
- report: null, error: null, done: false,
509
+ report: null, error: null, done: false, cancelled: false,
278
510
  promise: null, _settle: null, _settleSeq: 0,
511
+ // §19.5 D-M5 可决策字段(status 数据装配锚点):model 在 spawn 时记录;
512
+ // startedAt 在 ACTUAL start(queued 等待不计 elapsed);turn/maxTurns 由
513
+ // 下方 onToken 拦截层从子代理 ⟦ev⟧turn 事件镜像(T-M18 正确性断言)。
514
+ model: childProvider?.model ?? null,
515
+ startedAt: null,
516
+ turn: 0, maxTurns: 0,
517
+ // §19.5 D-M6 (round2 #2):条目级 AbortController——cancel 定向 abort 本
518
+ // 条目(runAgent signal 链);Ctrl+C 全停语义不变(基信号 abort 逐链传播)。
519
+ controller: null,
520
+ // §20 D-SD2 域元数据(AGENT-LOOP.md §20):running ∪ queued 条目全带——
521
+ // _files(归一化绝对路径)/ _dependsOn(字符串 id)——冲突检测与补位判据
522
+ // 的事实源;无调度参数 spawn 两字段皆空(legacy——不参与冲突检测——零改动)。
523
+ _files: files,
524
+ _dependsOn: dependsOn,
525
+ _lastQueuedSig: null, // ⟦ev⟧queued 去重 sig(refreshQueuedTokens)
526
+ }
527
+ // §20 D-SD3 准入落点:等待态(依赖未满足/域冲突/depc)→ queued(waiting-deps——
528
+ // 不占槽不启动——即使槽空);纯槽满(kind slot)→ queued(等位);否则立即启动。
529
+ // 派生实时计算(describeBlockers——池状态在 spawn 同步段内不变——与前面准入一致)。
530
+ const blockers = describeBlockers(parent, entry)
531
+ if (blockers.kind === "slot") {
532
+ entry.status = running >= ASYNC_SUBAGENT_LIMIT ? "queued" : "running"
533
+ } else {
534
+ entry.status = "queued" // waiting-deps / dependency-cancelled——slot 空也不启动
279
535
  }
280
536
  // The settle signal — resolves when the run chain settles (never rejects).
281
537
  entry.promise = new Promise((res) => { entry._settle = res })
538
+ // §19.5 D-M6:条目 controller 链到会话/回合基信号(_sessionSignal 优先——
539
+ // §17 挂起会话内 children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
540
+ const ctrl = new AbortController()
541
+ entry.controller = ctrl
542
+ const baseSignal = parent._sessionSignal ?? ctx.signal ?? null
543
+ if (baseSignal) {
544
+ if (baseSignal.aborted) ctrl.abort()
545
+ else baseSignal.addEventListener("abort", () => ctrl.abort(), { once: true })
546
+ }
547
+ // §19.5 D-M5:turn 镜像拦截层(callbacks 包装层——选改动最小方案:在既有
548
+ // wrapChildCallbacks 之外再包一层,只解析 ⟦ev⟧turn 更新条目,其余原样转发)。
549
+ const trackOpts = { ...childOpts }
550
+ const parentOnToken = trackOpts.onToken
551
+ if (parentOnToken) {
552
+ trackOpts.onToken = (t) => {
553
+ const ev = String(t).match(/^⟦ev⟧turn\x1e(\d+)\x1e(\d+)\x1e/)
554
+ if (ev) {
555
+ entry.turn = Number(ev[1]) || 0
556
+ entry.maxTurns = Number(ev[2]) || 0
557
+ }
558
+ return parentOnToken(t)
559
+ }
560
+ }
282
561
  entry.start = () => {
283
562
  entry.status = "running"
284
563
  entry.position = undefined
564
+ entry.startedAt = Date.now()
565
+ // §19.5.6 D-SF1(round3 #1):绑定子代理对象引用——绑定时刻 = 实际启动时
566
+ // (queued 条目 spawn-ack 时刻尚无子代理对象——§20 D-SD3b);绑定对象 = 子代理
567
+ // 对象(不是 _touchedFiles 数组引用——per-run 记账在 prepareRun 重置——数组
568
+ // 引用会陈旧——对象引用保证 status 查询时实时读——杀前一刻最新)。
569
+ entry.childAgent = child
570
+ // §19.5 D-M7b ①: async 标记事件——零字段 ⟦ev⟧async token(sync 不发)。
571
+ // 锚点 = 实际启动(与 [model] 同步——queued 入队不 paint,补位启动才发);
572
+ // 先于 [model] 发出——区块创建即知 sub.async(routeSubToken 解析——
573
+ // ⏹ 门控与头标 async 的判定源)。父级直接 emit(depth-0 专属路径——
574
+ // 不经子代理文本 strip 白名单——与 ⟦ev⟧stopped/settled 同族)。
575
+ ctx.callbacks?.onToken?.(relayPrefix + "⟦ev⟧async\x1e")
285
576
  // Deferred [model] emit: the TUI block is created at ACTUAL start.
286
577
  ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
287
578
  // Turn-cap on background children NEVER pops the continue panel (D-A3):
288
- // auto-decline, the partial-work report carries the cap reason.
289
- runChildPipeline(child, input, childOpts, childRunOpts, {
579
+ // §15 D-A3 exception (2026-09-02 unified rule, AGENT-LOOP.md §2): in an
580
+ // engineering && AUTO session the child auto-resumes the user authorized
581
+ // unattended runs, no one is at the panel. Every other tier auto-declines
582
+ // and the partial-work report carries the cap reason. §18 D-E2 relies on
583
+ // this exception as the turn-cap fallback for the default-async eng-coder
584
+ // delivery (the internal protocol does not raise the 100-turn cap).
585
+ runChildPipeline(child, input, trackOpts, { ...childRunOpts, signal: entry.controller.signal }, {
290
586
  parent, role, args,
291
- askContinue: () => Promise.resolve(false),
587
+ askContinue: () => Promise.resolve(Boolean(parent.config?.agent?.engineering && parent.autoApprove)),
292
588
  })
293
589
  .then((report) => { entry.report = report })
294
590
  .catch((err) => { entry.error = err?.message ?? String(err) })
295
591
  .finally(() => {
296
592
  entry.status = "done" // running 数口径(D-A1/D-A2/T6):已完成未消费不计入
297
593
  entry.done = true
298
- // D-A3 发射时机(2026-09-02 用户实证修正):settle 同刻发射 ⟦ev⟧done——
299
- // TUI routeSubToken 立即冻结区块,冻结位置 = 完成时刻的会话流位置
300
- // (回合收尾统一发会把块堆在结论之后)。父会话已 abort 不发:TUI 已按
301
- // interrupted 冻结,晚到 token 经 tombstone 丢弃——显式守卫更干净。
302
- if (!ctx.signal?.aborted) {
303
- ctx.callbacks?.onToken?.(`${entry.relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
594
+ // LOGGING(LOGGING.md):settle 分流事件——child:done/child:error(结果)+
595
+ // ev:cancelled/ev:settled(settle 回调分流——取消/挂起移交;正常回合内 settle
596
+ // child:done 覆盖不另发 ev——ev:stopped 见中止清池点)
597
+ const childLogId = `${entry.role}#${entry.id}`
598
+ const childMs = entry.startedAt ? Date.now() - entry.startedAt : 0
599
+ // 中止守卫(2026-09-03 code review #5):Ctrl+C/会话中止时子代理以 error 形态
600
+ // settle——不落 child:error/done/ev:settled(ev:stopped 已在中止清池点表达;
601
+ // 同文件阻塞路径同款抑制——"用户停——不落错误事件")。定向 cancel 走 ev:cancelled。
602
+ const parentAborted = ctx.signal?.aborted || entry.controller?.signal?.aborted
603
+ if (entry.cancelled) {
604
+ logEvent("ev:cancelled", { id: childLogId })
605
+ } else if (!parentAborted) {
606
+ if (entry.error != null) logEvent("child:error", { role: entry.role, id: childLogId, ms: childMs, err: errText(entry.error, 200) })
607
+ else logEvent("child:done", { role: entry.role, id: childLogId, ms: childMs, kind: String(entry.report ?? "").includes(TURN_CAP_MARK) ? "partial" : "ok" })
608
+ if (parent._suspended) logEvent("ev:settled", { id: childLogId, kind: "suspended" })
609
+ }
610
+ // §19.5 cancelled settle 分支(D-M6 round1 #1 + round2 #3):cancel 定向
611
+ // 中止的条目——不入 _pendingAsyncResults、不参与 collectSettledAsync 直注入
612
+ // (清池规则同 Ctrl+C 全停但只清该条目——陈旧错误零注入);发 ⟦ev⟧stopped
613
+ // 冻结事件(TUI 区块 interrupted 语义冻结——标题 "stopped");取消事实与半成品
614
+ // 警示对模型可见(user-role 提醒——形态仿 injectAsyncResult、XML 转义——防基于
615
+ // 半成品树继续:mergeChildMutations 不覆盖 abort 路径)。
616
+ // §20 D-SD5:running 依赖取消的 settle 终态点——写终态墓碑(running 取消无
617
+ // 出队事件——出池在 settle);queued 依赖者随之标注 dependency cancelled
618
+ // (refreshQueuedTokens——settle 后统一段);提醒文本列出依赖者(供模型决策)。
619
+ if (entry.cancelled) {
620
+ parent._asyncSubagents?.delete(String(entry.id))
621
+ const tombstones = (parent._asyncTombstones ??= new Map())
622
+ tombstones.set(String(entry.id), { status: "cancelled", role: entry.role })
623
+ ctx.callbacks?.onToken?.(`${entry.relayPrefix}⟦ev⟧stopped\x1e0\x1e0\x1estopped\x1e`)
624
+ const dependents = dependentLabels(parent, String(entry.id))
625
+ const autoNote = parent.autoApprove
626
+ ? " — AUTO session: they auto-start on slot availability (round2 #3)"
627
+ : " — they stay queued until you cancel them or an AUTO session starts them"
628
+ pushReal(parent, {
629
+ role: "user",
630
+ content: `[System reminder: subagent ${escapeXml(entry.role)}#${entry.id} cancelled by user — partial changes not merged/audited${dependents.length > 0 ? `; queued dependents ${dependents.join(", ")} marked "dependency cancelled"${autoNote}` : ""}]`,
631
+ })
632
+ } else if (!ctx.signal?.aborted) {
633
+ // 完成信号按会话态分流(§17 D-S8 冻结门控 + D-S3 记账——以读取时刻为准,确定性):
634
+ // - 非挂起态(普通回合内 settle):照发 ⟦ev⟧done —— TUI 立即冻结区块,冻结位置 =
635
+ // 完成时刻的流位置(§15 D-A3 2026-09-02 用户实证修正:收尾统一发会把块堆在结论之后)。
636
+ // §17.5 supersede:回合尾 collectSettledAsync(suspDriven)不再直注入——条目留池
637
+ // 由挂起会话首轮 sweep → digest 消化(17.5.2 方案 B——块已冻结不受影响)。
638
+ // - 挂起态(_suspended:回合已结束或 auto-turn 消化中):冻结延迟——改发 ⟦ev⟧settled
639
+ // (区块显示 "done · awaiting digestion" 驻留面板),条目移交 _pendingAsyncResults
640
+ // 由下个回合 prepareRun 前注入(D-S3 ②;注入即从池/pending 移除,无重复);
641
+ // §17.5.5:注入完成后 freezeReclaimDigestedBlocks 逐条回收冻结(不等池空)。
642
+ // 父会话 abort 两种都不发:TUI 已按 interrupted 冻结,晚到 token 经 tombstone 丢弃。
643
+ if (parent._suspended) {
644
+ parent._pendingAsyncResults ??= []
645
+ parent._pendingAsyncResults.push(entry)
646
+ parent._asyncSubagents?.delete(String(entry.id))
647
+ ctx.callbacks?.onToken?.(`${entry.relayPrefix}⟦ev⟧settled\x1e0\x1e0\x1esettled\x1e`)
648
+ } else {
649
+ ctx.callbacks?.onToken?.(`${entry.relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
650
+ }
304
651
  }
305
652
  entry._settleSeq = (parent._asyncSettleSeq = (parent._asyncSettleSeq ?? 0) + 1)
306
653
  entry._settle()
307
654
  for (const w of parent._asyncWaiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
655
+ // §20 D-SD4 释放点:settle 腾槽 + 依赖终态转移 → 补位(依赖满足者/域冲突
656
+ // 解除者自动启动——槽 ≤4)→ 排队态面板刷新(等待块头标注随终态更新——
657
+ // dependency cancelled / 位置前移)。
308
658
  maybeRefillAsync(parent)
659
+ refreshQueuedTokens(parent, ctx.callbacks?.onToken)
309
660
  })
310
661
  }
311
662
  parent._asyncSubagents.set(String(id), entry)
663
+ // LOGGING(LOGGING.md):child:spawn(async——注册即事件;status 记 queued/running 分流;
664
+ // 实际启动由补位 start() 触发——运行中由子内 llm/tool 事件可见)
665
+ logEvent("child:spawn", { role, id: `${role}#${id}`, kind: "async", status: entry.status, ms: 0 })
312
666
  if (entry.status === "queued") {
313
667
  parent._asyncQueue.push(entry)
314
668
  entry.position = parent._asyncQueue.length
315
- return JSON.stringify({ id: String(id), role, status: "queued", position: entry.position })
669
+ // §20 D-SD3b:排队 spawn 返回即建面板 waiting 块(⟦ev⟧queued 事件——spawn
670
+ // 发——TUI routeSubToken 消费建块/更新头;启动后 ⟦ev⟧async 转 running——同 key
671
+ // 不重建)。refreshQueuedTokens 同时校正既有排队条目的位置/等待态头。
672
+ refreshQueuedTokens(parent, ctx.callbacks?.onToken)
673
+ const blk = describeBlockers(parent, entry)
674
+ const out = { id: String(id), role, status: "queued", position: entry.position }
675
+ if (blk.kind !== "slot") {
676
+ out.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
677
+ out.reason = blk.detail
678
+ }
679
+ return JSON.stringify(out)
316
680
  }
317
681
  entry.start()
318
682
  return JSON.stringify({ id: String(id), role, status: "running" })
319
683
  }
320
684
 
321
685
  // ── Blocking path (unchanged semantics): await the full pipeline ──
322
- return await runChildPipeline(child, input, childOpts, childRunOpts, {
323
- parent, role, args,
324
- askContinue: askSubagentContinue,
325
- })
686
+ // LOGGING(LOGGING.md):child:*(阻塞 spawn——runChildPipeline 前后;declined
687
+ // partial 由 TURN_CAP_MARK 检出;错误原样上抛(dispatch 转 tool:error))
688
+ const blockT0 = Date.now()
689
+ logEvent("child:spawn", { role, id: child._logId, kind: "blocking" })
690
+ try {
691
+ const pipelineReport = await runChildPipeline(child, input, childOpts, childRunOpts, {
692
+ parent, role, args,
693
+ askContinue: askSubagentContinue,
694
+ })
695
+ logEvent("child:done", { role, id: child._logId, ms: Date.now() - blockT0, kind: String(pipelineReport).includes(TURN_CAP_MARK) ? "partial" : "ok" })
696
+ // §7.2.3 sync spawn 完成精确冻结(方案 e):execute 返回前 ctx 留子代理 key
697
+ // (relayPrefix 去尾 = `role#N`)——dispatch runOne 读它作 onToolResult 第 4 参 →
698
+ // TUI finishSubTaskKey 按 key 精确冻(async eng-coder 先启动时不再误冻其块——
699
+ // T-F2)。仅成功路径设置:async 分支不设(round2 #2——ack 带 status:running 由
700
+ // isAsyncSpawnResult 跳过冻结);错误/拒绝路径到此之前已 throw/return——ctx 未设
701
+ // ——错误路径不触发冻结(round1 #1——T-F5)。
702
+ ctx._subagentKey = relayPrefix.slice(0, -1)
703
+ return pipelineReport
704
+ } catch (e) {
705
+ if (ctx.signal?.aborted || e?.name === "AbortError") throw e // 用户停——不落错误事件
706
+ logEvent("child:error", { role, id: child._logId, ms: Date.now() - blockT0, err: errText(e, 200) })
707
+ throw e
708
+ }
326
709
  },
327
710
  }
328
711
 
329
- /**
330
- * Shared post-spawn pipeline (blocking AND async AGENT-LOOP.md §15 D-A1: the
331
- * async branch reuses the exact same spawn-child pipeline, "全不变"):
332
- * turn-cap continue loop declined partial-work return → MIN_REPORT_CHARS
333
- * expansion → eng-coder mutation merge designId suffix. Returns the report.
334
- * onDeclined lives here (identical for both paths) — only askContinue differs:
335
- * blocking asks the user via the permission panel, async auto-declines.
336
- */
337
- async function runChildPipeline(child, input, childOpts, childRunOpts, { parent, role, args, askContinue }) {
338
- const declined = { partial: null }
339
- let report = await runWithContinue(
340
- (child, input, cbs, opts) => runAgent(child, input, cbs, opts), // opts = childRunOpts + resume (managed by the pipeline)
341
- child, input, childOpts, childRunOpts,
342
- {
343
- askContinue,
344
- onDeclined: (e, output) => {
345
- if (role === "eng-coder" && child._mutatedThisRun) mergeChildMutations(parent, child)
346
- // Early return semantics (unchanged from the inline loop): the declined
347
- // partial-work message is returned WITHOUT the MIN_REPORT_CHARS expansion —
348
- // re-prompting a capped child for a longer report is wrong.
349
- // Review #2 fix: use the pipeline-captured output (the `report` variable is
350
- // still "" at this point — runWithContinue hasn't returned yet).
351
- declined.partial = `Subagent (${role}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output || ""}`
352
- },
353
- },
354
- )
355
- if (declined.partial !== null) {
356
- // declined eng-coder delivery still carries its designId — the fix round
357
- // re-spawns with the same slot (2026-09-01).
358
- if (role === "eng-coder") declined.partial += `\ndesignId: ${args.designId ?? "(single-design session — designId optional)"} — reuse it (with the same designToken) when re-spawning this eng-coder.`
359
- return declined.partial
360
- }
361
-
362
- // Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
363
- // The child agent's history is still intact; the continuation instruction is appended as new input so it can see its own earlier work.
364
- if (report.length < MIN_REPORT_CHARS) {
365
- report = await runAgent(child, REPORT_CONTINUATION, childOpts, childRunOpts)
366
- }
367
-
368
- // Engineering mode mechanical code gate: delegated file changes must not
369
- // bypass the parent's advisor/verify guards. Merge the child's mutations
370
- // into the parent so "advisor mandatory at both gates" is enforced, not just
371
- // promised in the engineering prompt.
372
- // CRITICAL: Only merge if child actually mutated files (defense-in-depth against
373
- // runAgent throwing before any writes occurred).
374
- // Review #8 clarification: eng-coder ONLY is intentional — the mechanical
375
- // two-gate merge exists for engineering mode; plain `coder` children carry
376
- // their own verify/advisor self-review discipline (per tool description), and
377
- // normal mode has no parent advisor/verify gate to feed.
378
- if (role === "eng-coder" && child._mutatedThisRun) {
379
- mergeChildMutations(parent, child)
380
- }
381
-
382
- // designId rides the delivery report (2026-09-01): the divergence-audit fix round
383
- // re-spawns with the SAME designId+token — the parent copies it from here, and the
384
- // prompt tells the model exactly where the matching token came from.
385
- if (role === "eng-coder") {
386
- report += `\ndesignId: ${args.designId ?? "(single-design session — designId optional)"} — reuse this designId with the same designToken (from the approved advisor type='design' review) when re-spawning this eng-coder for an audit fix round.`
387
- }
388
-
389
- return report
390
- }
391
-
392
- /** Slot-queue refill (AGENT-LOOP.md §15 D-A1/D-A6): start queue heads while a
393
- * running slot is free — called from every settle (completion frees a slot) and
394
- * from the turn-end collection's refill loop. Serial by construction: one slot
395
- * frees per settle, one head starts per call. */
396
- export function maybeRefillAsync(parent) {
397
- const queue = parent._asyncQueue ?? []
398
- while (queue.length > 0) {
399
- const running = [...(parent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length
400
- if (running >= ASYNC_SUBAGENT_LIMIT) return
401
- queue.shift().start()
402
- }
403
- }
404
-
405
- /**
406
- * Child agent run options — the parent's abort signal MUST propagate to the
407
- * child: without it, Ctrl+C aborts the parent's controller but the child keeps
408
- * running its full turn budget (up to subagentTurns) while the parent awaits —
409
- * the interrupt appears to do nothing.
410
- */
411
- export function buildChildRunOpts(ctx) {
412
- return {
413
- depth: (ctx.depth ?? 0) + 1,
414
- maxTurns: ctx.agent?.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS,
415
- signal: ctx.signal ?? null,
416
- }
417
- }
418
-
419
- /**
420
- * Merge an eng-coder child's mutations into the parent agent's bookkeeping.
421
- * The parent must stay aware of delegated file changes: `_touchedFiles` enables
422
- * the advisor guard (completion.mjs) to detect that code was modified and
423
- * pushback for review. Prior verify/advisor state is invalidated because it
424
- * judged an older state.
425
- *
426
- * `_advisorRound` is NOT reset: merged code enters the CURRENT convergence
427
- * cycle. Resetting here would break the review→fix→re-review loop (the parent
428
- * reviews, spawns an eng-coder to fix, merges, reviews again — every merge
429
- * would restart at round 1 and the 5-round cap could never be reached).
430
- * `_calledAdvisorThisRun` IS cleared so the merged code triggers a fresh
431
- * advisor call (the guard demands review of new mutations).
432
- *
433
- * Returns true when mutations were merged (kept for future caller checks).
434
- */
435
- export function mergeChildMutations(parent, child) {
436
- // A child claiming mutations without any touched file is a misbehaving
437
- // child (or a bookkeeping bug) — do not propagate an empty mutation claim
438
- // to the parent's guard state.
439
- if (!child._mutatedThisRun || !(child._touchedFiles?.length)) return false
440
- parent._mutatedThisRun = true
441
- for (const abs of child._touchedFiles ?? []) {
442
- if (!parent._touchedFiles.includes(abs)) parent._touchedFiles.push(abs)
443
- }
444
- if (parent._calledAdvisorThisRun) parent._calledAdvisorThisRun = false
445
- if (parent._verifiedThisRun) {
446
- parent._verifiedThisRun = false
447
- parent._verifyPassed = undefined
448
- }
449
- // Stale session cleanup only — the round counter survives (see above).
450
- parent._advisorSession = null
451
- return true
452
- }
712
+ // Re-export shim (2026-09-03 拆分轮 + §19 合体轮): 机械与合体动作执行器迁至
713
+ // ./subagent-async.mjs——保留本文件导出面,消费点(agent.mjs / agent-turn.mjs /
714
+ // consult.mjs / 测试)导入路径零改动。execute 仍直接使用 ASYNC_SUBAGENT_LIMIT /
715
+ // maybeRefillAsync / buildChildRunOpts / runChildPipeline(文件头部 import)。
716
+ // 2026-09-05 拆分轮: maybeRefillAsync §20 调度器独立(./subagent-scheduler.mjs)——
717
+ // 再导出源改写,消费面(agent.mjs 动态 import 等)不变。
718
+ export {
719
+ ASYNC_SUBAGENT_LIMIT,
720
+ MAX_ASYNC_CHECKS,
721
+ resolveChildProvider,
722
+ injectAsyncResult,
723
+ buildChildRunOpts,
724
+ mergeChildMutations,
725
+ } from "./subagent-async.mjs"
726
+ export { maybeRefillAsync } from "./subagent-scheduler.mjs"