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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## [0.12.58] — 2026-09-02
2
+
3
+ ### Changed
4
+
5
+ - **开发体验三项(用户需求批 2026-09-02,两端)**:
6
+ ① **lint 零依赖化**(TOOLS.md §10.2):eslint 全套删除(devDependencies + eslint.config.mjs + 级联分支),lint = `node scripts/check-syntax.mjs`(node --check 遍历 src/test/bin/scripts 含自检,零依赖);CLI-LINT-REQUIREMENTS/TUNING.md 标记被取代;package-lock 再生(eslint 树移除)
7
+ ② **工具作用域限制全部移除**(§10.1):resolveInCwd 去边界断言(信任模型 + 权限门禁为唯一防线,与 bash 一致);git workdir / execute scriptFile / file_ops 目录限制一并移除;工具描述与提示词 "confined to workspace" 措辞清理(两端 byte-identical)
8
+ ③ **模型上下文长度可配置**(PROVIDER.md §15):`providers[].context`(K 单位,如 128 = 128K)覆盖 MODEL_SPECS——providerSpec 拷贝覆盖不污染共享 spec;压缩阈值(auto ×0.6)/ TOKEN 窗口 / 状态栏显示 / advisor 预算全链路跟随;数字字符串("128")两端统一归一;非法值忽略 + 警告一次;CLI /model 管理流 + VS Code settings.json 配置界面
9
+
10
+ ## [0.12.57] — 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **subagent 异步化:真后台并行**(AGENT-LOOP.md §15,两端):subagent 工具加 `async: true`——spawn 立即返回 `{id, status:"running"}`,主会话可继续自己的回合;新增 `subagent_check` 工具(arrival order 先完成先取 / 带 id 等待 / n 递增校验防循环 / readonly);**槽位队列**:并发上限 4,超限入队(position 可见),running 完成即腾槽补位(不拒绝、不分批);回合收尾自动等待全部完成并注入报告(XML 转义 + 超长预览落盘);Ctrl+C 清空不注入、ContinueError 状态保留;async 仅 depth-0、后台撞 turn-cap 自动拒绝继续
15
+ - **approval 批确认**(AGENT-LOOP.md §16.1,两端):同批多个非只读工具一次合并询问(approve all / one by one / deny;deny 全批拒绝无二次询问;无 handler 回退逐项;onPermissionRequest 签名不变)
16
+ - **批量形态引导**(§16.2,数据驱动——真实使用 94.6% 单条 edit / apply_patch 0 次 / 35 例手工批量):edit 描述强化 edits 数组原子批量、apply_patch 补多文件新建场景、system.md 并行条款扩展批量句(两端 byte-identical)
17
+
18
+ ### Changed
19
+
20
+ - 工程模式并发纪律上限 3 → 4(engineering.md + ENGINEERING-MODE.md FR8/决策③ 三处同步)
21
+ - VS Code 端同批对齐:escape v5 / UTF-16 安全截断 / 续写构造 / 压缩可见性(见 thincoder-vscode CHANGELOG 0.12.57)
22
+
23
+ ## [0.12.56] — 2026-09-02
24
+
25
+ ### Added
26
+
27
+ - **上下文压缩面板 + 压缩失败可见性**(CONTEXT-COMPACTION.md §7):压缩开始即弹"Compressing…"面板区块(复用子 agent 面板机制:耗时 ticker + summarizing N messages)→ 完成态 `Compressed: N tokens freed → summary (Xs)` 可折叠冻结;失败态显示错误文本,连续 3 次失败后 compressFallback 截断兜底并显示降级说明;摘要正文永不进面板/会话流;headless 回调缺省 no-op
28
+ - **DeepSeek prefix 续写 400 止损**(PROVIDER.md §14):续写请求精简历史(过滤 tool/assistant(tool_calls) 消息,保留 system + 最近 ≤8 条文本)——真机矩阵实证:thinking 模式 prefix 续写 + 工具链消息必 400(补不补 reasoning_content 分别报 Function call / reasoning_content 错误),纯文本历史 200;续写失败注入 `_warnings` 不再静默飞出;partial 模式不受影响
29
+ - **会话恢复 provider/model 缺失 → 模型重选**(SESSION.md §8):CLI 启动校验 provider/model/baseURL 缺失 → 不再崩溃退出;TUI 首帧弹模型选择(复用 picker),Esc 仍进 TUI + 提示行;headless 可读错误 + 退出码 1;判据仅空缺失(MODEL_SPECS 未知不判无效——自定义模型保护)
30
+ - **MCP save&test 确认问句废除**(MCP.md §5 变更段):探活成功直接保存(删 `Save? (Y/n)`);探活失败报错回表单且无任何保存通道(save-anyway 整个废除);取消仅剩表单 Esc
31
+ - **搜索工具优先级条款**(PROMPT-DECOUPLING.md):discipline.md + engineering.md 行为规则——有 MCP 搜索工具优先用 MCP、websearch 仅备用;websearch 连续 2 次垃圾即切;被墙站点走镜像路径;动手抓页面前先扫工具表(两端 prompts byte-identical)
32
+
33
+ ### Fixed
34
+
35
+ - **hex-escape 400 真凶根治(escape.mjs v5)**:2026-09-02 实锤——`unexpected end of hex escape` 400 的毒源**不是字面 hex 转义序列**,而是 **doc_search 预览 slice 按 UTF-16 码元截断切断了 emoji 代理对**(🔴 → 孤立高代理 D83D)→ deepseek 严格 UTF-16 解码 400。两层修复:① 发送前净化(sanitizeLoneSurrogates:孤立代理 → U+FFFD,全字段)+ hex 转义 odd-run 修复(v1-v4 的 double/替换方向全错,本版为对象层面正解);② 源头 UTF-16 安全截断(setup.mjs doc_search 预览 + helpers.mjs offloadToolResult 截断点落高代理时向前收一个码元)。验证:真实会话 953 条(含孤立代理)重放 400→200,带 thinking:enabled 6/6 全 200
36
+ - **MCP 磁盘无 mcp 段时 remove/edit 崩溃**(code review #1/#2):persistRaw 建段守卫(`raw.mcp ??=`)——磁盘 mcp 段被整体删除而连接保留(T23 场景)时,remove 不再 TypeError、edit 不再静默丢("updated" 提示与落盘一致)
37
+
38
+ ## [0.12.55] — 2026-09-01
39
+
40
+ ### Added
41
+
42
+ - **`/mcp` edit/add 统一字段 picker 表单(v2)+ agent 代配闭环**(docs/design/MCP.md §5,CLI-only):① **edit = 字段选择表单**——picker 列可编辑字段行(`HTTP URL https://…` / `Token d90c26bb…` 打码 / `Headers 2 items`;stdio `Command/Args/Env`;name 不可改无行)+ 末行 `✓ Save & test`;选中字段只输入该字段新值(提示 `(current: …)`——空=不变、`-`=删可选字段、`k=`=删 header/env 项、required 字段拒绝 `-`)→ 回 picker 循环连改多字段(中间 Esc 回 picker 不丢已改值);**废除逐字段预填重问**(改 token 不再被迫路过 URL/headers);② **add 复用同一表单**——空 entry 起、必填字段 `(required)` 标注、Save 校验必填非空(未满足提示并停留表单不落盘)、headers/env 不选即跳过、add 的 name 可编辑(重复名检查);③ **保存前预览 + 探活 + 字段级重试合一**——`showPreview`(token 遮蔽)→ `probeMcpServer` 零副作用探活(`✓ N tools, Xms` / `✗ 错误`)→ `Save? (Y/n)`;探活失败 → `Save anyway? (y/N)` 显式 y,否则**回同一字段表单**只重输失败字段复 probe(**独立 retry 路径废除**,不重启流程;save-anyway 显式 y 保留);④ **表单文件拆分**——新增 `src/tui/cmd-mcp-form.mjs`(fieldPicker 机制独立文件;`cmd-mcp.mjs` 499→382 行,脱离 500 硬限压线);⑤ **列表即菜单**——主菜单 = server 行(●/○ 连接态 + tool 数)+ `+ Add server` + `↻ Refresh` + 顶部 agent 代配提示行;选中行 → per-server 子菜单(Edit/Test/Reconnect/Remove),"先选操作再选 server"双弹层与 View list 废除;⑥ **磁盘重读**——`config.mjs` 新增 `reloadMcpFromDisk()`(菜单打开边界 + Refresh):磁盘→内存仅替换 mcp 段;畸形 config.json 回退内存态 + `⚠ disk config unreadable` 提示行;disk 删除/变更的已连接 server 连接不断 + 行尾 `⚠ disk changed` 对账标记(persistRaw 落盘后重读幂等防环);⑦ AI 生成降 transport picker 末位(生成的 entry 同走预览+探活确认环,失败回表单补齐/修正);`/mcp edit|test|remove|connect <name>` 直达参数路径零改动
43
+ - **MCP Streamable POST 误判修复 + `/mcp edit`/`/mcp test` + token 一等字段**(docs/design/MCP.md §4,两端落地):① `httpTransport` 增 postOnly 标记——GET SSE 405 降级后的纯 POST 模式 `isAlive()` 不再因 `eventSource == null` 误判死(glm-websearch "reconnect failed after 4 attempts" 根因:降级后 isAlive 恒 false → ensureAlive 触发无意义重连循环;legacy SSE 流断仍正常 fireDead 重连不回归);② `/mcp edit [name]`:逐字段预填重问(空输入保留 / `-` 删除可选字段 / `k=` 删除单个 header 项),persistRaw 原位替换保数组序,保存后自动重连(config 指纹含 token,变更自动关旧连接);③ `/mcp test [name]`:probeMcpServer 一次性探活(initialize + tools/list 计时 → `OK — N tools, Xms` / 错误透传),零副作用(不进 session 表、不动 agent.tools、探完即关);④ token 一等字段:config 增 `token` 字段,connect 链自动合成 `Authorization: Bearer <token>`(显式 headers 优先,不写回 config);⑤ parseHeaders 改逗号分隔(`Authorization=Bearer abc, X-Foo=bar`——修复空格截断把 Bearer token 截成 "Bearer" 的缺陷);⑥ VS Code 同构:http transport 同款修复 + probeMcpServer 镜像 + 面板 [Edit]/[Test] 按钮(同一表单编辑预填,token 字段 + 逗号分隔 headers 提示)+ **面板 [Reconnect] 死按钮修复**(webview 发的 `reconnectMcp` 消息在路由拆分时丢失 case,按钮此前无效)
44
+ - **memory_delete 工具**(跨端):三层记忆条目删除——personal 行级删(embedding/FTS 随行)、project/team 文件级删;scope 与 id 前缀匹配校验,非法 scope 明确报错
45
+
46
+ ### Changed
47
+
48
+ - **multi-design 并行令牌(designId slots)**:eng-coder 子 agent 支持 `{designId, token}` 多槽并行 spawn——各设计独立令牌互不覆盖,复审不通过的设计不挤占既有槽(CLI 与 VS Code 镜像)
49
+
50
+ ### Fixed
51
+
52
+ - **edit 数组形态同文件串行**:同一文件多条 edit 的 raw 域快照随条目推进,第二条不再漂移(编辑器 CRLF 路径 + 磁盘路径双修复)
53
+ - **MCP tools/list 分页超时约束**(MCP.md §4 评审 #8):每页同受 INIT_TIMEOUT_MS 约束——probe 延迟统计有界
54
+ - **MCP 握手失败 transport 泄漏**(评审 #7):GET SSE 降级成功但 POST initialize 失败时关闭 transport,不留悬挂流
55
+ - **hex-escape 毒载荷 400 根治(escape.mjs v3)**:v2 的 lookbehind 单字符判定与 hex 窗口越界缺陷在长会话(讨论转义主题)下漏中和 → deepseek 等网关二次解析报 "unexpected end of hex escape";v3 数反斜杠 run 奇偶 + 窗口越界修复(`\\x/\\u` 相邻双写)+ 孤立代理对(`\\uD83D` 无配对 strict JSON 解析拒绝)预 double;真实会话 74 处毒点全量中和为 0,11 个 case 锁定(含 v1 行为兼容回归)
56
+
57
+ ## [0.12.54] — 2026-09-01
58
+
59
+ ### Added
60
+
61
+ - **Checkpoint 事故恢复闭环(CLI ↔ VS Code 两端)**:git 工具破坏性操作(checkout -- / restore / reset --hard / clean / rebase)前自动快照 + schema 描述含 rewind 恢复指引;`checkpointAction=list` 输出尾部提示行;**commit 后清空该项目 checkpoint**(commit = 安全点;懒兜底覆盖外部 git/IDE commit);每 cwd 快照上限 100(最旧淘汰);git 工具补齐 11 个 action(clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv);`/restore` 改为两级 picker 逐文件恢复;bash guard 保留并对齐(宽匹配 + 全量副本 + rewind 指引,与 VS Code 同构)
62
+ - **TUI 子 agent 运行中面板固定化**:运行中子 agent 活动从会话流内联改为固定底部面板(会话与 todo 之间,不随会话滚动);完全自适应高度;默认折叠(头部 + tail 3,⏸ = 等待审批);完成仍冻结进会话流(✓ 头可展开)
63
+ - **TUI 渲染鲁棒性**:wrap-off 硬截断(Ambiguous 宽度字符防软折行污染);启动/退出序列抽取(tui-lifecycle.mjs)
64
+
65
+ ### Changed
66
+
67
+ - **跨端会话共享一致性(会诊 4 模型收敛)**:sessionStart 打点(跨端同槽不再 F2 互轮转);F2 写前磁盘校验(同会话并发追加 → 轮转 .bak 保留);legacy transient 双端过滤;contextHistory 机读线判定(length>0);activeModel 双向;cwd 先行校验;newSession 死主清理落盘(deletions)
68
+ - **checkpoint cwdHash 归一化**:`sha1(normalizeCwd(cwd)).slice(0,12)`——CLI/VS Code 快照跨端互通(存量旧路径孤儿化不迁移)
69
+ - **操作并行化纪律提示词条款**(2026-09-01 用户需求):system.md "How you work — while coding" 段在既有并行条款后追加 "Parallelize aggressively"——独立只读调用一次发起多个(执行器批并行)、多文件编辑用 `edits` 数组(原子一次往返)、独立子代理/独立子项目一次 spawn 多个(F7 触发条件:不共享待改文件 + 无交叉依赖 + 各自有独立测试);明确不并行边界(同一文件写入/依赖链/bash 审批命令 = 审批风暴/同仓库并发 git/有状态操作)与收益判断(大操作并行、<1s 微操作不并行)。两端 system.md byte-identical,测试同步断言
70
+
71
+ ### Fixed
72
+
73
+ - **hex-escape 毒载荷 400**(deepseek-v4-flash 实测):发送前统一中和字面 `\\x`/`\\u` 不足位序列(escape.mjs,VS Code 同构)
74
+
1
75
  ## [0.12.52] — 2026-08-31
2
76
  ## [0.12.53] — 2026-08-31
3
77
 
package/bin/thincoder.mjs CHANGED
@@ -17,7 +17,7 @@ import { join } from "node:path"
17
17
  import { runAgent } from "../src/agent.mjs"
18
18
  import { loadConfig, configPath } from "../src/config.mjs"
19
19
  import { createMemory, syncDir } from "../src/memory.mjs"
20
- import { assembleAgent, teamConfig, gitAuthor } from "../src/cli/make-agent.mjs"
20
+ import { assembleAgent, teamConfig, gitAuthor, validateProvider } from "../src/cli/make-agent.mjs"
21
21
  import { memoryCommand } from "../src/cli/memory-command.mjs"
22
22
  import { setupWizard } from "../src/cli/setup-wizard.mjs"
23
23
  import { summarize, askPermission } from "../src/cli/permission.mjs"
@@ -79,6 +79,13 @@ switch (command) {
79
79
  }
80
80
 
81
81
  const agent = await assembleAgent()
82
+ // SESSION.md §8 D-S4(F4):headless 无 TUI —— 可读错误 + 退出码 1,不弹 UI、不崩溃
83
+ if (agent._providerInvalid) {
84
+ const prov = agent.activeProvider || "(未设置)"
85
+ console.error(`[error] 未配置有效 provider(activeProvider "${prov}":${agent._providerInvalidReason})。请运行 thincoder 进入 TUI 重新选择,或编辑 ${configPath}`)
86
+ exitSoon(1)
87
+ break
88
+ }
82
89
  if (!agent.provider.apiKey) {
83
90
  if (!process.stdin.isTTY) {
84
91
  console.error(noKeyMessage())
@@ -219,6 +226,9 @@ switch (command) {
219
226
  case "tui":
220
227
  case undefined: {
221
228
  const agent = await assembleAgent()
229
+ // SESSION.md §8 D-S1:TUI 路径在 startTUI 前清空无效 provider——空 provider 不流入 runAgent
230
+ // (崩溃源:chat() 缺 model → 网关 400 或 fetch("undefined/...") TypeError)
231
+ if (agent._providerInvalid) agent.provider = null
222
232
  const config = loadConfig()
223
233
  // 恢复上次的会话(同一项目目录);provider 按保存的名字切回(用户上次可能换过模型)
224
234
  const { loadSession, applySession } = await import("../src/session.mjs")
@@ -226,11 +236,15 @@ switch (command) {
226
236
  if (restored) {
227
237
  const switched = applySession(agent, restored)
228
238
  if (switched && agent.config?.agent?.compactThresholdAuto) {
229
- // 压缩阈值跟模型走(与 TUI 切换 provider 时的处理一致)
239
+ // 压缩阈值跟模型走(与 TUI 切换 provider 时的处理一致);传 provider 对象——
240
+ // providers[].context 覆盖生效(PROVIDER.md §15 T-C2)
230
241
  const { resolveCompactThreshold } = await import("../src/config.mjs")
231
- agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider.model).value
242
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
232
243
  }
233
244
  }
245
+ // D-S3 优先级补全:applySession 可能已用会话中的有效 provider 修复(config 无效 + 会话有效)——
246
+ // 修复后复验清除标记,仅当两者都无效才弹重选(validateProvider 幂等)
247
+ if (agent._providerInvalid) validateProvider(agent)
234
248
  // MCP 连接失败在 TUI alt-buffer 下 stderr 不可见,注入为下一条 user 消息后的提醒
235
249
  if (agent._mcpWarnings?.length) {
236
250
  agent._pendingReminders = agent._pendingReminders ?? []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.53",
3
+ "version": "0.12.58",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -33,13 +33,9 @@
33
33
  "scripts": {
34
34
  "test": "node --test \"test/*.test.mjs\"",
35
35
  "prepublishOnly": "npm run lint && node --test \"test/*.mjs\"",
36
- "lint": "eslint src test",
36
+ "lint": "node scripts/check-syntax.mjs",
37
37
  "test:full": "node test/run-full.mjs"
38
38
  },
39
39
  "author": "liwei <liwei@51marine.com> (上海新舶)",
40
- "license": "MIT",
41
- "devDependencies": {
42
- "eslint": "^9.0.0",
43
- "@eslint/js": "^9.0.0"
44
- }
40
+ "license": "MIT"
45
41
  }
@@ -73,7 +73,7 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
73
73
  // D7 (AGENT-LOOP.md §7.2): strip ⟦ev⟧ event tokens (bare or prefixed variants) —
74
74
  // they carry RS control characters and are a TUI display signal; structured ACP
75
75
  // mapping (tool_call_update) is tracked separately in docs/TODO.md.
76
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
76
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
77
77
  if (/^(?:[\w-]+#\d+\/)?⟦ev⟧(?:turn|approval)\x1e/.test(text)) return
78
78
  update("agent_message_chunk", { content: { type: "text", text } })
79
79
  },
package/src/acp.mjs CHANGED
@@ -19,28 +19,15 @@ import { assembleAgent } from "./cli/make-agent.mjs"
19
19
  import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
20
20
  import { createAcpSession } from "./acp/session.mjs"
21
21
  import { replayHistory } from "./acp/bridge.mjs"
22
- import { listSlots, applySession, deleteSlot, sessionPath, normalizeCwd, isLegacyTransient } from "./session.mjs"
22
+ import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession } from "./session.mjs"
23
23
  import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
24
24
  import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
25
25
 
26
26
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
27
27
 
28
28
  /** Load a specific slot file (not the active one) — session/load by id.
29
- * Same validation as loadSession: version 1/2, cwd match, legacy-transient
30
- * filtering (pre-filtering slot files must not leak machine lines into replay). */
31
- function loadSlotFile(cwd, slot) {
32
- const path = `${sessionPath(cwd)}.${slot}`
33
- try {
34
- const data = JSON.parse(readFileSync(path, "utf8"))
35
- if (data?.version !== 1 && data?.version !== 2) return null
36
- if (!Array.isArray(data.history)) return null
37
- if (data.cwd && normalizeCwd(data.cwd).toLowerCase() !== normalizeCwd(cwd).toLowerCase()) return null
38
- data.history = data.history.filter((m) => !isLegacyTransient(m))
39
- return data
40
- } catch {
41
- return null
42
- }
43
- }
29
+ * 2026-08-31 会诊 deepseek 🟡:改用 session.mjs 共享 loadSlotFile(校验/保现场/.tmp
30
+ * 回退与主路径一致)——本地实现此前无 .unreadable/.corrupted 保留。 */
44
31
 
45
32
  /**
46
33
  * Apply a session-level config option to the agent instance (memory only —
@@ -160,6 +147,11 @@ export function buildAcpHandlers({
160
147
  const id = String(nextId++)
161
148
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
162
149
  // `id` is immutable after construction (baked into the callbacks) — never reassign.
150
+ // 2026-09-01 会诊 kimi/glm 🔴:立即认领独立槽(对齐 cmd-new)——否则首回合保存
151
+ // 走 _slot ??= activeSlot() → ensureActive 早退分支(slotSessions[active]===
152
+ // mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
153
+ // F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
154
+ session.agent._slot = newSession(getCwd())
163
155
  sessions.set(id, session)
164
156
  return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
165
157
  } catch (e) {
@@ -185,6 +177,7 @@ export function buildAcpHandlers({
185
177
  },
186
178
 
187
179
  "session/cancel": (params) => {
180
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
188
181
  const found = findSession(params)
189
182
  if (found.error) return found
190
183
  found.session.cancel()
@@ -192,6 +185,7 @@ export function buildAcpHandlers({
192
185
  },
193
186
 
194
187
  "session/close": (params) => {
188
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
195
189
  const found = findSession(params)
196
190
  if (!found.error) {
197
191
  // Abort any in-flight turn first — the client is gone, the agent must
@@ -233,11 +227,33 @@ export function buildAcpHandlers({
233
227
  const id = String(nextId++)
234
228
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
235
229
  applySession(session.agent, data)
230
+ // 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
231
+ // IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
232
+ // 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
233
+ // 到新槽(与 switchToSlot 的"占用则 fork"语义对齐)。
234
+ const occ = slotOccupancy(getCwd(), slot)
235
+ // 2026-09-01 会诊 kimi/glm 🔴:同进程双会话同槽——slotOccupancy 排除本进程属主后,
236
+ // 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
237
+ // (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
238
+ const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
239
+ if (!occ.occupied && !sameProcessPinned) {
240
+ const m = loadManifest(getCwd())
241
+ m.slotSessions ??= {}
242
+ m.slotSessions[slot] = getSessionId()
243
+ saveManifest(getCwd(), m)
244
+ session.agent._slot = slot
245
+ } else {
246
+ // 2026-09-01 advisor 🔴:占用时显式分配全新槽(fork)——原 `_slot = null` 的
247
+ // fork 会经 saveSession → activeSlot → ensureActive 分支1 早退(slotSessions
248
+ // [active] === mySessionId 同进程恒真)落回同进程 active 槽 → 两会话写同一槽
249
+ // 静默互覆盖。newSession 跳过活认领号/现存文件号,必定落到新槽。
250
+ session.agent._slot = newSession(getCwd())
251
+ }
236
252
  sessions.set(id, session)
237
253
  // Replay the human line (role → chunk mapping, design §4.5) so the
238
254
  // client renders the restored conversation.
239
255
  replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
240
- log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)`)
256
+ log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
241
257
  return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
242
258
  } catch (e) {
243
259
  return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
@@ -258,9 +274,25 @@ export function buildAcpHandlers({
258
274
  const id = String(nextId++)
259
275
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
260
276
  applySession(session.agent, data)
277
+ // 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
278
+ // 下次保存 fork 新槽);空闲则认领后钉回。
279
+ const occ = slotOccupancy(getCwd(), slot)
280
+ // 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
281
+ const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
282
+ if (!occ.occupied && !sameProcessPinned) {
283
+ const m = loadManifest(getCwd())
284
+ m.slotSessions ??= {}
285
+ m.slotSessions[slot] = getSessionId()
286
+ saveManifest(getCwd(), m)
287
+ session.agent._slot = slot
288
+ } else {
289
+ // 2026-09-01 advisor 🔴:同 load——显式 newSession fork(_slot=null 的 fork 会
290
+ // 落回同进程 active 槽 → 两会话写同一槽静默互覆盖)
291
+ session.agent._slot = newSession(getCwd())
292
+ }
261
293
  sessions.set(id, session)
262
294
  // resume: no history replay — the client keeps its own rendering.
263
- log(`session ${slot} resumed as session ${id} (no replay)`)
295
+ log(`session ${slot} resumed as session ${id} (no replay)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
264
296
  return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
265
297
  } catch (e) {
266
298
  return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
@@ -278,6 +310,16 @@ export function buildAcpHandlers({
278
310
  if (!deleteSlot(getCwd(), slot)) {
279
311
  return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
280
312
  }
313
+ // 2026-08-31 会诊 deepseek 🟡:被删槽的在存会话 _slot 仍钉着 → 下次保存会重建
314
+ // 文件并重注册(删后复活)。清空其 _slot,下次保存重新认领新槽。
315
+ // 2026-09-01 advisor round2 🔴:不能清 _slot 等下次保存——saveSession 走
316
+ // activeSlot → ensureActive 分支1 早退(slotSessions[active] === mySessionId 同
317
+ // 进程恒真)→ 落回同进程 active 槽(另一在存会话的槽)→ 两会话写同一槽静默
318
+ // 互覆盖(sessionStart 均 null → F2 永不轮转)。与 load/resume 同型修复:
319
+ // 立即 newSession 钉全新槽(跳过活认领号/现存文件号)。
320
+ for (const s of sessions.values()) {
321
+ if (s.agent?._slot === slot) s.agent._slot = newSession(getCwd())
322
+ }
281
323
  log(`session ${slot} archive deleted`)
282
324
  return {}
283
325
  },
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { readFileSync, existsSync } from "node:fs"
7
7
  import { resolve, join, relative, dirname, sep } from "node:path"
8
- import { specForModel } from "../config.mjs"
8
+ import { providerSpec } from "../config.mjs"
9
9
  import { findReviewRepos, collectRepoSnapshots, collectChangedFiles } from "./repos.mjs"
10
10
  import { buildConvergenceBody, buildConvergenceInstructions } from "./convergence.mjs"
11
11
  import { loadAdvisorMd, extractConversationBackground, extractAgentResponseTable } from "./history.mjs"
@@ -90,7 +90,9 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
90
90
  }
91
91
  // readFileSync succeeded — compute the budget OUTSIDE the try so a spec
92
92
  // lookup failure can never masquerade as "no AGENTS.md".
93
- const ctx = specForModel(agent.provider?.model ?? "").context
93
+ // providerSpec: the project-guide budget follows the provider-level context
94
+ // override (PROVIDER.md §15 — advisor messages budget is context-based).
95
+ const ctx = providerSpec(agent.provider).context
94
96
  const cap = Math.max(PROJECT_GUIDE_MIN, Math.floor(ctx * PROJECT_GUIDE_FRACTION))
95
97
  const shown = text.length <= cap
96
98
  ? text
@@ -3,7 +3,7 @@
3
3
  * Message building lives in advisor.mjs.
4
4
  */
5
5
  import { chat } from "../provider/core.mjs"
6
- import { findProvider, specForModel } from "../config.mjs"
6
+ import { findProvider, providerSpec } from "../config.mjs"
7
7
  import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
9
  import { appendCitationReport } from "./citations.mjs"
@@ -221,7 +221,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
221
221
  id: tc.id, type: "function",
222
222
  function: { name: tc.name, arguments: tc.arguments },
223
223
  })),
224
- ...(response.reasoning && specForModel(provider.model).reasoningEcho === "required"
224
+ ...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
225
225
  ? { reasoning_content: response.reasoning }
226
226
  : {}),
227
227
  })
@@ -46,7 +46,12 @@ function logToolError(toolName, args, error) {
46
46
  */
47
47
  export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0, signal) {
48
48
  // ---- Phase 1: serial preparation ----
49
+ // Pre-gates run per tool (parse/planMode/engineering gates); non-readonly tools
50
+ // that REACH the permission stage are collected into one batch — a single merged
51
+ // ask covers the whole toolCalls array (§16 D-B1, "approve all / one by one /
52
+ // deny"). Tools stopped by a pre-gate never join the batch (review #7).
49
53
  const prepared = []
54
+ const permPending = [] // { toolCall, tool, args } — reached the permission stage
50
55
  for (const toolCall of toolCalls) {
51
56
  const tool = toolByName.get(toolCall.name)
52
57
  let args
@@ -106,39 +111,74 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
106
111
  }
107
112
  }
108
113
 
109
- if (!tool.readonly) {
110
- // autoApprove short-circuit: skip prompt when agent is already marked for auto-approval
111
- const allowed = agent.autoApprove
112
- ? true
113
- : callbacks.onPermissionRequest
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
- })()
124
- : false
125
- if (!allowed) {
126
- prepared.push({ toolCall, tool, denied: true, reason: callbacks.onPermissionRequest ? "denied by user" : "no permission handler" })
114
+ // Readonly tools (and autoApprove — the short-circuit, unchanged for the
115
+ // whole batch too) skip the permission stage entirely.
116
+ if (tool.readonly || agent.autoApprove) {
117
+ if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
118
+ prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
127
119
  continue
128
120
  }
129
- }
130
-
131
- // PreToolUse hooks: allow user scripts to gate tool execution
132
- if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
133
- prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
121
+ // Panel area abolished — all tools now stream inline via onToolOutput.
122
+ callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
123
+ prepared.push({ toolCall, tool, args })
134
124
  continue
135
125
  }
126
+ permPending.push({ toolCall, tool, args })
127
+ }
136
128
 
137
- // Panel area abolished all tools now stream inline via onToolOutput.
129
+ // ---- Permission stage: one merged ask for the whole batch (§16 D-B1) ----
130
+ // >1 non-readonly tools in the same toolCalls array → a single
131
+ // onBatchPermissionRequest({ tools, count }) ask; verdicts:
132
+ // "approveAll" → batch-scope allowance (autoApprove style, NOT persistent)
133
+ // "deny" → the whole batch is rejected, no second ask
134
+ // "oneByOne" (or anything else / no handler) → the existing per-item
135
+ // onPermissionRequest channel, signature unchanged (NF-B1: ACP bridge /
136
+ // headless / old versions without the new callback are never harmed).
137
+ if (permPending.length > 0) {
138
+ let batchAllowed = null // true = approveAll, false = deny, null = per-item fallback
139
+ if (permPending.length > 1 && callbacks.onBatchPermissionRequest) {
140
+ const verdict = await callbacks.onBatchPermissionRequest({
141
+ tools: permPending.map((p) => ({ name: p.toolCall.name, args: p.args })),
142
+ count: permPending.length,
143
+ })
144
+ if (verdict === "approveAll") batchAllowed = true
145
+ else if (verdict === "deny") batchAllowed = false
146
+ // anything else (oneByOne/unknown) → fall through to the per-item channel
147
+ }
148
+ for (const p of permPending) {
149
+ let allowed
150
+ if (batchAllowed === true) allowed = true
151
+ else if (batchAllowed === false) allowed = false
152
+ else if (callbacks.onPermissionRequest) {
153
+ allowed = await (async () => {
154
+ // D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
155
+ // subagent block header flips to "等待审批" so a waiting child is visibly
156
+ // different from a stalled one. Depth>0 only (the parent TUI shows its own
157
+ // permission panel). turn n/max = the child's live turn counters.
158
+ if (depth > 0) {
159
+ callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(p.toolCall.name).slice(0, 40)}`)
160
+ }
161
+ return await callbacks.onPermissionRequest(p.toolCall.name, p.args)
162
+ })()
163
+ } else allowed = false
164
+ if (!allowed) {
165
+ prepared.push({
166
+ toolCall: p.toolCall, tool: p.tool, denied: true,
167
+ reason: (callbacks.onPermissionRequest || batchAllowed === false) ? "denied by user" : "no permission handler",
168
+ })
169
+ continue
170
+ }
138
171
 
139
- callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
172
+ // PreToolUse hooks: allow user scripts to gate tool execution
173
+ if (!(await runHooks("PreToolUse", { agent, toolName: p.toolCall.name, toolArgs: p.args }))) {
174
+ prepared.push({ toolCall: p.toolCall, tool: p.tool, denied: true, reason: "blocked by PreToolUse hook" })
175
+ continue
176
+ }
140
177
 
141
- prepared.push({ toolCall, tool, args })
178
+ // Panel area abolished — all tools now stream inline via onToolOutput.
179
+ callbacks.onToolCall?.(p.toolCall.name, p.args, p.toolCall.id)
180
+ prepared.push({ toolCall: p.toolCall, tool: p.tool, args: p.args })
181
+ }
142
182
  }
143
183
 
144
184
  // ---- Phase 2: order-preserving execution ----
@@ -35,6 +35,17 @@ export const REPORT_CONTINUATION =
35
35
  const TOOL_RESULT_OFFLOAD_LIMIT = 64 * 1024 // 65536 chars — offload only above 64K (2026-08-24)
36
36
  const TOOL_RESULT_PREVIEW = 64 * 1024 // chars shown inline when offloaded (aligns with CLI/VS Code webview)
37
37
 
38
+ /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
39
+ * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
40
+ * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。
41
+ * 与 setup.mjs 的 safeSliceUTF16 同语义(两处独立实现——escape.mjs 的 sanitizeLoneSurrogates 是发送兜底,此处是源头)。 */
42
+ function safeSliceUTF16(text, max) {
43
+ if (text.length <= max) return text
44
+ const cp = text.charCodeAt(max - 1)
45
+ if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, max - 1)
46
+ return text.slice(0, max)
47
+ }
48
+
38
49
  /** Offload-dir write-time self-cleanup retention window (2026-08-21): files older than 3 days are deleted on the next offload. */
39
50
  export const TMP_RETENTION_MS = 3 * 24 * 3600 * 1000
40
51
 
@@ -87,12 +98,12 @@ export async function offloadToolResult(text, callId, dir = join(configDir, "too
87
98
  const file = join(dir, `${Date.now()}-${String(callId).replace(/[^a-zA-Z0-9_-]/g, "_")}.log`)
88
99
  await writeFile(file, text, "utf8")
89
100
  return (
90
- text.slice(0, TOOL_RESULT_PREVIEW) +
101
+ safeSliceUTF16(text, TOOL_RESULT_PREVIEW) +
91
102
  `\n\n[... output too large (${text.length} chars total), full content saved to: ${file}\n` +
92
103
  `Page through it with the read tool (offset/limit) or sed -n 'START,ENDp' — do NOT re-run the tool blindly.]`
93
104
  )
94
105
  } catch {
95
- return text.slice(0, TOOL_RESULT_OFFLOAD_LIMIT) + `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
106
+ return safeSliceUTF16(text, TOOL_RESULT_OFFLOAD_LIMIT) + `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
96
107
  }
97
108
  }
98
109
 
@@ -22,6 +22,17 @@ import { fileURLToPath } from "node:url"
22
22
  const DEFAULT_COMPACT_THRESHOLD = 100_000
23
23
  const DOC_SEARCH_LIMIT = 5
24
24
  const DOC_CHUNK_PREVIEW_LEN = 300
25
+ /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
26
+ * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
27
+ * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。 */
28
+ function safeSliceUTF16(text, max) {
29
+ if (text.length <= max) return text
30
+ const end = max
31
+ // 截断点恰在高代理(D800-DBFF)上 → 收到高代理之前(不带它)
32
+ const cp = text.charCodeAt(end - 1)
33
+ if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, end - 1)
34
+ return text.slice(0, end)
35
+ }
25
36
  const MEMORY_SEARCH_LIMIT = 3
26
37
 
27
38
  /** Build engineering-mode system prompt by reading METHODOLOGY.md and wrapping it in the engineering template */
@@ -114,7 +125,7 @@ export async function prepareRun(agent, input, callbacks, {
114
125
  role: "user",
115
126
  content:
116
127
  `[Relevant documentation${more}:\n` +
117
- docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(d.content.slice(0, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
128
+ docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(safeSliceUTF16(d.content, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
118
129
  "]",
119
130
  transient: true,
120
131
  })
@@ -148,7 +159,8 @@ export async function prepareRun(agent, input, callbacks, {
148
159
  pushReal(agent, { role: "user", content: input })
149
160
  }
150
161
  // Time grounding for EVERY agent depth AND every resume, pushed LAST (after the user
151
- // input): transient, dropped on persist, fresh at every run start including resumes
162
+ // input): transient on the HUMAN line — dropped on persist; on the MACHINE linekept
163
+ // (byte-identical resume for the provider prefix cache, 2026-08-16), fresh at every run start
152
164
  // (an interrupt-continuation must know NOW, not the pre-interrupt time; 2026-08-16).
153
165
  // Tail position keeps the second-precision content out of any prefix — caches stay hit.
154
166
  agent.history.push({
@@ -168,6 +180,7 @@ export async function prepareRun(agent, input, callbacks, {
168
180
  // eng-coder subagents get advisor for mandatory design review before coding
169
181
  const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
170
182
  const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
183
+ const { subagentCheckTool } = await import("../agent-tools/subagent-check.mjs")
171
184
  const { escalateTool } = await import("../agent-tools/escalate.mjs")
172
185
  const { CONSULT_BASE } = await import("../agent.mjs")
173
186
  // withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
@@ -211,7 +224,7 @@ export async function prepareRun(agent, input, callbacks, {
211
224
  const consultTools = consultModels.length
212
225
  ? [withPool(consultStartTool), consultCheckTool, consultStopTool, ...(engineering ? [] : [withPool(escalateTool)])]
213
226
  : []
214
- const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
227
+ const depthOnly = depth === 0 ? [filteredSubagent, subagentCheckTool, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
215
228
  // Write-permission coder sub-agents (subagent role="coder" + escalate): the
216
229
  // system prompt names verify (system.md) and advisor (discipline.md) — without them an
217
230
  // escalate hit "unknown tool" and fell back to bash node --check / npm test to
@@ -51,7 +51,9 @@ export function makeRelay(parent, label, emit, model) {
51
51
  // Single source for the event grammar branch lists (consult P3, 2026-08-30):
52
52
  // stripEventToken (display) and stripEventTokensForCapture (capture) shared them
53
53
  // literally — extending the event set meant touching both regexes.
54
- const EVENT_PHASE = "turn|approval"
54
+ // "done" = §15 D-A3 async-child completion event (emitted by the parent's
55
+ // turn-end collection, not by children — listed so the grammar stays honest).
56
+ const EVENT_PHASE = "turn|approval|done"
55
57
  const EVENT_TYPE = "llm|tool|approval|done"
56
58
  const WELL_FORMED_EVENT = new RegExp(`^${EVENT_SENTINEL}(${EVENT_PHASE})${RS}[^${RS}]*${RS}[^${RS}]*${RS}(${EVENT_TYPE})${RS}`)
57
59
  export function stripEventToken(text) {
@@ -130,7 +130,12 @@ export const advisorTool = {
130
130
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
131
131
  // The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
132
132
  // the gate is a mechanical string match, not fragile semantics parsing.
133
+ // A random designId is minted for EVERY design-review call (2026-09-01 multi-design
134
+ // slots): on pass the token is stored in parent._engDesignTokens keyed by this id and
135
+ // the id is echoed to the parent; on failure the id is dropped — never stored, so it
136
+ // cannot clobber any other design's slot. Not a document anchor (rejected 2026-08-31).
133
137
  const designToken = reviewType === "design" ? generateDesignToken(agent) : null
138
+ const designId = reviewType === "design" ? randomUUID() : null
134
139
  const result = await runAdvisorReview(agent, reviewType, {
135
140
  onOutput: ctx.onOutput,
136
141
  signal: ctx.signal,
@@ -144,6 +149,11 @@ export const advisorTool = {
144
149
  if (designToken && result && tokenPattern.test(result)) {
145
150
  // Advisor echoed the token → review passed. Issue it to the parent for eng-coder.
146
151
  // (session cleanup for design reviews is owned by runAdvisorReview)
152
+ // Multi-design slots (2026-09-01): store under this review's designId; the single
153
+ // `_engDesignToken` mirror stays for the legacy boolean gates (dispatch "has token",
154
+ // session persistence) — key decision ② of ENGINEERING-MODE.md §7 2026-09-01.
155
+ agent._engDesignTokens ??= new Map()
156
+ agent._engDesignTokens.set(designId, designToken)
147
157
  agent._engDesignToken = designToken
148
158
  // Unlock the dispatch design gate (dispatch.mjs) for eng-coder SELF-review:
149
159
  // an eng-coder whose own design review passed may write files without the
@@ -156,16 +166,16 @@ export const advisorTool = {
156
166
  if (agent._role === "eng-coder") agent._engDesignReviewed = true
157
167
  // Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
158
168
  const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
159
- return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
169
+ // designId rides the Approved block (review #1): the parent needs it to aim the FIRST
170
+ // eng-coder spawn when several designs live in the same session.
171
+ return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}\ndesignId: ${designId} (pass as the designId parameter when spawning eng-coder; optional while this session holds a single design)`
160
172
  }
161
- // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
162
- // Guards (v2 2026-08-25): result === null means the review was SKIPPED (advisor disabled /
163
- // not engineering mode) must not revoke. An error reply (own "Advisor:" prefix — the
164
- // error-return convention of runAdvisorReview) is a provider crash/timeout artifact, not
165
- // a completed verdict a network glitch must not revoke unrelated standing tokens.
166
- // Only a COMPLETED review that did not pass revokes.
167
- const isCompletedReview = result !== null && !result.startsWith("Advisor:")
168
- if (isCompletedReview) agent._engDesignToken = null
173
+ // Review failed (or advisor chose not to pass) → do NOT touch ANY slot (方案 ②, review #2:
174
+ // a failed RE-review leaves the previously approved token alive until TTL; the failed call's
175
+ // own designId was never stored, so there is nothing to clear). Isolation (2026-08-30,
176
+ // extended to the multi-slot Map 2026-09-01): a network glitch must not clear / other
177
+ // designs' slots must not be affected only a COMPLETED non-passing review lands here,
178
+ // and it revokes nothing.
169
179
  // Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
170
180
  if (result) {
171
181
  const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()