thincoder 0.12.50 → 0.12.52

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 (49) hide show
  1. package/CHANGELOG.md +64 -3
  2. package/README.md +2 -2
  3. package/package.json +4 -3
  4. package/src/acp/bridge.mjs +5 -0
  5. package/src/agent/dispatch.mjs +19 -7
  6. package/src/agent/helpers.mjs +13 -1
  7. package/src/agent/record-results.mjs +130 -0
  8. package/src/agent/setup.mjs +4 -7
  9. package/src/agent/spawn-child.mjs +159 -0
  10. package/src/agent-tools/consult.mjs +94 -73
  11. package/src/agent-tools/escalate.mjs +53 -62
  12. package/src/agent-tools/skill.mjs +1 -1
  13. package/src/agent-tools/subagent.mjs +39 -38
  14. package/src/agent-tools/task.mjs +0 -2
  15. package/src/agent-tools/verify.mjs +0 -1
  16. package/src/agent.mjs +27 -112
  17. package/src/config.mjs +8 -103
  18. package/src/generate-title.mjs +30 -1
  19. package/src/model-specs.mjs +108 -0
  20. package/src/prompts/advisor-round1.md +5 -6
  21. package/src/prompts/advisor-round2.md +3 -4
  22. package/src/prompts/advisor-round3.md +3 -4
  23. package/src/prompts/eng-coder.md +9 -0
  24. package/src/prompts/engineering.md +61 -9
  25. package/src/prompts/system.md +2 -2
  26. package/src/provider/core.mjs +5 -71
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/session.mjs +40 -1
  29. package/src/tools/git.mjs +3 -3
  30. package/src/tools/shared.mjs +1 -0
  31. package/src/tools/system.mjs +3 -1
  32. package/src/tui/agent-turn.mjs +37 -364
  33. package/src/tui/clipboard.mjs +3 -1
  34. package/src/tui/dims.mjs +47 -0
  35. package/src/tui/fold-block.mjs +208 -0
  36. package/src/tui/index.mjs +33 -17
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +10 -6
  39. package/src/tui/layout.mjs +21 -20
  40. package/src/tui/mouse.mjs +9 -6
  41. package/src/tui/pickers.mjs +1 -1
  42. package/src/tui/render-conversation.mjs +367 -113
  43. package/src/tui/render-frame.mjs +16 -90
  44. package/src/tui/render-loop.mjs +12 -8
  45. package/src/tui/render.mjs +16 -0
  46. package/src/tui/startup.mjs +66 -13
  47. package/src/tui/subagent-blocks.mjs +327 -0
  48. package/src/tui/tool-args.mjs +67 -0
  49. package/src/tui/tool-events.mjs +459 -0
@@ -11,9 +11,14 @@
11
11
  * CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
12
12
  * prefix `consult#<id>/` (same channel subagent uses), not onSubagent/onToolPanel.
13
13
  */
14
- import { createAgent, runAgent, readonlyToolNames, ContinueError } from "../agent.mjs"
14
+ import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
15
15
  import { resolveChildProvider } from "./subagent.mjs"
16
- import { specForModel } from "../config.mjs"
16
+ import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
17
+
18
+ // Named consult defaults (consult P2, 2026-08-30).
19
+ const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
20
+ const CONSULT_TURNS = 40 // default per-child turn cap
21
+
17
22
 
18
23
  function consultLabel(m) {
19
24
  return `${m.provider}:${m.model}`
@@ -21,7 +26,9 @@ function consultLabel(m) {
21
26
 
22
27
  /** Narrow the configured consultModels pool to a requested subset.
23
28
  * Each selector is "provider:model", a bare provider name, or a bare model name
24
- * (case-insensitive). Returns { models, error } error set when a selector matches
29
+ * (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
30
+ * 对齐 escalate.mjs:withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
+ * Returns { models, error } — error set when a selector matches
25
32
  * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
26
33
  * → the full pool. */
27
34
  function selectConsultModels(pool, selectors) {
@@ -31,7 +38,7 @@ function selectConsultModels(pool, selectors) {
31
38
  const seen = new Set()
32
39
  const unknowns = []
33
40
  for (const raw of list) {
34
- const s = String(raw).trim().toLowerCase()
41
+ const s = String(raw).replace(/\s+\([^)]*\)\s*$/, "").trim().toLowerCase()
35
42
  const matches = pool.filter((m) =>
36
43
  consultLabel(m).toLowerCase() === s ||
37
44
  String(m.provider ?? "").toLowerCase() === s ||
@@ -85,11 +92,18 @@ export function makeMainHistoryTool(parentAgent) {
85
92
  : ""
86
93
  return `--- [${m.role}] ---\n${content}${calls ? "\n" + calls : ""}`
87
94
  }
88
- const BUDGET = 60_000
95
+ const BUDGET = 60_000 // per-consult token budget (tokens)
89
96
  let out = ""
90
97
  for (let i = slice.length - 1; i >= 0; i--) {
91
98
  const line = render(slice[i])
92
- if (out.length + line.length > BUDGET) { out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out; break }
99
+ if (out.length + line.length > BUDGET) {
100
+ // A single message over the whole budget: truncate IT (it is the newest
101
+ // and most relevant) instead of dropping everything with a misleading
102
+ // "earlier messages trimmed" note. Older accumulation still trims.
103
+ if (out === "") { out = line.slice(0, BUDGET) + "\n(… truncated — single message exceeded budget " + BUDGET + " chars)"; break }
104
+ out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out
105
+ break
106
+ }
93
107
  out = out ? line + "\n\n" + out : line
94
108
  }
95
109
  return out
@@ -119,7 +133,7 @@ function settleChild(session, id, label, ok, payload) {
119
133
 
120
134
  async function runConsultChild(ctx, session, id, m, problem, ctrl) {
121
135
  const agent = ctx.agent
122
- const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? 600_000
136
+ const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? CONSULT_TIMEOUT_MS
123
137
  let timedOut = false
124
138
  const armWatchdog = () => {
125
139
  const t = setTimeout(() => {
@@ -135,7 +149,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
135
149
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
136
150
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
137
151
  const provider = resolveChildProvider(agent, `${m.provider}:${m.model}`)
138
- if (!provider?.apiKey?.trim()) {
152
+ if (!ensureChildApiKey(provider)) {
139
153
  // resolveChildProvider may still lack a key; fail loudly like the plugin precheck
140
154
  // (settleChild turns this message into a clear failed reply instead of a raw 401)
141
155
  throw new Error(`consult model ${label} has no API key — check providers[${m.provider}].apiKey in config.json`)
@@ -145,14 +159,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
145
159
  // Symmetric with escalate.mjs; 2026-08-16 a real consult died on qwen3.8-max
146
160
  // effort "high" (enum is xhigh/medium/low). Out-of-enum: DROP the effort entirely
147
161
  // (the provider preset default may ALSO be out-of-enum for this override model).
148
- if (m.effort) {
149
- const enumList = specForModel(m.model).reasoningEffortEnum
150
- if (enumList && !enumList.includes(m.effort)) {
151
- delete provider.reasoningEffort
152
- } else {
153
- provider.reasoningEffort = m.effort
154
- }
155
- }
162
+ clampEffort(provider, m.model, m.effort)
156
163
 
157
164
  // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
158
165
  const allowed = readonlyToolNames(agent.tools ?? [])
@@ -172,57 +179,57 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
172
179
  role: "consult",
173
180
  })
174
181
 
175
- // Activity relay: `consult#<subId>/` prefix the parent TUI's subTasks panel
176
- // (same channel subagent uses — parallel consultants stay independent).
177
- agent._subAgentCounter = (agent._subAgentCounter ?? 0) + 1
178
- const subId = agent._subAgentCounter
179
- const relayPrefix = `consult#${subId}/`
180
- // Report this consultant's model to the display layer (each consultant may use a different model).
181
- ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? ""))
182
- const childCallbacks = {
183
- onToken: ctx.callbacks?.onToken ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`) : null,
184
- onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
185
- onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
186
- }
182
+ // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
183
+ // prefix (same channel subagent uses — parallel consultants stay independent) +
184
+ // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
185
+ const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
186
+ const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
187
+ let declined = false // review #1: guard against double-settle when onDeclined fired
187
188
 
188
- // Turn-cap continue loop (TURN-CAP-CONTINUE.md): hitting the cap asks the user via
189
- // the SAME y/n panel the main agent uses (ctx.onPermissionRequest "continue") —
190
- // unlimited continues, each with a fresh turn budget AND a re-armed wall-clock
191
- // watchdog (a continue is a fresh budget, the clock restarts too). Parallel
192
- // consultants serialize their prompts through a session-level queue. Declined /
193
- // headless → failed reply (partial diagnosis).
189
+ // Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3): hitting
190
+ // the cap asks the user via the SAME y/n panel the main agent uses unlimited
191
+ // continues, each with a fresh turn budget AND a re-armed wall-clock watchdog (a
192
+ // continue is a fresh budget, the clock restarts too). Parallel consultants serialize
193
+ // their prompts through a session-level queue. Declined / headless → failed reply
194
+ // (partial diagnosis).
194
195
  const runner = ctx.runAgent ?? runAgent
195
- for (let resume = false; ; resume = true) {
196
- try {
197
- const result = await runner(child, "# Problem\n" + problem, childCallbacks, {
198
- depth: 1,
199
- maxTurns: agent?.config?.agent?.consultTurns ?? 40,
200
- signal: ctrl.signal,
201
- resume,
202
- })
203
- settleChild(session, id, label, true, String(result ?? ""))
204
- return
205
- } catch (e) {
206
- if (e instanceof ContinueError) {
207
- let go = false
208
- if (ctx.onPermissionRequest) {
196
+ try {
197
+ const result = await runWithContinue(
198
+ (childAgent, input, cbs, opts) => runner(childAgent, input, cbs, opts),
199
+ child, "# Problem\n" + problem,
200
+ childCallbacks,
201
+ { depth: 1, maxTurns: agent?.config?.agent?.consultTurns ?? CONSULT_TURNS, signal: ctrl.signal },
202
+ {
203
+ askContinue: (e) => {
204
+ if (!ctx.onPermissionRequest) return Promise.resolve(false)
209
205
  const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: label })
210
206
  session.continueQueue = (session.continueQueue ?? Promise.resolve()).then(ask, ask)
211
- go = await session.continueQueue
212
- }
213
- if (go) {
214
- clearTimeout(watchdog)
215
- timedOut = false // fresh budget → fresh clock
216
- watchdog = armWatchdog()
217
- continue
218
- }
219
- settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
220
- return
221
- }
222
- const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
223
- settleChild(session, id, label, false, note)
224
- return
225
- }
207
+ return session.continueQueue.then((go) => {
208
+ if (go) {
209
+ clearTimeout(watchdog)
210
+ timedOut = false // fresh budget → fresh clock
211
+ watchdog = armWatchdog()
212
+ }
213
+ return go
214
+ })
215
+ },
216
+ onDeclined: (e) => {
217
+ declined = true
218
+ settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) stopped, diagnosis may be partial`)
219
+ return undefined
220
+ },
221
+ },
222
+ )
223
+ // Review #1 fix: onDeclined already settled this child as a failed reply —
224
+ // settling again here would push a phantom empty success reply and decrement
225
+ // `pending` twice (negative pending → consult_check's two exits both
226
+ // unreachable → permanent block until user abort).
227
+ if (!declined) settleChild(session, id, label, true, String(result ?? ""))
228
+ } catch (e) {
229
+ // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
230
+ // continue/declined paths are already handled inside runWithContinue.
231
+ const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
232
+ settleChild(session, id, label, false, note)
226
233
  }
227
234
  } catch (e) {
228
235
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
@@ -243,6 +250,11 @@ export function cleanupConsultSessions(agent) {
243
250
  for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
244
251
  }
245
252
  agent._consultSessions?.clear()
253
+ // NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
254
+ // only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
255
+ // blocks on tool calls) — cleanup runs from the turn finally, where the block
256
+ // freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
257
+ // dead weight. Blocks still get frozen on interrupt via that sweep.
246
258
  }
247
259
 
248
260
  export const consultStartTool = {
@@ -313,14 +325,19 @@ export const consultCheckTool = {
313
325
  "have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
314
326
  "replies are coming.\n" +
315
327
  "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
328
+ "Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
316
329
  "Parameters:\n" +
317
- "- id (required): the consult id from consult_start",
330
+ "- id (required): the consult id from consult_start\n" +
331
+ "- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
318
332
  parameters: {
319
333
  type: "object",
320
- properties: { id: { type: "string", description: "Consult id" } },
321
- required: ["id"],
334
+ properties: {
335
+ id: { type: "string", description: "Consult id" },
336
+ n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
337
+ },
338
+ required: ["id", "n"],
322
339
  },
323
- async execute({ id }, ctx) {
340
+ async execute({ id, n: _n }, ctx) {
324
341
  const s = ctx.agent?._consultSessions?.get(String(id))
325
342
  if (!s) return JSON.stringify({ error: "unknown consult id" })
326
343
  const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
@@ -367,18 +384,22 @@ export const consultStopTool = {
367
384
  "Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
368
385
  "Already-answered replies stay available for consult_check.\n" +
369
386
  "Parameters:\n" +
370
- "- id (required): the consult id from consult_start",
387
+ "- id (required): the consult id from consult_start\n" +
388
+ "- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
371
389
  parameters: {
372
390
  type: "object",
373
- properties: { id: { type: "string", description: "Consult id" } },
374
- required: ["id"],
391
+ properties: {
392
+ id: { type: "string", description: "Consult id" },
393
+ n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
394
+ },
395
+ required: ["id", "n"],
375
396
  },
376
- async execute({ id }, ctx) {
397
+ async execute({ id, n }, ctx) {
377
398
  const s = ctx.agent?._consultSessions?.get(String(id))
378
399
  if (!s) return JSON.stringify({ error: "unknown consult id" })
379
- const n = s.pending
400
+ const abandoned = s.pending
380
401
  s.stopped = true
381
402
  for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
382
- return JSON.stringify({ stopped: n })
403
+ return JSON.stringify({ stopped: n, abandoned })
383
404
  },
384
405
  }
@@ -16,9 +16,9 @@
16
16
  * CLI mergeChildMutations(parent, child) (agent object, not a state sink).
17
17
  */
18
18
  import { isAbsolute, relative } from "node:path"
19
- import { createAgent, runAgent, ContinueError, CODER_OVERLAY } from "../agent.mjs"
19
+ import { createAgent, runAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
20
20
  import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
21
- import { specForModel } from "../config.mjs"
21
+ import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
22
22
 
23
23
  const label = (m) => `${m.provider}:${m.model}`
24
24
 
@@ -70,31 +70,21 @@ export const escalateTool = {
70
70
  } catch (e) {
71
71
  return `Error: ${e.message}`
72
72
  }
73
- if (!provider?.apiKey?.trim()) {
73
+ if (!ensureChildApiKey(provider)) {
74
74
  return `Error: provider "${pick.provider}" has no API key — set it in config.json before flying it in`
75
75
  }
76
76
  let effortNote = ""
77
- if (pick.effort) {
78
- // Clamp the pool's effort to the model's reasoningEffortEnum an out-of-enum
79
- // value makes provider/core.mjs throw on EVERY chat call (candidate dies on takeoff).
80
- // Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
77
+ if (pick.effort && !clampEffort(provider, pick.model, pick.effort)) {
78
+ // Out-of-enum effort dropped (see clampEffort): the provider preset default may ALSO be
81
79
  // out-of-enum for this override model — e.g. qwenplan preset default "high" is
82
- // invalid for qwen3.8-max, enum xhigh/medium/low).
83
- const enumList = specForModel(pick.model).reasoningEffortEnum
84
- if (enumList && !enumList.includes(pick.effort)) {
85
- effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
86
- delete provider.reasoningEffort
87
- } else {
88
- provider.reasoningEffort = pick.effort
89
- }
80
+ // invalid for qwen3.8-max, enum xhigh/medium/low.
81
+ effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
90
82
  }
91
83
 
92
- parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
93
- const subId = parent._subAgentCounter
94
84
  const tag = label(pick)
95
- const relayPrefix = `escalate#${subId}/`
96
- // Report the escalated model to the display layer (it may differ from the parent's).
97
- ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? tag))
85
+ const relayPrefix = makeRelay(parent, "escalate", ctx.callbacks?.onToken, provider.model ?? tag)
86
+ // Report the escalated model to the display layer (it may differ from the parent's)
87
+ // makeRelay already emitted the `[model]` metadata token above.
98
88
 
99
89
  // No wall-clock watchdog — turn cap only, exactly like subagent (the verified write
100
90
  // path). Rationale (2026-08-16): a fixed wall-clock aborts NORMAL-but-slow surgery —
@@ -103,12 +93,12 @@ export const escalateTool = {
103
93
  // signal propagates directly below). maxTurns is the cost budget; hitting it asks
104
94
  // the user whether to continue (main-agent parity), falling back to partial work.
105
95
 
106
- let output = ""
107
- const childCallbacks = {
108
- onToken: ctx.callbacks?.onToken ? (t) => { output += t; ctx.callbacks.onToken(`${relayPrefix}${t}`) } : (t) => { output += t },
109
- onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
110
- onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
111
- }
96
+ // No custom onToken here (consult P2, 2026-08-30): wrapChildCallbacks already
97
+ // applies the prefixed relay + D7 sentinel strip for the display path, and
98
+ // runWithContinue owns the capture (stripEventTokensForCapture) for the
99
+ // partial-output return a hand-rolled duplicate ran the strip twice and
100
+ // maintained a second output buffer.
101
+ const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
112
102
 
113
103
  // Declared outside try so the catch can merge mutations even on a partial failure.
114
104
  let child = null
@@ -127,48 +117,49 @@ export const escalateTool = {
127
117
  const runner = ctx.runAgent ?? runAgent
128
118
  const runOpts = {
129
119
  depth: 1,
130
- maxTurns: parent.config?.agent?.subagentTurns ?? 100,
120
+ maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS, // review #7: constant, not literal (single source with subagent.mjs)
131
121
  signal: ctx.signal ?? null,
132
122
  }
133
- // Turn-cap continue, main-agent parity (tui/agent-turn.mjs): when the child hits
134
- // ContinueError, ask the user through the SAME channel as child write approval
135
- // (ctx.onPermissionRequest). The name "continue" renders the TUI's dedicated y/n
136
- // Continue panel the same panel the main agent's turn-cap pause uses. The
137
- // resumed run passes resume:true, so runAgent does NOT re-inject the task text
138
- // (setup.mjs skips input on resume) and keeps the child's history + mutation
139
- // bookkeeping, with a fresh maxTurns budget per run. No permission handler
140
- // (headless) or a declined prompt falls through to the partial-work return.
123
+ // Turn-cap continue via runWithContinue (§7.2 D3), main-agent parity (tui/agent-turn.mjs):
124
+ // when the child hits ContinueError, ask the user through the SAME channel as child
125
+ // write approval (ctx.onPermissionRequest). The name "continue" renders the TUI's
126
+ // dedicated y/n Continue panel. The resumed run passes resume:true, so runAgent does
127
+ // NOT re-inject the task text (setup.mjs skips input on resume) and keeps the child's
128
+ // history + mutation bookkeeping, with a fresh maxTurns budget per run. No permission
129
+ // handler (headless) or a declined prompt falls through to the partial-work return.
141
130
  // Continues are UNLIMITED — the user can decline at any prompt.
142
- for (let resumes = 0; ; resumes++) {
143
- try {
144
- const report = await runner(child, task, {
145
- ...childCallbacks,
146
- // AUTO parity with subagent.mjs: parent.autoApprove must reach the child even
147
- // when no onPermissionRequest exists (ACP/headless embeds) — otherwise every
148
- // child write burns a turn on "no permission handler" rejections.
149
- onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null),
150
- }, { ...runOpts, resume: resumes > 0 })
151
- // Escalate mutations are the parent's mutations: verify/advisor guards must see them
152
- mergeChildMutations(parent, child)
153
- return `escalate (${tag})${effortNote} post-op report:\n${report || output.slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
154
- } catch (e) {
155
- // Even a failed surgery may have written files — merge whatever the child touched.
156
- mergeChildMutations(parent, child)
157
- const msg = e?.message ?? String(e)
158
- if (ctx.signal?.aborted || e?.name === "AbortError") throw e
159
- if (e instanceof ContinueError) {
160
- if (ctx.onPermissionRequest) {
161
- const go = await ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
162
- if (go) continue // fresh maxTurns budget; task NOT re-injected (resume:true)
163
- }
164
- return `escalate (${tag}) stopped: turn cap reached (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`
131
+ const report = await runWithContinue(
132
+ async (childAgent, input, cbs, opts) => {
133
+ // Merge mid-run mutations even when the run throws — the outer catch keeps
134
+ // handling createAgent failures; AbortError still propagates (user Stop).
135
+ try {
136
+ return await runner(childAgent, input, cbs, opts)
137
+ } catch (e) {
138
+ mergeChildMutations(parent, childAgent)
139
+ throw e
165
140
  }
166
- return `escalate (${tag}) error: ${msg}\nPartial output: ${output.slice(0, 2000)}`
167
- }
168
- }
141
+ },
142
+ child, task, { ...childCallbacks, onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null) },
143
+ runOpts,
144
+ {
145
+ // escalate has NO permQueue: prompts go straight to the user (T-L spec).
146
+ askContinue: (e) => (ctx.onPermissionRequest
147
+ ? ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
148
+ : Promise.resolve(false)),
149
+ onDeclined: (e, output) => `escalate (${tag}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`,
150
+ },
151
+ ).catch((e) => {
152
+ // Generic run failure (not ContinueError): match the original loop's return shape —
153
+ // error text + partial output, mutations already merged in the runner wrapper.
154
+ if (ctx.signal?.aborted || e?.name === "AbortError") throw e
155
+ return `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child._capturedOutput ?? "").slice(0, 2000)}`
156
+ })
157
+ // Escalate mutations are the parent's mutations: verify/advisor guards must see them
158
+ mergeChildMutations(parent, child)
159
+ return `escalate (${tag})${effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
169
160
  } catch (e) {
170
161
  // Reached only when createAgent itself fails or the continue prompt throws —
171
- // run failures are handled inside the loop above.
162
+ // run failures are handled above (mutations merge inside the runner wrapper).
172
163
  if (child) mergeChildMutations(parent, child)
173
164
  if (ctx.signal?.aborted || e?.name === "AbortError") throw e
174
165
  return `escalate (${tag}) error: ${e?.message ?? String(e)}`
@@ -1,4 +1,4 @@
1
- import { loadSkills, formatSkillListing, readSkill } from "../skills.mjs"
1
+ import { loadSkills, readSkill } from "../skills.mjs"
2
2
  import { escapeXml } from "../agent.mjs"
3
3
 
4
4
  /**
@@ -1,16 +1,19 @@
1
1
  import {
2
- createAgent, runAgent, ContinueError,
2
+ createAgent, runAgent,
3
3
  readonlyToolNames, collectGitContext, escapeXml,
4
4
  EXPLORE_OVERLAY, CODER_OVERLAY, PLAN_OVERLAY, ENG_CODER_OVERLAY,
5
5
  MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
6
6
  } from "../agent.mjs"
7
+ import { makeRelay, wrapChildCallbacks, runWithContinue, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
7
8
  import { validateDesignToken } from "./advisor.mjs"
8
9
 
9
10
  /**
10
11
  * subagent tool: spawn a child agent to handle an independent subtask (isolated context, only the report is returned).
11
12
  * - role: "explore" — read-only tools, search/read/analyze (suitable for codebase exploration)
12
13
  * - role: "coder" — full tool set, self-contained implementation tasks (suitable for isolated coding)
13
- * - no role specified — default behavior, same tool set as parent agent
14
+ * - no role specified — invalid by design since the 2026-08-25 fail-closed gate
15
+ * (role is mandatory; "no role → same tool set as parent" was removed with the
16
+ * coder-leak fix and the header text above predates it)
14
17
  * - parallel subagent calls via the parallel channel (parallel: true)
15
18
  * - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
16
19
  */
@@ -169,54 +172,48 @@ export const subagentTool = {
169
172
  if (gitCtx) input = `<untrusted_git_context>\n${escapeXml(gitCtx)}\n</untrusted_git_context>\n\n${input}`
170
173
  }
171
174
 
172
- // Relay content/reasoning tokens + tool calls to the parent TUI (child agent panel shows activity).
173
- // Prefix includes a unique id: parallel child agents with the same role stay independent and don't overwrite each other.
175
+ // Relay content/reasoning/tool/output to the parent TUI via the unified spawn-child
176
+ // pipeline (AGENT-LOOP.md §7.2 D3). Prefix includes a unique id: parallel child agents
177
+ // with the same role stay independent and don't overwrite each other.
174
178
  // Format: role#id/ → onToken("coder#2/writing..."), onToolCall("coder#2/read", args)
175
- parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
176
- const subId = parent._subAgentCounter
177
- const relayPrefix = `${role ?? "sub"}#${subId}/`
178
- // Report the subagent's effective model to the display layer (it may differ from the
179
- // parent's). Emitted as a `[model]` metadata token via the relay prefix — the TUI/webview
180
- // parse it into the subagent block's header instead of showing it as content.
181
- ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
179
+ const relayPrefix = makeRelay(parent, role ?? "sub", ctx.callbacks?.onToken, childProvider.model ?? "")
182
180
  const childOpts = {
183
181
  onPermissionRequest: childPermission,
184
- onToken: ctx.callbacks?.onToken
185
- ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`)
186
- : null,
187
- onReasoning: ctx.callbacks?.onReasoning
188
- ? (t) => ctx.callbacks.onReasoning(`${relayPrefix}${t}`)
189
- : null,
190
- onToolCall: ctx.callbacks?.onToolCall
191
- ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
192
- : null,
182
+ ...wrapChildCallbacks(relayPrefix, ctx.callbacks),
193
183
  }
194
184
  const childRunOpts = buildChildRunOpts(ctx)
195
185
  let report = ""
196
- // Turn-cap continue loop (TURN-CAP-CONTINUE.md): hitting the cap asks the user via
197
- // the SAME y/n panel the main agent uses (ctx.onPermissionRequest "continue")
186
+ // Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3):
187
+ // hitting the cap asks the user via the SAME y/n panel the main agent uses —
198
188
  // unlimited continues, resume:true keeps the child's history + mutation bookkeeping,
199
189
  // fresh budget each run. Prompts queue through parent._permQueue (same as write
200
190
  // approval) so parallel children never pop two panels at once. Declined / headless
201
191
  // → partial-work return. Non-ContinueError errors still propagate (dispatch.mjs
202
192
  // turns them into Error tool results — unchanged behavior).
203
- for (let resume = false; ; resume = true) {
204
- try {
205
- report = await runAgent(child, input, childOpts, { ...childRunOpts, resume })
206
- break
207
- } catch (e) {
208
- if (!(e instanceof ContinueError)) throw e
209
- let go = false
210
- if (ctx.onPermissionRequest) {
211
- const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: `${role ?? "sub"}#${subId}` })
212
- parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
213
- go = await parent._permQueue
214
- }
215
- if (go) continue
216
- if (role === "eng-coder" && child._mutatedThisRun) mergeChildMutations(parent, child)
217
- return `Subagent (${role}) stopped: turn cap reached (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${report || ""}`
218
- }
193
+ const askSubagentContinue = (e) => {
194
+ if (!ctx.onPermissionRequest) return Promise.resolve(false)
195
+ const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: relayPrefix.slice(0, -1) })
196
+ parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
197
+ return parent._permQueue
219
198
  }
199
+ const declined = { partial: null }
200
+ report = await runWithContinue(
201
+ (child, input, cbs, opts) => runAgent(child, input, cbs, opts), // opts = childRunOpts + resume (managed by the pipeline)
202
+ child, input, childOpts, childRunOpts,
203
+ {
204
+ askContinue: askSubagentContinue,
205
+ onDeclined: (e, output) => {
206
+ if (role === "eng-coder" && child._mutatedThisRun) mergeChildMutations(parent, child)
207
+ // Early return semantics (unchanged from the inline loop): the declined
208
+ // partial-work message is returned WITHOUT the MIN_REPORT_CHARS expansion —
209
+ // re-prompting a capped child for a longer report is wrong.
210
+ // Review #2 fix: use the pipeline-captured output (the `report` variable is
211
+ // still "" at this point — runWithContinue hasn't returned yet).
212
+ declined.partial = `Subagent (${role}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output || ""}`
213
+ },
214
+ },
215
+ )
216
+ if (declined.partial !== null) return declined.partial
220
217
 
221
218
  // Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
222
219
  // The child agent's history is still intact; the continuation instruction is appended as new input so it can see its own earlier work.
@@ -230,6 +227,10 @@ export const subagentTool = {
230
227
  // promised in the engineering prompt.
231
228
  // CRITICAL: Only merge if child actually mutated files (defense-in-depth against
232
229
  // runAgent throwing before any writes occurred).
230
+ // Review #8 clarification: eng-coder ONLY is intentional — the mechanical
231
+ // two-gate merge exists for engineering mode; plain `coder` children carry
232
+ // their own verify/advisor self-review discipline (per tool description), and
233
+ // normal mode has no parent advisor/verify gate to feed.
233
234
  if (role === "eng-coder" && child._mutatedThisRun) {
234
235
  mergeChildMutations(parent, child)
235
236
  }
@@ -1,5 +1,3 @@
1
- const VALID_TASK_STATUS = new Set(["pending", "in_progress", "done"])
2
-
3
1
  /** Common synonyms LLMs tend to use — normalize to canonical values */
4
2
  const STATUS_ALIASES = {
5
3
  completed: "done",
@@ -1,4 +1,3 @@
1
- import { repairHistory, listWorkDir } from "../agent.mjs"
2
1
  import { isDocFile } from "../advisor/repos.mjs"
3
2
  import { execSync, spawn, spawnSync } from "node:child_process"
4
3
  import { readFileSync, existsSync } from "node:fs"