thincoder 0.12.54 → 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 (67) hide show
  1. package/CHANGELOG.md +57 -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/advisor/messages.mjs +4 -2
  6. package/src/advisor/run.mjs +2 -2
  7. package/src/agent/dispatch.mjs +66 -26
  8. package/src/agent/helpers.mjs +13 -2
  9. package/src/agent/setup.mjs +14 -2
  10. package/src/agent/spawn-child.mjs +3 -1
  11. package/src/agent-tools/advisor.mjs +19 -9
  12. package/src/agent-tools/eng.mjs +2 -0
  13. package/src/agent-tools/subagent-check.mjs +107 -0
  14. package/src/agent-tools/subagent.mjs +205 -42
  15. package/src/agent.mjs +68 -3
  16. package/src/cli/make-agent.mjs +25 -0
  17. package/src/cli/memory-command.mjs +28 -7
  18. package/src/config.mjs +120 -8
  19. package/src/context.mjs +28 -7
  20. package/src/escape.mjs +76 -23
  21. package/src/mcp/transport-http.mjs +13 -1
  22. package/src/mcp.mjs +52 -7
  23. package/src/memory/core.mjs +78 -10
  24. package/src/memory/docs.mjs +33 -7
  25. package/src/memory.mjs +1 -1
  26. package/src/model-specs.mjs +23 -0
  27. package/src/prompts/discipline.md +15 -1
  28. package/src/prompts/engineering.md +62 -5
  29. package/src/prompts/main.md +1 -0
  30. package/src/prompts/system.md +2 -1
  31. package/src/provider/anthropic.mjs +7 -5
  32. package/src/provider/core.mjs +48 -26
  33. package/src/provider/google.mjs +57 -24
  34. package/src/provider/normalize.mjs +1 -1
  35. package/src/provider/rate.mjs +0 -2
  36. package/src/provider/responses.mjs +8 -13
  37. package/src/provider/sse.mjs +20 -0
  38. package/src/session.mjs +15 -0
  39. package/src/tools/apply_patch.md +2 -0
  40. package/src/tools/bash.md +2 -2
  41. package/src/tools/edit-batch.mjs +104 -0
  42. package/src/tools/edit.md +3 -0
  43. package/src/tools/execute.md +4 -4
  44. package/src/tools/execute.mjs +14 -22
  45. package/src/tools/file.mjs +17 -55
  46. package/src/tools/file_ops.md +1 -1
  47. package/src/tools/git.md +1 -1
  48. package/src/tools/git.mjs +8 -16
  49. package/src/tools/lint.md +1 -1
  50. package/src/tools/linter.mjs +9 -37
  51. package/src/tools/patch.mjs +1 -1
  52. package/src/tools/shared.mjs +7 -20
  53. package/src/tui/agent-turn.mjs +3 -3
  54. package/src/tui/clipboard.mjs +2 -2
  55. package/src/tui/cmd-eng.mjs +1 -0
  56. package/src/tui/cmd-mcp-form.mjs +197 -0
  57. package/src/tui/cmd-mcp.mjs +255 -114
  58. package/src/tui/index.mjs +25 -5
  59. package/src/tui/interaction.mjs +28 -1
  60. package/src/tui/key-handler.mjs +14 -2
  61. package/src/tui/mouse.mjs +1 -1
  62. package/src/tui/pickers.mjs +62 -4
  63. package/src/tui/render-frame.mjs +18 -10
  64. package/src/tui/render.mjs +4 -4
  65. package/src/tui/startup.mjs +4 -2
  66. package/src/tui/subagent-blocks.mjs +119 -4
  67. package/src/tui/tool-events.mjs +60 -15
package/CHANGELOG.md CHANGED
@@ -1,3 +1,59 @@
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
+
1
57
  ## [0.12.54] — 2026-09-01
2
58
 
3
59
  ### Added
@@ -10,6 +66,7 @@
10
66
 
11
67
  - **跨端会话共享一致性(会诊 4 模型收敛)**:sessionStart 打点(跨端同槽不再 F2 互轮转);F2 写前磁盘校验(同会话并发追加 → 轮转 .bak 保留);legacy transient 双端过滤;contextHistory 机读线判定(length>0);activeModel 双向;cwd 先行校验;newSession 死主清理落盘(deletions)
12
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,测试同步断言
13
70
 
14
71
  ### Fixed
15
72
 
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.54",
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
  },
@@ -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
  })
@@ -169,6 +180,7 @@ export async function prepareRun(agent, input, callbacks, {
169
180
  // eng-coder subagents get advisor for mandatory design review before coding
170
181
  const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
171
182
  const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
183
+ const { subagentCheckTool } = await import("../agent-tools/subagent-check.mjs")
172
184
  const { escalateTool } = await import("../agent-tools/escalate.mjs")
173
185
  const { CONSULT_BASE } = await import("../agent.mjs")
174
186
  // withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
@@ -212,7 +224,7 @@ export async function prepareRun(agent, input, callbacks, {
212
224
  const consultTools = consultModels.length
213
225
  ? [withPool(consultStartTool), consultCheckTool, consultStopTool, ...(engineering ? [] : [withPool(escalateTool)])]
214
226
  : []
215
- 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]
216
228
  // Write-permission coder sub-agents (subagent role="coder" + escalate): the
217
229
  // system prompt names verify (system.md) and advisor (discipline.md) — without them an
218
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()
@@ -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
+ }