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.
- package/CHANGELOG.md +74 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +16 -3
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +110 -22
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +17 -3
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +90 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +282 -306
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -8
- package/src/tools/git.mjs +55 -177
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +28 -27
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +41 -2
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +40 -16
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +61 -16
- 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
|
|
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.
|
|
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": "
|
|
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
|
}
|
package/src/acp/bridge.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
},
|
package/src/advisor/messages.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
package/src/advisor/run.mjs
CHANGED
|
@@ -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,
|
|
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 &&
|
|
224
|
+
...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
|
|
225
225
|
? { reasoning_content: response.reasoning }
|
|
226
226
|
: {}),
|
|
227
227
|
})
|
package/src/agent/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ----
|
package/src/agent/helpers.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/agent/setup.mjs
CHANGED
|
@@ -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
|
|
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
|
|
162
|
+
// input): transient on the HUMAN line — dropped on persist; on the MACHINE line — kept
|
|
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
|
-
|
|
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
|
-
|
|
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) →
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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()
|