thincoder 0.12.53 → 0.12.58

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 (82) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/acp.mjs +60 -18
  6. package/src/advisor/messages.mjs +4 -2
  7. package/src/advisor/run.mjs +2 -2
  8. package/src/agent/dispatch.mjs +66 -26
  9. package/src/agent/helpers.mjs +13 -2
  10. package/src/agent/setup.mjs +16 -3
  11. package/src/agent/spawn-child.mjs +3 -1
  12. package/src/agent-tools/advisor.mjs +19 -9
  13. package/src/agent-tools/eng.mjs +2 -0
  14. package/src/agent-tools/subagent-check.mjs +107 -0
  15. package/src/agent-tools/subagent.mjs +205 -42
  16. package/src/agent.mjs +68 -3
  17. package/src/cli/make-agent.mjs +25 -0
  18. package/src/cli/memory-command.mjs +28 -7
  19. package/src/config.mjs +120 -8
  20. package/src/context.mjs +28 -7
  21. package/src/escape.mjs +110 -22
  22. package/src/git/checkpoint.mjs +32 -6
  23. package/src/mcp/transport-http.mjs +13 -1
  24. package/src/mcp.mjs +52 -7
  25. package/src/memory/core.mjs +78 -10
  26. package/src/memory/docs.mjs +33 -7
  27. package/src/memory.mjs +1 -1
  28. package/src/model-specs.mjs +23 -0
  29. package/src/prompts/discipline.md +17 -3
  30. package/src/prompts/engineering.md +62 -5
  31. package/src/prompts/main.md +1 -0
  32. package/src/prompts/system.md +2 -1
  33. package/src/provider/anthropic.mjs +7 -5
  34. package/src/provider/core.mjs +90 -26
  35. package/src/provider/google.mjs +57 -24
  36. package/src/provider/normalize.mjs +1 -1
  37. package/src/provider/rate.mjs +0 -2
  38. package/src/provider/responses.mjs +8 -13
  39. package/src/provider/sse.mjs +20 -0
  40. package/src/session-migrate.mjs +6 -0
  41. package/src/session-slots.mjs +361 -0
  42. package/src/session.mjs +282 -306
  43. package/src/tools/apply_patch.md +2 -0
  44. package/src/tools/bash.md +2 -2
  45. package/src/tools/edit-batch.mjs +104 -0
  46. package/src/tools/edit.md +3 -0
  47. package/src/tools/execute.md +4 -4
  48. package/src/tools/execute.mjs +14 -22
  49. package/src/tools/file.mjs +17 -55
  50. package/src/tools/file_ops.md +1 -1
  51. package/src/tools/git-checkpoint.mjs +143 -0
  52. package/src/tools/git-ext.mjs +173 -0
  53. package/src/tools/git.md +21 -8
  54. package/src/tools/git.mjs +55 -177
  55. package/src/tools/lint.md +1 -1
  56. package/src/tools/linter.mjs +9 -37
  57. package/src/tools/patch.mjs +1 -1
  58. package/src/tools/shared.mjs +7 -20
  59. package/src/tui/agent-turn.mjs +3 -3
  60. package/src/tui/ansi.mjs +2 -0
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-eng.mjs +1 -0
  63. package/src/tui/cmd-mcp-form.mjs +197 -0
  64. package/src/tui/cmd-mcp.mjs +255 -114
  65. package/src/tui/cmd-new.mjs +6 -6
  66. package/src/tui/cmd-restore.mjs +27 -6
  67. package/src/tui/cmd-session.mjs +17 -4
  68. package/src/tui/index.mjs +28 -27
  69. package/src/tui/interaction.mjs +28 -1
  70. package/src/tui/key-handler.mjs +14 -2
  71. package/src/tui/layout.mjs +81 -25
  72. package/src/tui/mouse.mjs +41 -2
  73. package/src/tui/pickers.mjs +62 -4
  74. package/src/tui/render-conversation.mjs +36 -93
  75. package/src/tui/render-frame.mjs +40 -16
  76. package/src/tui/render-loop.mjs +1 -1
  77. package/src/tui/render.mjs +4 -4
  78. package/src/tui/startup.mjs +4 -2
  79. package/src/tui/subagent-blocks.mjs +119 -4
  80. package/src/tui/subagent-panel.mjs +81 -0
  81. package/src/tui/tool-events.mjs +61 -16
  82. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -22,6 +22,7 @@ export const engTool = {
22
22
  if (args.action === "exit") {
23
23
  ctx.agent.config.agent.engineering = false
24
24
  ctx.agent._engDesignToken = null // stale token from prior design review invalidated
25
+ ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
25
26
  ctx.agent._engDesignReviewed = false // reset gate state
26
27
  ctx.agent._advisorRound = 0 // reset convergence budget
27
28
  ctx.agent._touchedFiles = [] // clear mutation tracking
@@ -49,6 +50,7 @@ export const engTool = {
49
50
  }
50
51
  ctx.agent.config.agent.engineering = true
51
52
  ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
53
+ ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
52
54
  ctx.agent._lastEngState = true
53
55
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
54
56
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
@@ -0,0 +1,107 @@
1
+ /**
2
+ * subagent-check.mjs — async subagent 结果消费侧(AGENT-LOOP.md §15 D-A2)。
3
+ *
4
+ * 与 subagent.mjs 的生成侧(async 分支 + 槽位队列)分离,保持 subagent.mjs
5
+ * 在 500 行硬限内;本模块只承载 subagent_check 工具 + 其等待机制。
6
+ *
7
+ * 语义(D-A2):
8
+ * - id 缺省 → 按 ARRIVAL ORDER 返回下一个已完成的子代理(先完成先返回)
9
+ * - id 给定 → 阻塞到该 id 完成(queued 项先等启动再等完成)
10
+ * - n(必填)→ 1-based 递增读数,per-run 计数器(runAgent 非 resume 重置、
11
+ * turn-end 清空);乱序/重复 n 拒绝且不消费结果;超 MAX_ASYNC_CHECKS 上限
12
+ * 报错引导走回合收尾自动等待
13
+ * - 消费后从 map 删除——已消费 id 再查 = 与未知 id 同款错误(T12)
14
+ */
15
+ import { MAX_ASYNC_CHECKS } from "./subagent.mjs"
16
+
17
+ /** Wait for an async entry to settle (or the parent signal to abort), parked on
18
+ * the agent's waiter list — the entry settle finally wakes every waiter (same
19
+ * pattern as consult_check's session waiters). Returns "aborted" on signal. */
20
+ function wakeOnAsyncSettle(agent, ctx) {
21
+ return new Promise((resolve) => {
22
+ const cleanup = () => {
23
+ const i = (agent._asyncWaiters ?? []).indexOf(w)
24
+ if (i >= 0) agent._asyncWaiters.splice(i, 1)
25
+ ctx.signal?.removeEventListener("abort", onAbort)
26
+ }
27
+ const w = () => { cleanup(); resolve("settled") }
28
+ const onAbort = () => { cleanup(); resolve("aborted") }
29
+ ;(agent._asyncWaiters ??= []).push(w)
30
+ if (ctx.signal) {
31
+ if (ctx.signal.aborted) { onAbort(); return }
32
+ ctx.signal.addEventListener("abort", onAbort, { once: true })
33
+ }
34
+ })
35
+ }
36
+
37
+ /**
38
+ * subagent_check (AGENT-LOOP.md §15 D-A2) — fetch async subagent results.
39
+ * - id omitted → the next completed child in ARRIVAL order (first finished first)
40
+ * - id given → block until THAT child finishes (queued items wait for their start)
41
+ * - n (required) → 1-based read counter, strictly incrementing per run (loop
42
+ * guard); capped at MAX_ASYNC_CHECKS per turn — beyond that, use the turn-end
43
+ * auto-wait. Errors never consume results.
44
+ * Consumed entries are deleted from the map — a re-check of the same id is the
45
+ * same "unknown async subagent id" error (T12).
46
+ */
47
+ export const subagentCheckTool = {
48
+ name: "subagent_check",
49
+ readonly: true,
50
+ description:
51
+ "Fetch the result of an async subagent (subagent with async:true). Spawn async children to keep working in your own turn while they run in the background, then collect their reports here. Multiple async children return in completion (arrival) order — the first finished is returned first, so fast results are handled immediately instead of waiting for the slowest. Blocks until the target finishes.\n" +
52
+ "When done is true, no more results are coming (all finished and consumed) — anything left arrives automatically at turn end.\n" +
53
+ "Parameters:\n" +
54
+ "- id (optional): the subagent id from the async spawn return. Omit to fetch the next completed child (arrival order).\n" +
55
+ "- n (required): 1-based read counter — pass 1 on the first check, 2 on the next, and so on. Consecutive checks must be distinct tool calls (loop detector); at most 3 checks per turn — use the turn-end auto-wait for the rest.",
56
+ parameters: {
57
+ type: "object",
58
+ properties: {
59
+ id: { type: "string", description: "Optional subagent id (from the async spawn return). Omit = next completed child (arrival order)." },
60
+ n: { type: "number", description: "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)." },
61
+ },
62
+ required: ["n"],
63
+ },
64
+ async execute({ id, n }, ctx) {
65
+ const agent = ctx.agent
66
+ const map = agent._asyncSubagents ?? new Map()
67
+ // Strict 1-based incrementing read counter (D-A2, review #1): out-of-order /
68
+ // repeated n is rejected WITHOUT consuming a result (T14).
69
+ const lastN = agent._asyncCheckLastN ?? 0
70
+ if (!Number.isInteger(n) || n !== lastN + 1) {
71
+ return JSON.stringify({ status: "error", error: "invalid read counter — pass n = lastN+1" })
72
+ }
73
+ if (n > MAX_ASYNC_CHECKS) {
74
+ return JSON.stringify({ status: "error", error: "check limit exceeded — use turn-end auto-wait for the rest" })
75
+ }
76
+ agent._asyncCheckLastN = n
77
+
78
+ let target = null
79
+ if (id !== undefined && id !== null && String(id) !== "") {
80
+ target = map.get(String(id))
81
+ // Unknown OR already-consumed ids (consumed entries are deleted) — same error (T12).
82
+ if (!target) return JSON.stringify({ id: String(id), status: "error", error: `unknown async subagent id: ${id}` })
83
+ }
84
+
85
+ // Block until the target settles (specific id / next completed in arrival order).
86
+ for (;;) {
87
+ if (target) {
88
+ if (target.done) break
89
+ const woke = await wakeOnAsyncSettle(agent, ctx)
90
+ if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
91
+ continue
92
+ }
93
+ const completed = [...map.values()].filter((e) => e.done)
94
+ if (completed.length > 0) {
95
+ target = completed.sort((a, b) => (a._settleSeq ?? 0) - (b._settleSeq ?? 0))[0]
96
+ break
97
+ }
98
+ if (map.size === 0) return JSON.stringify({ done: true })
99
+ const woke = await wakeOnAsyncSettle(agent, ctx)
100
+ if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
101
+ }
102
+
103
+ map.delete(String(target.id))
104
+ if (target.error) return JSON.stringify({ id: String(target.id), status: "error", error: target.error })
105
+ return JSON.stringify({ id: String(target.id), role: target.role, status: "done", report: target.report ?? "" })
106
+ },
107
+ }
@@ -7,6 +7,11 @@ import {
7
7
  import { makeRelay, wrapChildCallbacks, runWithContinue, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
8
8
  import { validateDesignToken } from "./advisor.mjs"
9
9
 
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
14
+
10
15
  /**
11
16
  * subagent tool: spawn a child agent to handle an independent subtask (isolated context, only the report is returned).
12
17
  * - role: "explore" — read-only tools, search/read/analyze (suitable for codebase exploration)
@@ -52,6 +57,38 @@ export function resolveChildProvider(parent, modelArg) {
52
57
  return { ...parent.provider, model: modelArg }
53
58
  }
54
59
 
60
+ /**
61
+ * Resolve the design-token slot for an eng-coder spawn (2026-09-01 multi-design, FR3):
62
+ * - designId given → exact slot lookup (no match = explicit error, never a fuzzy guess)
63
+ * - designId omitted → exactly ONE slot must exist (single-design compatibility); with
64
+ * multiple slots we refuse rather than pick one (T16: never silently aim the wrong design)
65
+ * Returns { token } on success; throws with a parent-actionable message otherwise.
66
+ * The HMAC/TTL check itself stays in validateDesignToken (unchanged).
67
+ */
68
+ export function resolveDesignSlot(parent, designIdArg) {
69
+ const slots = parent._engDesignTokens
70
+ const hasSlots = slots instanceof Map && slots.size > 0
71
+ const legacy = parent._engDesignToken
72
+ // eng(exit/enter) resets the single mirror to force a fresh review (eng.mjs) —
73
+ // a non-empty slot map surviving that reset must NOT resurrect stale tokens:
74
+ // mirror cleared + slots present = re-entered engineering mode → re-review.
75
+ if (!legacy && hasSlots) {
76
+ throw new Error("Design tokens were reset (engineering mode was re-entered) — run advisor with type='design' again and spawn with the fresh designId+token pair.")
77
+ }
78
+ if (designIdArg) {
79
+ if (!hasSlots || !slots.has(designIdArg)) {
80
+ throw new Error(`designId not found — no approved design review holds this id. Run advisor with type='design' again and pass the designId echoed with the token. (session holds ${hasSlots ? slots.size : 0} approved design slot(s))`)
81
+ }
82
+ return { token: slots.get(designIdArg) }
83
+ }
84
+ if (hasSlots && slots.size > 1) {
85
+ throw new Error(`Multiple approved designs in this session (${slots.size}) — pass the designId parameter (echoed with each token) to choose which design this eng-coder spawn belongs to.`)
86
+ }
87
+ if (hasSlots && slots.size === 1) return { token: [...slots.values()][0] }
88
+ if (legacy) return { token: legacy } // single-slot mirror fallback (pre-multi-slot sessions)
89
+ throw new Error("Invalid or missing design token — run advisor with type='design' first and pass the returned token as designToken.")
90
+ }
91
+
55
92
  export const subagentTool = {
56
93
  name: "subagent",
57
94
  description:
@@ -61,8 +98,9 @@ export const subagentTool = {
61
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" +
62
99
  "- plan — read-only implementation planning. Same read/search toolset; NEVER edits files. Returns a step-by-step plan for the parent to execute.\n" +
63
100
  "- 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" +
64
- "- 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.\n" +
101
+ "- 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" +
65
102
  "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" +
66
104
  "Writing the prompt:\n" +
67
105
  "- 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" +
68
106
  "- Put exact paths and commands in the prompt when you know them. The sub-agent should not search for things you already know.\n" +
@@ -76,6 +114,8 @@ export const subagentTool = {
76
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." },
77
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." },
78
116
  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
+ 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)." },
79
119
  },
80
120
  required: ["task"],
81
121
  },
@@ -107,9 +147,12 @@ export const subagentTool = {
107
147
 
108
148
  // eng-coder token gate: the design review must have passed and the caller must
109
149
  // present the exact token advisor issued — otherwise the child is not authorized to code.
150
+ // 2026-09-01: multi-design slots — the token is located by designId (exact slot,
151
+ // single-slot fallthrough); HMAC/TTL validation itself is unchanged.
152
+ let issuedToken
110
153
  if (role === "eng-coder") {
111
- const issued = parent._engDesignToken
112
- if (!issued || args.designToken !== issued || !validateDesignToken(args.designToken)) {
154
+ issuedToken = resolveDesignSlot(parent, args.designId).token
155
+ if (!issuedToken || args.designToken !== issuedToken || !validateDesignToken(args.designToken)) {
113
156
  throw new Error("Invalid or missing design token — run advisor with type='design' first and pass the returned token as designToken.")
114
157
  }
115
158
  }
@@ -164,6 +207,12 @@ export const subagentTool = {
164
207
 
165
208
  // Token-verified design review → child is authorized to modify files without re-reviewing
166
209
  if (role === "eng-coder") child._engDesignReviewed = true
210
+ // designId+token ride the child bookkeeping: the delivery report carries the designId
211
+ // so the divergence-audit fix round re-spawns with the SAME slot (2026-09-01 FR3).
212
+ if (role === "eng-coder" && issuedToken) {
213
+ child._engDesignId = args.designId ?? null
214
+ child._engDesignToken = issuedToken
215
+ }
167
216
 
168
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)
169
218
  let input = args.context ? `Context:\n${args.context}\n\nTask:\n${args.task}` : args.task
@@ -176,13 +225,22 @@ export const subagentTool = {
176
225
  // pipeline (AGENT-LOOP.md §7.2 D3). Prefix includes a unique id: parallel child agents
177
226
  // with the same role stay independent and don't overwrite each other.
178
227
  // Format: role#id/ → onToken("coder#2/writing..."), onToolCall("coder#2/read", args)
179
- const relayPrefix = makeRelay(parent, role ?? "sub", ctx.callbacks?.onToken, childProvider.model ?? "")
228
+ // Async id allocation (AGENT-LOOP.md §15 D-A1): reserve the relay counter at
229
+ // spawn time — the returned id must be stable while the item sits in the queue.
230
+ // The [model] token (TUI block creation) is DEFERRED to actual start so queued
231
+ // children don't paint an empty panel block ("queued 态不显示").
232
+ let relayPrefix
233
+ if (args.async === true) {
234
+ parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
235
+ relayPrefix = `${role}#${parent._subAgentCounter}/`
236
+ } else {
237
+ relayPrefix = makeRelay(parent, role ?? "sub", ctx.callbacks?.onToken, childProvider.model ?? "")
238
+ }
180
239
  const childOpts = {
181
240
  onPermissionRequest: childPermission,
182
241
  ...wrapChildCallbacks(relayPrefix, ctx.callbacks),
183
242
  }
184
243
  const childRunOpts = buildChildRunOpts(ctx)
185
- let report = ""
186
244
  // Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3):
187
245
  // hitting the cap asks the user via the SAME y/n panel the main agent uses —
188
246
  // unlimited continues, resume:true keeps the child's history + mutation bookkeeping,
@@ -196,49 +254,154 @@ export const subagentTool = {
196
254
  parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
197
255
  return parent._permQueue
198
256
  }
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
217
-
218
- // Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
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.
220
- if (report.length < MIN_REPORT_CHARS) {
221
- report = await runAgent(child, REPORT_CONTINUATION, childOpts, childRunOpts)
222
- }
223
257
 
224
- // Engineering mode mechanical code gate: delegated file changes must not
225
- // bypass the parent's advisor/verify guards. Merge the child's mutations
226
- // into the parent so "advisor mandatory at both gates" is enforced, not just
227
- // promised in the engineering prompt.
228
- // CRITICAL: Only merge if child actually mutated files (defense-in-depth against
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.
234
- if (role === "eng-coder" && child._mutatedThisRun) {
235
- mergeChildMutations(parent, child)
258
+ // ── Async branch (AGENT-LOOP.md §15 D-A1/D-A6): spawn without waiting ──
259
+ // The child runs the EXACT blocking pipeline (runChildPipeline below relay /
260
+ // turn-cap / permission / MIN_REPORT_CHARS / mergeChildMutations all unchanged),
261
+ // 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
263
+ // count < ASYNC_SUBAGENT_LIMIT start now; ≥ limit → enqueue (status "queued",
264
+ // position = queue index) never rejected, never requiring the model to batch.
265
+ if (args.async === true) {
266
+ if ((ctx.depth ?? 0) > 0) {
267
+ throw new Error("async spawn only available at the top level")
268
+ }
269
+ parent._asyncSubagents ??= new Map()
270
+ parent._asyncQueue ??= []
271
+ const running = [...parent._asyncSubagents.values()].filter((e) => e.status === "running").length
272
+ const id = parent._subAgentCounter
273
+ const entry = {
274
+ id, role, relayPrefix,
275
+ status: running >= ASYNC_SUBAGENT_LIMIT ? "queued" : "running",
276
+ position: undefined,
277
+ report: null, error: null, done: false,
278
+ promise: null, _settle: null, _settleSeq: 0,
279
+ }
280
+ // The settle signal — resolves when the run chain settles (never rejects).
281
+ entry.promise = new Promise((res) => { entry._settle = res })
282
+ entry.start = () => {
283
+ entry.status = "running"
284
+ entry.position = undefined
285
+ // Deferred [model] emit: the TUI block is created at ACTUAL start.
286
+ ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
287
+ // 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, {
290
+ parent, role, args,
291
+ askContinue: () => Promise.resolve(false),
292
+ })
293
+ .then((report) => { entry.report = report })
294
+ .catch((err) => { entry.error = err?.message ?? String(err) })
295
+ .finally(() => {
296
+ entry.status = "done" // running 数口径(D-A1/D-A2/T6):已完成未消费不计入
297
+ 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`)
304
+ }
305
+ entry._settleSeq = (parent._asyncSettleSeq = (parent._asyncSettleSeq ?? 0) + 1)
306
+ entry._settle()
307
+ for (const w of parent._asyncWaiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
308
+ maybeRefillAsync(parent)
309
+ })
310
+ }
311
+ parent._asyncSubagents.set(String(id), entry)
312
+ if (entry.status === "queued") {
313
+ parent._asyncQueue.push(entry)
314
+ entry.position = parent._asyncQueue.length
315
+ return JSON.stringify({ id: String(id), role, status: "queued", position: entry.position })
316
+ }
317
+ entry.start()
318
+ return JSON.stringify({ id: String(id), role, status: "running" })
236
319
  }
237
320
 
238
- return report
321
+ // ── Blocking path (unchanged semantics): await the full pipeline ──
322
+ return await runChildPipeline(child, input, childOpts, childRunOpts, {
323
+ parent, role, args,
324
+ askContinue: askSubagentContinue,
325
+ })
239
326
  },
240
327
  }
241
328
 
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
+
242
405
  /**
243
406
  * Child agent run options — the parent's abort signal MUST propagate to the
244
407
  * child: without it, Ctrl+C aborts the parent's controller but the child keeps
package/src/agent.mjs CHANGED
@@ -18,7 +18,7 @@ import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
18
18
  import {
19
19
  escapeXml, repairHistory, listWorkDir, ensureAutoReminder,
20
20
  readonlyToolNames, collectGitContext, loadProjectInstructions,
21
- ContinueError,
21
+ ContinueError, offloadToolResult,
22
22
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
23
23
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
24
24
  } from "./agent/helpers.mjs"
@@ -140,6 +140,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
140
140
  agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
141
141
  agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
142
142
  agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
143
+ agent._asyncCheckLastN = 0 // subagent_check read counter is per-run (§15 D-A2): a fresh user turn restarts from 1
143
144
  }
144
145
  // eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE runAgent —
145
146
  // only reset for the top-level agent (depth 0); child runs must keep their granted authorization
@@ -164,6 +165,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
164
165
  tools: toolSchemas,
165
166
  }
166
167
 
168
+ let thrownError = null
167
169
  try {
168
170
  for (let turn = 0; turn < maxTurns; turn++) {
169
171
  // Update turn counter for status bar display
@@ -184,16 +186,25 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
184
186
  agent._compressFailures = 0
185
187
  agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
186
188
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
187
- callbacks.onCompress?.()
189
+ // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode: "summary", tokensFreed, elapsedMs }
190
+ // from compressIfNeeded, or { mode: "fallback", tailMessages } from compressFallback below —
191
+ // the TUI panel renders the matching completion state. Existing callers that ignore the
192
+ // argument keep the exact previous onCompress semantics.
193
+ callbacks.onCompress?.(agent._lastCompressInfo ?? {})
188
194
  ensureAutoReminder(agent)
189
195
  }
190
196
  } catch (compressError) {
191
197
  // AbortError must not be swallowed: user cancellation must propagate
192
198
  if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
193
199
  agent._compressFailures = (agent._compressFailures ?? 0) + 1
200
+ // Q3 visibility (CONTEXT-COMPACTION §7 D-C1): a failed compression is no longer silent —
201
+ // the frontend updates the compression panel with the error text (and logs to stderr).
202
+ // Failure STRATEGY is unchanged: COMPRESS_FAILURE_LIMIT consecutive failures still degrade
203
+ // to compressFallback — this only adds observability.
204
+ callbacks?.onCompressFail?.(compressError)
194
205
  if (agent._compressFailures >= COMPRESS_FAILURE_LIMIT) {
195
206
  agent._compressFailures = 0
196
- if (compressFallback(agent)) callbacks.onCompress?.()
207
+ if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
197
208
  }
198
209
  }
199
210
  }
@@ -420,9 +431,63 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
420
431
  }
421
432
 
422
433
  throw new ContinueError(maxTurns)
434
+ } catch (e) {
435
+ thrownError = e
436
+ throw e
423
437
  } finally {
424
438
  // Turn-end cleanup: abort any leftover consultation children (consult_start spawns
425
439
  // fire-and-forget runners; a completed turn must not let them keep burning tokens).
426
440
  cleanupConsultSessions(agent)
441
+ // Async subagent turn-end collection (AGENT-LOOP.md §15 D-A3). Lifecycle:
442
+ // - Ctrl+C / Ctrl+I (signal aborted): children were aborted with the parent
443
+ // signal — clear WITHOUT injecting stale errors (user explicitly stopped).
444
+ // - ContinueError (turn cap): no wait, no injection — children keep running
445
+ // and the RESUME run's turn-end collection takes over.
446
+ // - anything else: refill loop → wait for all → inject reports → clear.
447
+ if (signal?.aborted) {
448
+ agent._asyncSubagents?.clear()
449
+ agent._asyncQueue = []
450
+ agent._asyncCheckLastN = 0
451
+ } else if (thrownError instanceof ContinueError) {
452
+ // keep _asyncSubagents + the check counter — the resumed run continues them
453
+ } else {
454
+ await collectAsyncSubagents(agent)
455
+ agent._asyncCheckLastN = 0
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Turn-end async subagent collection (AGENT-LOOP.md §15 D-A3):
462
+ * 1. refill loop — start queued heads while slots free (each settle already
463
+ * refills via its finally; this drains the tail), keeping the cap ≤4 serial.
464
+ * 2. wait for every entry to settle (queued entries start through the refill
465
+ * chain — the drain loop converges when nothing is running and the queue is empty).
466
+ * 3. inject one user-role reminder per entry: the report/error text XML-escaped
467
+ * (child reports may carry content from files/webpages — reminder discipline),
468
+ * >64K offloaded to disk with a preview + path.
469
+ * 4. clear the map. (The ⟦ev⟧done freeze signal is NOT emitted here — D-A3
470
+ * 2026-09-02: each entry's settle callback emits it at completion time so
471
+ * blocks freeze at their completion position in the stream.)
472
+ */
473
+ async function collectAsyncSubagents(agent) {
474
+ const map = agent._asyncSubagents
475
+ if (!map || map.size === 0) return
476
+ const { maybeRefillAsync } = await import("./agent-tools/subagent.mjs")
477
+ for (;;) {
478
+ maybeRefillAsync(agent)
479
+ const running = [...map.values()].filter((e) => e.status === "running")
480
+ if (running.length === 0) break
481
+ await Promise.allSettled(running.map((e) => e.promise))
482
+ }
483
+ for (const e of [...map.values()]) {
484
+ const body = e.error ?? e.report ?? "(no report)"
485
+ const preview = await offloadToolResult(String(body), `async-subagent-${e.id}`)
486
+ pushReal(agent, {
487
+ role: "user",
488
+ content: `[System reminder: async subagent #${e.id} (${e.role}) finished]\n${escapeXml(preview)}`,
489
+ })
427
490
  }
491
+ map.clear()
492
+ agent._asyncQueue = []
428
493
  }
@@ -107,6 +107,31 @@ export async function assembleAgent() {
107
107
  agent.activeProvider = config.activeProvider
108
108
  agent.activeModel = config.activeModel ?? null
109
109
  agent._mcpWarnings = mcpWarnings
110
+ // SESSION.md §8 D-S1:assembleAgent 后唯一校验点(TUI/chat 两路径同源)——不抛错不退出,
111
+ // 标记由调用侧消费(TUI 弹重选 / headless 报错)。空 provider 由 TUI 路径在 startTUI 前清空。
112
+ validateProvider(agent)
113
+ return agent
114
+ }
115
+
116
+ /**
117
+ * SESSION.md §8 D-S1 — provider 有效性校验(assembleAgent 后唯一校验点,TUI/chat 两路径同源)。
118
+ * 判据(评审 #1/#2):仅 model/baseURL 缺失判 invalid——**不得用 MODEL_SPECS 成员资格判无效**
119
+ * (未知模型 = 受支持场景:自定义端点模型不在 spec 表是常态,误判会让自定义模型用户每次恢复都弹重选)。
120
+ * apiKey 缺失不判(既有 wizard /model 流程处理)。幂等:有效时清标记,无效时置标记 + 原因。
121
+ * 不抛错、不退出。返回 agent 便于链式调用。
122
+ */
123
+ export function validateProvider(agent) {
124
+ const ok = Boolean(agent.provider?.name && agent.provider.model && agent.provider.baseURL)
125
+ if (ok) {
126
+ delete agent._providerInvalid
127
+ delete agent._providerInvalidReason
128
+ } else {
129
+ agent._providerInvalid = true
130
+ agent._providerInvalidReason = !agent.provider?.name
131
+ ? "provider 不存在"
132
+ : !agent.provider.model ? "model 缺失"
133
+ : "缺少 baseURL"
134
+ }
110
135
  return agent
111
136
  }
112
137
 
@@ -1,7 +1,12 @@
1
- import { put, remove, search, list } from "../memory.mjs"
1
+ import { join } from "node:path"
2
+ import { loadConfig } from "../config.mjs"
3
+ import { teamConfig } from "./make-agent.mjs"
4
+ import { put, search, list, deleteByUid } from "../memory/core.mjs"
2
5
 
3
- /** thincoder memory <list|search|put|remove> subcommands */
4
- export async function memoryCommand(memory, args) {
6
+ /** thincoder memory <list|search|put|remove> subcommands.
7
+ * opts.dirs: { project, team } layer directories for project/team file deletion (tests inject their own);
8
+ * falls back to the same config-derived dirs the agent uses. */
9
+ export async function memoryCommand(memory, args, opts = {}) {
5
10
  const [sub, ...rest] = args
6
11
 
7
12
  const flags = {}
@@ -37,12 +42,18 @@ export async function memoryCommand(memory, args) {
37
42
  break
38
43
  }
39
44
  case "remove": {
40
- const id = Number(positional[0])
41
- if (!id) {
42
- console.error("Usage: thincoder memory remove <id>")
45
+ const uid = positional[0]
46
+ if (!uid) {
47
+ console.error("Usage: thincoder memory remove <uid> (uid: personal:<n> | project:<origin>:<path> | team:<origin>:<path>; bare <n> = personal)")
48
+ return 1
49
+ }
50
+ try {
51
+ const entry = await deleteByUid(memory, uid, { dirs: opts.dirs ?? cliDirs() })
52
+ console.log(`Removed ${entry.id}: ${entry.title}`)
53
+ } catch (e) {
54
+ console.error(e.message)
43
55
  return 1
44
56
  }
45
- console.log((await remove(memory, id)) ? `Removed #${id}` : `No entry #${id}`)
46
57
  break
47
58
  }
48
59
  default:
@@ -51,6 +62,16 @@ export async function memoryCommand(memory, args) {
51
62
  }
52
63
  }
53
64
 
65
+ /** Layer directories for project/team file deletion — derived from the same config the agent uses. */
66
+ function cliDirs() {
67
+ const config = loadConfig()
68
+ const dirs = { project: null, team: null }
69
+ if (config.memory?.projectDir) dirs.project = join(process.cwd(), config.memory.projectDir)
70
+ const team = teamConfig(config)
71
+ if (team) dirs.team = team.dir
72
+ return dirs
73
+ }
74
+
54
75
  function printEntries(entries) {
55
76
  if (entries.length === 0) {
56
77
  console.log("(no entries)")