thincoder 0.12.49 → 0.12.51

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 (41) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/package.json +4 -3
  3. package/src/acp/bridge.mjs +4 -0
  4. package/src/agent/dispatch.mjs +19 -7
  5. package/src/agent/helpers.mjs +12 -0
  6. package/src/agent/record-results.mjs +130 -0
  7. package/src/agent/setup.mjs +5 -8
  8. package/src/agent/spawn-child.mjs +159 -0
  9. package/src/agent-tools/consult.mjs +95 -73
  10. package/src/agent-tools/escalate.mjs +53 -62
  11. package/src/agent-tools/subagent.mjs +39 -38
  12. package/src/agent.mjs +25 -109
  13. package/src/generate-title.mjs +30 -1
  14. package/src/prompts/advisor-round1.md +5 -6
  15. package/src/prompts/advisor-round2.md +3 -4
  16. package/src/prompts/advisor-round3.md +3 -4
  17. package/src/prompts/eng-coder.md +10 -0
  18. package/src/prompts/engineering.md +81 -14
  19. package/src/prompts/methodology-template.md +8 -3
  20. package/src/prompts/system.md +1 -1
  21. package/src/session.mjs +48 -1
  22. package/src/tools/system.mjs +3 -1
  23. package/src/tui/agent-turn.mjs +44 -363
  24. package/src/tui/cmd-advisor.mjs +20 -2
  25. package/src/tui/cmd-eng.mjs +44 -7
  26. package/src/tui/dims.mjs +74 -0
  27. package/src/tui/fold-block.mjs +208 -0
  28. package/src/tui/index.mjs +53 -16
  29. package/src/tui/key-handler-search.mjs +1 -1
  30. package/src/tui/key-handler.mjs +9 -6
  31. package/src/tui/layout.mjs +21 -20
  32. package/src/tui/mouse.mjs +8 -6
  33. package/src/tui/pickers.mjs +1 -1
  34. package/src/tui/render-conversation.mjs +368 -113
  35. package/src/tui/render-frame.mjs +9 -88
  36. package/src/tui/render-loop.mjs +12 -8
  37. package/src/tui/render.mjs +5 -0
  38. package/src/tui/startup.mjs +67 -13
  39. package/src/tui/subagent-blocks.mjs +326 -0
  40. package/src/tui/tool-args.mjs +67 -0
  41. package/src/tui/tool-events.mjs +461 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,49 @@
1
- # Changelog
1
+ ## [0.12.51] — 2026-08-30
2
2
 
3
- 本文件记录 ThinCoder CLI 的发布历史。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),版本遵循[语义化版本](https://semver.org/lang/zh-CN/)。
3
+ ### Added
4
+
5
+ - **折叠系统大修(0.12.7 单框化的终章)**:公共折叠组件 `src/tui/fold-block.mjs`(147→168 行)——所有可折叠区块(思考/工具/子agent/advisor/长消息/连续 dim)统一:折叠态 = 命名头 + tail 3 行;展开态 = 空行 + ▼ 控制行 + 60% 屏幕封顶(触顶时底部第二个 ▼ 控制行必在视口内)。主输出(C.text)**永不折叠**;思考**无条件折叠**(阈值思路三次真机失败后废弃);工具摘要(C.dim)>12 行折叠。`/fold off` 全关
6
+ - **工具调用单框化**:`_toolBlock` 载体——头 = `❯ name 参数摘要 · running|耗时·摘要`,体 = 参数 pretty JSON + 流式输出 + 结果(dim)。旧四段式(标题/_live 滚动/done 行)废除
7
+ - **会话文件瘦身(deepseek 会诊方案,`slimForDisplay`)**:写入时人读线截断(tool args 300 字符 / tool 结果 500 / 多模态图剥 base64 只留 text part),机读线(contextHistory)一字不动(provider 前缀缓存/配对/多轮看图零风险);copy-on-write 绝不原地改(两线共享对象引用);VS Code 端同款
8
+ - **会诊(consult)体验修复**:consult_check/consult_stop 新增递增 `n` 参数(连续调用参数集不同,绕开循环检测器误报);会诊结束**会话级结算**(finishSubTasksByRole 全量冻结 N 块,单块 finishSubTask 曾留 N-1 个 running 幽灵);单条回复按 model 精确收尾(尾段归一化——`[model]` 裸名 vs `r.model` provider:model 曾是死代码);冻结墓碑防 abort 尾部 token 复活块
9
+ - **终端尺寸单源(`dims.mjs`)**:Windows ConPTY 的 columns/rows 不稳定(启动 falsy、输出活动期报 stale 小值)——sample-and-hold 缓存 + 事件驱动采样(启动收敛重试/resize/空闲看门狗/turn-start/turn-finally),渲染路径纯读缓存;非对称接受(变大立即提交、变小需连续两次确认)
10
+ - **主输出呼吸空行**:主输出段前后各空一行(渲染期插入,不写 state.lines);streaming 与落盘路径一致(首版漏 streaming 分支,用户实测"生成时不空落盘后才空")
11
+ - **任务面板顶部分隔线** + **子agent 运行区块段首分隔线**(`─` dim,与上方会话区切分;小终端压缩时 task 分隔线先让位)
12
+ - **会诊四轮**:P0(搜索缓存键/中断清扫/tool_call_id 贯穿)、P1(死代码/缓存维度/增量行数/restore 守卫/foldKey 稳定化)、P3(multimodal flag 驱动 offload/日志截断/事件语法单源)——第三轮会诊(ConPTY)与窄屏真根因(组件漏传 cols)分别独立成条
13
+
14
+ ### Fixed
15
+
16
+ - **流式窄屏真根因**:`renderExpandedBlock`/`renderFoldedHead` 三处调用漏传 `cols` → 组件按默认 80 wrap(280 列终端"生成中左边一小块、落盘后宽");全文扫尾 + 组件 cols 纪律入文档
17
+ - **窄屏误诊链清理**:ConPTY 采样不再每帧 refresh(输出活动期 stale 80 污染缓存);启动收敛跑满窗口(sawValid 曾提前掐断);turn-start/turn-finally 采样点
18
+ - **会诊残留双根因**:finishSubTask 单块语义(N-1 幽灵 + interrupted 误标)+ 无墓碑(迟到 token 复活块)
19
+ - **subagent/escalate/advisor 成功调用误标 "(interrupted)"**(settleToolBlock 收尾);Ctrl+I 中断时 tool_calls 悬空(合成占位 tool 结果,strict provider 400);main 输出空行 streaming 路径缺失
20
+ - **搜索高亮被缓存吃掉**(convCacheKey 补 search 维度);[model] token 后头部不刷新(subSig 补 model);并行同名工具输出/耗时错配(toolCallId 贯穿)
21
+ - **generate-title proxy 路径 bug**(动态 import 相对路径从 src/ 解析到仓库根——静默 ERR_MODULE_NOT_FOUND 被 catch 吞,代理用户标题自创建起失效;静态导入 + 测试缝)
22
+
23
+ ### Changed
24
+
25
+ - **agent.mjs 拆分**:工具结果提交/记账抽 `src/agent/record-results.mjs`(多模态延迟注入、FILE_MUTATORS 失效链、touchedFiles + reindex);`_maxTurns` 引用 DEFAULT_MAX_TURNS;escalate 删手写 onToken/output(runWithContinue 统一 capture);AUTO_REMINDER/ensureAutoReminder 单源(helpers);魔法数字具名(tool-events caps / consult 默认值);事件语法分支列表单源(EVENT_PHASE/EVENT_TYPE)
26
+ - **行语法统一**:三个行生产者(live flushStream / 恢复 historyToLines / 注入行)统一打 `_kind` 标记(thinking/text/tool),buildConvLines 读标记不再从颜色猜
27
+ - **live/restore 逐行对齐**:同一载体、同一渲染路径;恢复结果守卫与 live 共享(`slimToolResultForDisplay`:read_image base64 剥离 + 400 行封顶)
28
+ - **折叠 key 稳定化**:工具块 fold key 用行级 `_lineId`(loadOlder unshift 不再错绑展开态)
29
+ - **文档同步**:TUI.md / TUI-TOOL-OUTPUT.md / CONSULTATION.md / SESSION.md / AGENT-LOOP.md 与今日全部机制更新对齐
30
+
31
+ ### Tests
32
+
33
+ - 812 全量(今日新增:折叠组件、dims 单源(sampler 注入 + 守卫 grep 断言)、会诊残留/精确收尾/墓碑、streaming 空行路径、subagent 分隔线、中断配对、工具块 ids、会话瘦身等 30+ 用例)
34
+ - 会诊四轮评审全部落地并回归锁定:P0/P1/P3 + ConPTY 硬度 + ByModel 生产格式(自洽世界假绿被二次会诊实锤后改用生产报文断言)
35
+
36
+
37
+
38
+ ### Changed
39
+
40
+ - **engineering 与 advisor.guard 改为会话级(跨端污染修复)**:旧设计里 engineering 只存 config.json 全局(`agent.engineering`),CLI `/eng` 与 VS Code 面板都写它 → 两端互相翻转对方的工程模式("VS Code 工程模式下模型仍委托 role='coder'")。现事实源是当前会话槽位文件(`engineering` 字段 + `advisor.guard`),config.json 降为 CLI 兼容/可见性镜像(双写保留:slot 先、config 后,slot 失败不阻断)。改动:`/eng` toggle 双写 slot(`cmd-eng.mjs` persistEngineering);`/advisor` guard 切换双写 slot(`cmd-advisor.mjs` persistGuard,model/thinking 仍 config-scoped);`saveSession`/`applySession` 往返 slot 值(无字段旧槽位回退 config,兼容锁定);启动恢复链 `bin/thincoder.mjs` applySession 时 slot 覆盖 config 播种值。权威文档:`docs/design/ENGINEERING-MODE.md` §5 重写
41
+ - **METHODOLOGY 三缺口修复**(核对工程模式提示词时发现):① 需求文档三层结构(总目标/功能用户故事/非功能标准)落地进 METHODOLOGY.md 与 methodology-template.md——engineering.md 的 "three layers per METHODOLOGY" 引用此前悬空;② engineering.md 交付评审补测试文档口径(METHODOLOGY 存在时每条用户故事须有测试用例覆盖正常/边界/异常,无覆盖=评审不通过)——与三文档硬流程对齐;③ METHODOLOGY.md 缺失警告点名后果(引用悬空+硬流程失效+恢复路径),不再静默降级
42
+ - **工程模式 UI/交互决策全链路落档**(用户报告"agent 无视讨论过的 UI 设计"):设计文档要素扩项——涉及界面时必须收录与用户达成的每条 UI/交互决策(布局/流程/控件行为/状态反馈),未定标 open 不静默发明;eng-coder 任务书必须复述这些决策(或指向设计文档具体章节)——子代理零上下文,留在聊天里的决策永远到不了它;`eng-coder.md` 执行侧闭合——缺失的界面决策停下报告,不自行发明。两端 prompts byte-identical;`ENGINEERING-MODE.md` §7 变更记录
43
+
44
+ ### Tests
45
+
46
+ - 新增 `test/session-eng-advisor.test.mjs`:/eng 双写断言(slot+config)、applySession slot 恢复(true/false/显式 false 压过 config true)、旧槽位无字段回退 config 锁定、saveSession 每 turn 往返、/advisor guard 双写(model 等仍 config-scoped)
4
47
 
5
48
  ## [0.12.49] — 2026-08-29
6
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.49",
3
+ "version": "0.12.51",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -31,9 +31,10 @@
31
31
  "url": "git+https://github.com/xinbo-tech/thincoder.git"
32
32
  },
33
33
  "scripts": {
34
- "test": "node --test \"test/*.mjs\"",
34
+ "test": "node --test \"test/*.test.mjs\"",
35
35
  "prepublishOnly": "npm run lint && node --test \"test/*.mjs\"",
36
- "lint": "eslint src"
36
+ "lint": "eslint src",
37
+ "test:full": "node test/run-full.mjs"
37
38
  },
38
39
  "author": "liwei <liwei@51marine.com> (上海新舶)",
39
40
  "license": "MIT",
@@ -70,6 +70,10 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
70
70
  // Strip the subagent `[model]` metadata token (role#id/[model]<name>) — it's a
71
71
  // TUI/webview display signal, not conversation content, and must not reach ACP clients.
72
72
  if (/^[\w-]+#\d+\/\[model\]/.test(text)) return
73
+ // D7 (AGENT-LOOP.md §7.2): strip ⟦ev⟧ event tokens (bare or prefixed variants) —
74
+ // they carry RS control characters and are a TUI display signal; structured ACP
75
+ // mapping (tool_call_update) is tracked separately in docs/TODO.md.
76
+ if (/^(?:[\w-]+#\d+\/)?⟦ev⟧(?:turn|approval)\x1e/.test(text)) return
73
77
  update("agent_message_chunk", { content: { type: "text", text } })
74
78
  },
75
79
  onReasoning: (text) => update("agent_thought_chunk", { content: { type: "text", text } }),
@@ -27,7 +27,7 @@ function logToolError(toolName, args, error) {
27
27
  const entry = [
28
28
  `time: ${now.toISOString()}`,
29
29
  `tool: ${toolName}`,
30
- `args: ${JSON.stringify(args, null, 2)}`,
30
+ `args: ${JSON.stringify(args, null, 2).slice(0, 2000)}${JSON.stringify(args, null, 2).length > 2000 ? "… (truncated)" : ""}`,
31
31
  `error: ${error?.message ?? String(error)}`,
32
32
  error?.stack ? `stack:\n${error.stack}` : "",
33
33
  ].filter(Boolean).join("\n") + "\n"
@@ -111,7 +111,16 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
111
111
  const allowed = agent.autoApprove
112
112
  ? true
113
113
  : callbacks.onPermissionRequest
114
- ? await callbacks.onPermissionRequest(toolCall.name, args)
114
+ ? await (async () => {
115
+ // D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
116
+ // subagent block header flips to "等待审批" so a waiting child is visibly
117
+ // different from a stalled one. Depth>0 only (the parent TUI shows its own
118
+ // permission panel). turn n/max = the child's live turn counters.
119
+ if (depth > 0) {
120
+ callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(toolCall.name).slice(0, 40)}`)
121
+ }
122
+ return await callbacks.onPermissionRequest(toolCall.name, args)
123
+ })()
115
124
  : false
116
125
  if (!allowed) {
117
126
  prepared.push({ toolCall, tool, denied: true, reason: callbacks.onPermissionRequest ? "denied by user" : "no permission handler" })
@@ -127,7 +136,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
127
136
 
128
137
  // Panel area abolished — all tools now stream inline via onToolOutput.
129
138
 
130
- callbacks.onToolCall?.(toolCall.name, args)
139
+ callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
131
140
 
132
141
  prepared.push({ toolCall, tool, args })
133
142
  }
@@ -157,7 +166,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
157
166
  if (callbacks.toolRouter) {
158
167
  const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
159
168
  if (routed?.handled) {
160
- callbacks.onToolResult?.(item.toolCall.name, routed.result)
169
+ callbacks.onToolResult?.(item.toolCall.name, routed.result, item.toolCall.id)
161
170
  return { ...item, result: routed.result, ok: true }
162
171
  }
163
172
  }
@@ -167,14 +176,17 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
167
176
  depth,
168
177
  signal,
169
178
  callbacks,
170
- onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk),
179
+ onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
171
180
  onQuestion: callbacks.onQuestion,
172
181
  onPermissionRequest: callbacks.onPermissionRequest,
173
182
  })
174
183
  if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
175
184
  const raw = String(rawResult)
176
- const result = item.toolCall.name === "read_image" ? raw : await offloadToolResult(raw, item.toolCall.id)
177
- callbacks.onToolResult?.(item.toolCall.name, result)
185
+ // Multimodal tools keep the raw result (base64 images ride the multimodal
186
+ // channel); everything else offloads oversized text to disk. Flag-driven, not
187
+ // name-driven (consult P3, 2026-08-30).
188
+ const result = item.tool?.multimodal ? raw : await offloadToolResult(raw, item.toolCall.id)
189
+ callbacks.onToolResult?.(item.toolCall.name, result, item.toolCall.id)
178
190
  // PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
179
191
  runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
180
192
  return { ...item, result, ok: true }
@@ -8,6 +8,18 @@ import { writeFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
8
8
  import { join } from "node:path"
9
9
  import { execSync } from "node:child_process"
10
10
 
11
+ /** Single source for the AUTO-mode reminder (was duplicated in agent.mjs +
12
+ * setup.mjs — consult P2, 2026-08-30). */
13
+ export const AUTO_REMINDER = "[System reminder: AUTO mode is active — all tool calls are automatically approved without asking.]"
14
+
15
+ /** Inject the AUTO reminder once per history (both call sites used the same
16
+ * literal + guard — consolidated here). */
17
+ export function ensureAutoReminder(agent) {
18
+ if (agent.autoApprove && !agent.history.some((m) => m.content === AUTO_REMINDER)) {
19
+ agent.history.push({ role: "user", content: AUTO_REMINDER })
20
+ }
21
+ }
22
+
11
23
  export const DEFAULT_MAX_TURNS = 200
12
24
  export const DEFAULT_SUBAGENT_TURNS = 100
13
25
  export const DEFAULT_GOAL_TURNS = 200
@@ -0,0 +1,130 @@
1
+ /**
2
+ * record-results.mjs — tool result commit + mutation accounting (split from
3
+ * agent.mjs 2026-08-30, consult P2: the 100-line loop was pure bookkeeping
4
+ * mixed into the run loop; it now lives behind one call).
5
+ *
6
+ * Contract (moved verbatim from the run loop):
7
+ * - Multimodal tool results ({ text, images }) close the tool pairing with a
8
+ * tool message, then inject the images as a multimodal user message — but
9
+ * NEVER between tool messages of parallel calls (strict providers 400 when
10
+ * a tool message does not immediately follow its assistant tool_calls), so
11
+ * image injections are DEFERRED until all results are committed.
12
+ * - Non-vision models get a text-only System-reminder instead of image parts
13
+ * (image parts 400 on every subsequent request — defense-in-depth).
14
+ * - FILE_MUTATORS invalidate advisor/verify state (code changed → prior
15
+ * review/verify is stale); non-mutating side-effect tools (bash/git)
16
+ * invalidate verify only (user decision 2026-08-08: reviews trigger on
17
+ * code mutations, not environment changes).
18
+ * - advisor calls always advance _advisorRound (convergence budget counts
19
+ * attempts, not successes).
20
+ * - Mutations feed _touchedFiles and fire-and-forget memory reindex.
21
+ */
22
+ import { pushReal } from "../context.mjs"
23
+ import { specForModel } from "../config.mjs"
24
+ import { FILE_MUTATORS } from "./helpers.mjs"
25
+ import { join } from "node:path"
26
+
27
+ let _reindexFile = null
28
+
29
+ export async function recordToolResults(agent, toolByName, results) {
30
+ // Multimodal user messages (injected images / not-injected reminders) must NOT be pushed
31
+ // between tool results of parallel calls — strict providers (DeepSeek) 400 when a tool
32
+ // message does not immediately follow its assistant tool_calls. Defer to after the loop.
33
+ // real: image injections are real messages (pushReal → _fullHistory); reminders stay machine-only.
34
+ const deferredUserMsgs = []
35
+
36
+ for (const { toolCall, result, ok } of results) {
37
+ const tool = toolByName.get(toolCall.name)
38
+ // Multimodal tools return JSON { text, images } — inject as multimodal user message
39
+ if (tool?.multimodal && ok) {
40
+ try {
41
+ const parsed = JSON.parse(result)
42
+ if (parsed.images?.length) {
43
+ // tool message first — closes the tool_call pairing (OpenAI API requires tool result immediately after assistant with tool_calls)
44
+ pushReal(agent, { role: "tool", tool_call_id: toolCall.id, name: toolCall.name, content: parsed.text })
45
+ if (specForModel(agent.provider.model).multimodal) {
46
+ // then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
47
+ deferredUserMsgs.push({
48
+ real: true,
49
+ msg: {
50
+ role: "user",
51
+ content: [{ type: "text", text: parsed.text }, ...parsed.images],
52
+ },
53
+ })
54
+ } else {
55
+ // Non-vision model: image parts must never enter history — text-only APIs 400 on them on EVERY
56
+ // subsequent request, poisoning the conversation. (read_image itself already refuses; this is defense-in-depth.)
57
+ deferredUserMsgs.push({
58
+ real: false,
59
+ msg: {
60
+ role: "user",
61
+ content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
62
+ },
63
+ })
64
+ }
65
+ continue
66
+ }
67
+ } catch { /* Parse failure doesn't affect normal tool messages */ }
68
+ }
69
+ pushReal(agent, { role: "tool", tool_call_id: toolCall.id, name: toolCall.name, content: result })
70
+ if (tool && ok) {
71
+ if (FILE_MUTATORS.has(toolCall.name)) {
72
+ // Direct file edit — code was changed. The prior advisor review and
73
+ // verify are stale: a review that ran before the edit no longer
74
+ // covers the current file state.
75
+ agent._mutatedThisRun = true
76
+ agent._calledAdvisorThisRun = false
77
+ agent._verifiedThisRun = false
78
+ agent._verifyPassed = undefined
79
+ } else if (!tool.readonly && !tool.sideEffectExempt) {
80
+ // Non-mutating side-effect tools (bash, git): do NOT invalidate the
81
+ // advisor review — a review is triggered by CODE MUTATIONS only
82
+ // (user decision 2026-08-08: the guard rule is "review after code
83
+ // changes", not "review after any environment change"; bash is
84
+ // barred from writing files, so it cannot change the reviewed code).
85
+ // Verify IS invalidated: its state snapshot (git diff, file list)
86
+ // may be stale after git/shell operations.
87
+ if (agent._verifiedThisRun) {
88
+ agent._verifiedThisRun = false
89
+ agent._verifyPassed = undefined
90
+ }
91
+ }
92
+ if (toolCall.name === "verify") agent._verifiedThisRun = true
93
+ if (toolCall.name === "advisor") {
94
+ agent._calledAdvisorThisRun = true
95
+ // All advisor calls (code and design) share the 5-round convergence
96
+ // budget — each advances _advisorRound toward MAX_ADVISOR_ROUNDS.
97
+ // Always advance the round — the convergence protocol cares about
98
+ // how many reviews have run (round 1→2→3→4→5), not how many succeeded.
99
+ // A failed/interrupted review is still a review attempt and should use
100
+ // the next round's prompt on retry.
101
+ agent._advisorRound++
102
+ }
103
+ if (FILE_MUTATORS.has(toolCall.name)) {
104
+ const args = JSON.parse(toolCall.arguments)
105
+ const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
106
+ for (const p of paths) {
107
+ const abs = join(agent.cwd, p)
108
+ if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
109
+ if (agent.memory) {
110
+ // Fire-and-forget: don't block the agent loop on indexing.
111
+ // Reuses a single cached import; errors surface as pending reminders on next turn.
112
+ if (!_reindexFile) {
113
+ const mod = await import("../memory.mjs")
114
+ _reindexFile = mod.reindexFile
115
+ }
116
+ _reindexFile(agent.memory, agent.cwd, abs).catch((e) => {
117
+ agent._pendingReminders.push(`[System reminder: background indexing failed for ${toolCall.name} on ${abs}: ${e.message}. This does not affect your work — the code index will catch up on next reindex.]`)
118
+ })
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ // All tool results committed — now safe to inject deferred multimodal user messages
126
+ for (const { real, msg } of deferredUserMsgs) {
127
+ if (real) pushReal(agent, msg)
128
+ else agent.history.push(msg)
129
+ }
130
+ }
@@ -13,13 +13,12 @@ import { loadSkills, formatSkillListing } from "../skills.mjs"
13
13
  import {
14
14
  escapeXml, repairHistory, listWorkDir, readonlyToolNames,
15
15
  collectGitContext, loadProjectInstructions, OUTLINE_INJECT_PREFIX,
16
- DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
16
+ DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS, ensureAutoReminder,
17
17
  } from "./helpers.mjs"
18
18
  import { readFileSync, existsSync } from "node:fs"
19
19
  import { resolve, dirname } from "node:path"
20
20
  import { fileURLToPath } from "node:url"
21
21
 
22
- const AUTO_REMINDER = "[System reminder: AUTO mode is active — all tool calls are automatically approved without asking.]"
23
22
  const DEFAULT_COMPACT_THRESHOLD = 100_000
24
23
  const DOC_SEARCH_LIMIT = 5
25
24
  const DOC_CHUNK_PREVIEW_LEN = 300
@@ -154,7 +153,7 @@ export async function prepareRun(agent, input, callbacks, {
154
153
  // Tail position keeps the second-precision content out of any prefix — caches stay hit.
155
154
  agent.history.push({
156
155
  role: "user",
157
- content: `[System reminder: current time is ${timeNowLocal()} (local; timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone || "local"}).`,
156
+ content: `[System reminder: current time is ${timeNowLocal()} (local; timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone || "local"})].`,
158
157
  transient: true,
159
158
  })
160
159
 
@@ -252,12 +251,12 @@ export async function prepareRun(agent, input, callbacks, {
252
251
  warnings.push(`Engineering template (${agent._role === "eng-coder" ? "engineering-sub.md" : "engineering.md"}) not found — using degraded constraints.`)
253
252
  }
254
253
  if (engResult.methodologyMissing) {
255
- warnings.push("METHODOLOGY.md not found — project-specific rules are absent.")
254
+ warnings.push("METHODOLOGY.md not found in the project root no project methodology is loaded, so every 'per METHODOLOGY' reference in the engineering prompt is dangling and the three-document hard flow (requirements / design / test doc) is NOT enforced. Ask the user whether to create METHODOLOGY.md (scaffold available as src/prompts/methodology-template.md) before designing.")
256
255
  }
257
256
  if (warnings.length > 0) {
258
257
  agent.history.push({
259
258
  role: "user",
260
- content: `[System reminder: ENGINEERING MODE is active but ${warnings.join(" ")} Create METHODOLOGY.md and ensure prompt templates exist for full enforcement, or disable engineering mode (/eng).]`,
259
+ content: `[System reminder: ENGINEERING MODE is active but ${warnings.join(" ")}]`,
261
260
  })
262
261
  }
263
262
  }
@@ -285,9 +284,7 @@ export async function prepareRun(agent, input, callbacks, {
285
284
  if (listing) systemPrompt += `\n\n${listing}`
286
285
  }
287
286
 
288
- if (agent.autoApprove && !agent.history.some((m) => m.content === AUTO_REMINDER)) {
289
- agent.history.push({ role: "user", content: AUTO_REMINDER })
290
- }
287
+ ensureAutoReminder(agent)
291
288
 
292
289
  return { maxTurns, threshold, tools, toolSchemas, toolByName, systemPrompt }
293
290
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * spawn-child.mjs — 生成侧统一子代理管线(AGENT-LOOP.md §7.2 D3)。
3
+ *
4
+ * 收编 subagent/escalate/consult 三份机械同构的重复实现:
5
+ * - makeRelay: _subAgentCounter + relayPrefix 生成 + `[model]` 元数据 token 发送
6
+ * - wrapChildCallbacks: onToken/onReasoning/onToolCall/onToolOutput 前缀包装
7
+ * (onToolOutput 复用 dispatch.mjs 的 onOutput 管线,D1;
8
+ * LLM 文本 token strip ⟦ev⟧ 哨兵——防伪造,D7)
9
+ * - runWithContinue: turn-cap ContinueError 循环骨架(askContinue 差异点经参数注入)
10
+ * - ensureChildApiKey: provider.apiKey trim/检查(apiKey 检查 ×3 合并)
11
+ * - clampEffort: effort 枚举钳制(enum 外丢弃——行为与 escalate/consult 现状逐字一致)
12
+ *
13
+ * 不收编(防过度抽象,留在各工具内):角色过滤/overlay/git 注入、subagent 专属
14
+ * (报告契约 MIN_REPORT_CHARS、mergeChildMutations)、escalate 专属(touched-files、
15
+ * effortNote)、consult 专属(session 队列、watchdog、main_history 工具)。
16
+ */
17
+ import { specForModel } from "../config.mjs"
18
+ import { ContinueError } from "../agent.mjs"
19
+
20
+ /** 事件 token 哨兵串(D1)——LLM 正常内容混淆概率极低;字段分隔用 RS (\x1e)。 */
21
+ export const EVENT_SENTINEL = "⟦ev⟧"
22
+ const RS = "\x1e"
23
+
24
+ /** turn-cap 降级文案的公共锚点:subagent/escalate 的 onDeclined 文案必含此子串,
25
+ * 消费端(TUI tool-events onToolResult)用 includes() 检测"工作可能不完整"语义。
26
+ * 单源化(2026-08-30 评审):文案演进只改这里,消除文案与检测正则的漂移面。 */
27
+ export const TURN_CAP_MARK = "stopped: turn cap reached"
28
+
29
+ /**
30
+ * 构造 relay 前缀 + 发送 `[model]` 元数据 token(显示层据此更新区块头部,
31
+ * 不进内容流)。counter 挂在 parent agent 上,多轮/并行子代理互不冲突。
32
+ * @returns {string} relayPrefix,形如 "coder#3/"
33
+ */
34
+ export function makeRelay(parent, label, emit, model) {
35
+ parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
36
+ const relayPrefix = `${label}#${parent._subAgentCounter}/`
37
+ emit?.(relayPrefix + "[model]" + (model ?? ""))
38
+ return relayPrefix
39
+ }
40
+
41
+ /**
42
+ * strip ⟦ev⟧ 哨兵序列:子 agent 的 LLM 文本 token 若以事件哨兵开头,剥除之
43
+ * (模型伪造事件 token 的风险面收窄到"仅生成侧发出口",D7)。
44
+ * **良构事件放行**(事件与内容共用 onToken 通道的必然结果):真正由 runAgent
45
+ * depth>0 / dispatch 发出的 turn/approval 事件 token(形如
46
+ * `⟦ev⟧turn\x1e{n}\x1e{max}\x1e{phase}\x1e{detail}`)必须原样通过——只有非良构的
47
+ * `⟦ev⟧…`(模型伪造)才剥为普通文本。
48
+ * 已知限制(round2 #7):单 chunk 匹配,哨兵串切在 chunk 边界(⟦e + v⟧)漏剥——
49
+ * 残危害由消费端 sanitizeDisplay 兜底,外观级;不引入跨 chunk carry-over 缓冲。
50
+ */
51
+ // Single source for the event grammar branch lists (consult P3, 2026-08-30):
52
+ // stripEventToken (display) and stripEventTokensForCapture (capture) shared them
53
+ // literally — extending the event set meant touching both regexes.
54
+ const EVENT_PHASE = "turn|approval"
55
+ const EVENT_TYPE = "llm|tool|approval|done"
56
+ const WELL_FORMED_EVENT = new RegExp(`^${EVENT_SENTINEL}(${EVENT_PHASE})${RS}[^${RS}]*${RS}[^${RS}]*${RS}(${EVENT_TYPE})${RS}`)
57
+ export function stripEventToken(text) {
58
+ if (!text.startsWith(EVENT_SENTINEL)) return text
59
+ if (WELL_FORMED_EVENT.test(text)) return text // 真事件(生成侧发出),放行
60
+ return text.slice(EVENT_SENTINEL.length) // 模型伪造的哨兵串 → 剥为普通文本
61
+ }
62
+
63
+ /**
64
+ * Review #4: 事件 token 整体剥除——用于捕获进父 LLM 历史的 output(partial
65
+ * 输出/空报告 fallback)。与 stripEventToken(显示路径,放行真事件供头部消费)
66
+ * 不同:partial 输出不需要事件语义,RS 控制字符对父 LLM 是纯噪音。
67
+ * detail 字段可选(turn 事件 detail 为空且带收尾 RS;approval 事件 detail 为
68
+ * 工具名、无收尾 RS)——round2 复核缺口修复:此前正则要求收尾 RS,approval 的
69
+ * detail 会作为裸工具名残留在 partial 输出里。
70
+ */
71
+ export function stripEventTokensForCapture(text) {
72
+ if (!String(text).includes(EVENT_SENTINEL)) return text
73
+ return String(text).replace(new RegExp(`${EVENT_SENTINEL}(${EVENT_PHASE})${RS}[^${RS}]*${RS}[^${RS}]*${RS}(${EVENT_TYPE})(?:${RS}[^${RS}]*)?`, "g"), "")
74
+ }
75
+
76
+ /**
77
+ * 子 agent 回调前缀包装:`role#id/` 前缀转发给父回调。
78
+ * - onToken 先剥伪造的 ⟦ev⟧ 哨兵(D7),再带前缀转发。
79
+ * - onToolOutput 带**已加前缀**的 name 走父 onToolOutput(name 形如
80
+ * "coder#1/bash",消费端剥前缀路由进对应区块;chunk 对象/裸串原样透传)。
81
+ * 父回调缺省时不包装(headless 嵌入)。
82
+ */
83
+ export function wrapChildCallbacks(relayPrefix, parentCallbacks = {}) {
84
+ const wrapped = {
85
+ onToken: parentCallbacks.onToken
86
+ ? (t) => parentCallbacks.onToken(relayPrefix + stripEventToken(String(t)))
87
+ : null,
88
+ onReasoning: parentCallbacks.onReasoning
89
+ ? (t) => parentCallbacks.onReasoning(relayPrefix + t)
90
+ : null,
91
+ onToolCall: parentCallbacks.onToolCall
92
+ ? (name, args) => parentCallbacks.onToolCall(relayPrefix + name, args)
93
+ : null,
94
+ onToolOutput: parentCallbacks.onToolOutput
95
+ ? (name, chunk) => parentCallbacks.onToolOutput(relayPrefix + name, chunk)
96
+ : null,
97
+ }
98
+ return wrapped
99
+ }
100
+
101
+ /**
102
+ * 子 agent provider API key 检查:trim 后非空才保留;缺失返回 null(调用方
103
+ * 按各自业务语汇报错——subagent 抛出 / escalate·consult 返回 Error 文本)。
104
+ */
105
+ export function ensureChildApiKey(provider) {
106
+ provider.apiKey = provider.apiKey?.trim() || null
107
+ return provider.apiKey ? provider : null
108
+ }
109
+
110
+ /**
111
+ * effort 枚举钳制:pool 配置的 effort 超出模型的 reasoningEffortEnum 时
112
+ * 丢弃(provider/core.mjs 会在每次 chat 抛错——候选未起飞先坠机)。enum 外
113
+ * 丢弃即"provider 预设默认也可能是该 override 模型的 enum 外值"的同款防御。
114
+ * @returns {boolean} true = effort 保留;false = effort 被 clamp 丢弃(调用方决定是否拼 effortNote)
115
+ */
116
+ export function clampEffort(provider, model, effort) {
117
+ if (!effort) return true
118
+ const enumList = specForModel(model).reasoningEffortEnum
119
+ if (enumList && !enumList.includes(effort)) {
120
+ delete provider.reasoningEffort
121
+ return false
122
+ }
123
+ provider.reasoningEffort = effort
124
+ return true
125
+ }
126
+
127
+ /**
128
+ * turn-cap continue 循环骨架(TURN-CAP-CONTINUE.md):ContinueError → 询问
129
+ * 继续(askContinue 回调,差异点注入)→ resume:true 重跑(保留 history 与
130
+ * mutation 记账、刷新 turn 预算);拒绝/headless → 按 onDeclined 降级返回。
131
+ * 非 ContinueError 错误原样抛出(dispatch.mjs 转 Error 工具结果——行为不变)。
132
+ *
133
+ * @param {Function} runner — async (child, input, callbacks, opts) => result
134
+ * @param {object} child — createAgent 返回的子 agent 对象
135
+ * @param {string} input — 任务文本
136
+ * @param {object} callbacks — 子 agent callbacks(含 onPermissionRequest 等)
137
+ * @param {object} runOpts — { depth, maxTurns, signal }(resume 由本函数管理)
138
+ * @param {object} hooks — { askContinue(err) => Promise<boolean>(必需),
139
+ * onDeclined(err, output) => string(拒绝降级)}
140
+ */
141
+ export async function runWithContinue(runner, child, input, callbacks, runOpts, { askContinue, onDeclined }) {
142
+ let output = ""
143
+ // Review #4 fix: strip sentinel/control chars from the capture — `output` feeds
144
+ // onDeclined's partial-output return, which lands in the PARENT LLM history where
145
+ // the display-layer sanitizeDisplay backstop does not apply.
146
+ const capture = callbacks?.onToken
147
+ ? (t) => { output += stripEventTokensForCapture(String(t)); child._capturedOutput = output; callbacks.onToken(t) }
148
+ : (t) => { output += stripEventTokensForCapture(String(t)); child._capturedOutput = output }
149
+ for (let resume = false; ; resume = true) {
150
+ try {
151
+ return await runner(child, input, { ...callbacks, onToken: capture }, { ...runOpts, resume })
152
+ } catch (e) {
153
+ if (!(e instanceof ContinueError)) throw e
154
+ const go = await askContinue(e)
155
+ if (go) continue
156
+ return onDeclined(e, output)
157
+ }
158
+ }
159
+ }