mocode-ai 0.7.3 → 1.0.2

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/README.md CHANGED
@@ -8,13 +8,67 @@ A terminal coding agent: give it a goal, and it **completes it autonomously**
8
8
 
9
9
  MoCode explores your code, reads/writes/edits files, runs shell commands, and searches the web on its own, driving the task forward through a loop of "think → call a tool → observe the result → think again." It works with any OpenAI-compatible endpoint (GLM, DeepSeek, Qwen, local Ollama / vLLM, etc.), runs as a full-screen TUI with streaming output and visible reasoning.
10
10
 
11
+ ## Architecture
12
+
13
+ MoCode is organized as a layered runtime: the terminal experience drives an autonomous core, the core reaches capabilities through a guarded execution plane, and a persistent intelligence layer keeps long-running work coherent.
14
+
15
+ <p align="center"><img src="./assets/architecture/system-overview.svg" alt="MoCode layered system architecture" width="100%"></p>
16
+
17
+ ### Autonomous execution loop
18
+
19
+ Each model response is one step in a closed loop. Tool calls are classified by declared capabilities, safe reads can run in parallel, writes acquire canonical resource locks, observations are encoded before returning to context, and code changes pass through automatic validation.
20
+
21
+ <p align="center"><img src="./assets/architecture/agent-loop.svg" alt="MoCode autonomous agent execution loop" width="100%"></p>
22
+
23
+ ### Context that ages instead of exploding
24
+
25
+ Tool output does not accumulate as an undifferentiated transcript. Typed encoders, relevance pruning, an observation lifecycle, age-aware compression, and a five-zone budget scheduler continuously reshape the active working set while sessions, snapshots, skills, notes, and memory retain durable knowledge.
26
+
27
+ <p align="center"><img src="./assets/architecture/context-engine.svg" alt="MoCode context engineering and durable memory architecture" width="100%"></p>
28
+
29
+ ### Multi-agent work without unsafe shared writes
30
+
31
+ Read-only sub-agents fan out concurrently. Writer agents work inside private filesystem overlays and return structured ChangeSets; the coordinator checks expected hashes, acquires canonical locks, performs conflict-safe merges, and runs one unified verification gate in the main workspace.
32
+
33
+ <p align="center"><img src="./assets/architecture/multi-agent.svg" alt="MoCode multi-agent overlay and ChangeSet coordination" width="100%"></p>
34
+
35
+ ### Controlled execution: permission gates and capability scheduling
36
+
37
+ Every mutating tool calls into a permission layer before it runs. Tools are classified `safe` / `confirm` / `dangerous`, scopes can be `once` / `session` / `project` / global-tool, fingerprints are stable hashes (command, path, or args), and the persistent record lives in `~/.mocode/permissions.json` (v3 schema, with v2 resource grants still loaded). Piped or CI environments default to deny until you opt in.
38
+
39
+ <p align="center"><img src="./assets/architecture/permission-model.svg" alt="MoCode permission model: tool classes, four-tier grants, fingerprinting, durable storage" width="100%"></p>
40
+
41
+ ### Verification cascade: cheap checks first, expensive checks only on demand
42
+
43
+ Code changes go through V0 (file post-conditions) → V1 (scoped tsc/eslint markers) → V2 (targeted unit tests) → V3 (affected package scripts). The first actionable failure stops the cascade and is fed back to the agent as a fresh observation; a SHA-256 content cache skips repeated work on unchanged files.
44
+
45
+ <p align="center"><img src="./assets/architecture/verification-cascade.svg" alt="MoCode verification cascade V0 to V3 with content fingerprint cache" width="100%"></p>
46
+
47
+ ### Rollback timeline: per-mutation snapshots, restore by turn
48
+
49
+ A clean undo point is saved before every mutating tool. `/rollback <turnId>` restores file buffers in reverse-chronological order under canonical resource locks, then reruns V0+V1 to confirm a clean state — never re-runs the model. Read tools, network effects, and binary changes are explicitly out of scope, kept honest in the contract.
50
+
51
+ <p align="center"><img src="./assets/architecture/rollback-flow.svg" alt="MoCode rollback timeline and per-turn snapshot flow" width="100%"></p>
52
+
53
+ ### Context controls: five independent dials, not one big toggle
54
+
55
+ `autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget` each gate a different knob (push-time compression, encoders, superseded-read pruning, observation lifecycle, five-zone scheduler). Each is independently killable via a `MOCODE_*=false` env var; the observation lifecycle runs even with all toggles off, so context still ages instead of exploding. An EWMA self-calibrates the token estimator against real provider usage.
56
+
57
+ <p align="center"><img src="./assets/architecture/context-controls.svg" alt="MoCode context controls: five independent toggles, observation lifecycle, token self-calibration" width="100%"></p>
58
+
59
+ ### Desktop pet: a passive mirror over WebSocket
60
+
61
+ The optional Electron sub-package (`packages/pet-app`) shows a stateful floating character that mirrors agent activity via a one-way WebSocket stream. Quit with `/pet quit`. The renderer owns no business logic; the agent loop is unchanged regardless of whether the pet is running.
62
+
63
+ <p align="center"><img src="./assets/architecture/pet-bridge.svg" alt="MoCode desktop pet bridge: hooks, frames, Electron client" width="100%"></p>
64
+
11
65
  ## Why MoCode
12
66
 
13
67
  MoCode isn't a chat box with a coat of paint — it's an agent that actually gets things done:
14
68
 
15
69
  - **Autonomous multi-step execution** — In a single conversation, the agent chains multiple steps on its own: read code, edit code, run tests, fix based on errors, and so on. It decides the next step without you nagging it. When it hits a decision point, it calls `ask_human` to pop up a panel and ask you (blocking until you respond).
16
70
  - **Parallel read-only tools** — Consecutive read-only operations in a turn (reading files, grep, glob, codegraph, web search/fetch) run concurrently, so total time is roughly the slowest single call instead of the sum of all of them. Operations with side effects (writing/editing files) stay sequential to preserve snapshot ordering and data safety.
17
- - **Sub-agents divide and conquer** — Complex tasks can spawn independent sub-agents, each with its own conversation history (isolated from the main thread), an optional restricted toolset, and a step cap. Sub-agent calls execute serially while they share the main workspace, preventing concurrent writes from racing; each returns only a summary to the main thread.
71
+ - **Sub-agents divide and conquer** — Complex tasks can spawn independent sub-agents with isolated histories and scoped toolsets. Read-only workers can fan out concurrently; writer workers run in private filesystem overlays and return ChangeSets that are merged under expected-hash checks and canonical resource locks. Only structured findings return to the main thread.
18
72
  - **Plan / Auto dual mode** — In `plan` mode the agent is read-only (reads code, queries indexes, searches — never writes to disk, runs commands, or spawns sub-agents) and produces a plan; `auto` mode unlocks the full toolset. The agent can switch between the two on its own — scope out an unfamiliar codebase first, then start making changes.
19
73
  - **Automatic context compression** — As the context window fills up, a three-tier compression kicks in (trim individual results → compact older tool results in place → summarize older turns), so long sessions never overflow. `/context` shows live token usage; `/compact` triggers manual compression (optionally with a focus hint to preserve what matters).
20
74
  - **Cross-session long-term memory** — The agent can save project architecture, conventions, and lessons learned as long-term memory, auto-loaded in future sessions. A background process periodically reflects on conversations to mine things worth remembering. Memories can be created, searched, updated, and forgotten, with recall-based decay.
@@ -112,8 +166,8 @@ Common backend `base_url` values:
112
166
  | `ANYSEARCH_BASE_URL` | Search API endpoint | `https://api.anysearch.com` |
113
167
  | `SKILLS_DIRS` | Override the default skill scan directories (platform path separator) | three default directories |
114
168
  | `MOCODE_CONTEXT_OPTIMIZE` | Typed encoding of tool results before they reach the LLM (tree/search/log…); disable for raw passthrough (length trimming only) | `true` |
115
- | `MAX_STEPS` | Max agent loop steps per turn (prevents infinite loops) | `200` |
116
- | `SUB_AGENT_MAX_STEPS` | Default step cap for sub-agents (spawned via the `task` tool) | `50` |
169
+ | `MAX_STEPS` | Max agent loop steps per turn (infinite-loop safety only) | `1000` |
170
+ | `SUB_AGENT_MAX_STEPS` | Sub-agent loop safety ceiling; defaults to the main-agent value | `1000` |
117
171
  | `SANDBOX_ROOT` | Sandbox root directory (file operation boundary; falls back to cwd if unset) | none |
118
172
  | `MOCODE_THEME` | Color theme (default/dark/light…; shell env takes precedence over file) | `default` |
119
173
 
@@ -149,7 +203,7 @@ The agent operates in **the working directory it was launched from** — to have
149
203
  | `ask_human` | Pop up a Q&A panel at decision points; user picks a preset or types freely (blocks until answered) |
150
204
  | `switch_mode` | Switch between `plan` (read-only planning) and `auto` (full execution); the agent can call this itself to explore before acting |
151
205
  | `drop_context` | Replace irrelevant old tool results in history with stubs to free up context (preserves tool_call_id pairing, leaves system prompt and current turn untouched, idempotent) |
152
- | `task` | Spawn a sub-agent for an independent subtask (isolated history, optional restricted toolset, optional step cap); calls run serially while sharing the workspace and return only a summary |
206
+ | `sub-agent` | Spawn a capable isolated worker; read tasks can run concurrently and writes use overlay + ChangeSet safe merge |
153
207
 
154
208
  | `memory_save` | Save a piece of cross-session long-term memory (title indexed, body fetched on demand) |
155
209
  | `memory_search` | Search memory bodies by keyword; hits boost the recall count (affects forgetting decay) |
package/README.zh-CN.md CHANGED
@@ -8,13 +8,67 @@
8
8
 
9
9
  mocode 自己探索代码、读写改文件、执行命令、联网查资料,以「思考 → 调用工具 → 观察结果 → 再思考」的循环一步步把任务推进到完成。接任意 OpenAI 兼容接口(GLM、DeepSeek、Qwen、本地 Ollama / vLLM 等),全屏 TUI 交互,流式输出、思考过程可见。
10
10
 
11
+ ## 架构
12
+
13
+ MoCode 是一个分层的自治运行时:终端交互层驱动 Agent 内核,内核通过受控能力平面执行真实操作,持久化认知层则让长任务和跨会话工作保持连贯。
14
+
15
+ <p align="center"><img src="./assets/architecture/system-overview-zh-CN.svg" alt="MoCode 分层系统架构" width="100%"></p>
16
+
17
+ ### 自治执行循环
18
+
19
+ 每次模型响应都是闭环中的一步。工具调用按能力声明分类,安全读取可以并行,写操作获取规范化资源锁,观察结果编码后才回到上下文,代码改动最终经过自动验证门。
20
+
21
+ <p align="center"><img src="./assets/architecture/agent-loop-zh-CN.svg" alt="MoCode 自治 Agent 执行循环" width="100%"></p>
22
+
23
+ ### 会衰减、不会膨胀的上下文
24
+
25
+ 工具输出不会作为无差别日志无限堆积。类型化编码、相关性裁剪、观察生命周期、年龄感知压缩和五区预算调度持续重塑活跃工作集;会话、Snapshot、Skill、notes.md 与长期记忆负责保留耐久知识。
26
+
27
+ <p align="center"><img src="./assets/architecture/context-engine-zh-CN.svg" alt="MoCode 上下文工程与持久化记忆架构" width="100%"></p>
28
+
29
+ ### 多 Agent 并行,但不冒险共享写入
30
+
31
+ 只读子 Agent 可以并行扇出;写任务在私有文件系统 overlay 中完成并返回结构化 ChangeSet。协调器校验 expected hash、获取规范化资源锁、安全合并冲突,最后由主工作区统一执行验证。
32
+
33
+ <p align="center"><img src="./assets/architecture/multi-agent-zh-CN.svg" alt="MoCode 多 Agent overlay 与 ChangeSet 协调" width="100%"></p>
34
+
35
+ ### 受控执行:权限门 + 能力调度
36
+
37
+ 每个写入工具在执行前都会经过权限层。工具分为 `safe` / `confirm` / `dangerous` 三档,授权粒度支持 `once` / `session` / `project` / 全局工具四档,指纹使用稳定哈希(命令、路径或参数),持久化记录落在 `~/.mocode/permissions.json`(v3 格式,会自动加载 v2 资源授权)。管道/CI 环境默认拒绝所有需确认的操作,除非显式开启。
38
+
39
+ <p align="center"><img src="./assets/architecture/permission-model-zh-CN.svg" alt="MoCode 权限模型:工具分级、四档授权、指纹、持久化" width="100%"></p>
40
+
41
+ ### 验证瀑布:便宜检查先做,贵检查按需上场
42
+
43
+ 代码改动按 V0(文件级后置条件)→ V1(限范围的 tsc/eslint)→ V2(定向单元测试)→ V3(受影响 package 的脚本)由低到高执行。首个可操作失败立即停止,作为新的观察反馈给 Agent;SHA-256 文件指纹缓存避免对未改动文件重复劳动。
44
+
45
+ <p align="center"><img src="./assets/architecture/verification-cascade-zh-CN.svg" alt="MoCode 自动验证瀑布 V0 到 V3,带文件指纹缓存" width="100%"></p>
46
+
47
+ ### 回滚时间线:每次写入都留干净撤销点
48
+
49
+ 每次写入工具执行前先存一份 undo 快照。`/rollback <turnId>` 按时间逆序在 canonical 资源锁下恢复文件缓冲,然后重跑 V0+V1 验证状态干净——完全不重跑模型。读取类工具、网络副作用、二进制改动明确不在截图范围,契约里写死。
50
+
51
+ <p align="center"><img src="./assets/architecture/rollback-flow-zh-CN.svg" alt="MoCode 回滚时间线和每轮快照流" width="100%"></p>
52
+
53
+ ### 上下文控制:五个独立开关,不是一锅端
54
+
55
+ `autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget` 各自把控一个旋钮(push 压缩、编码器、被取代读取的剪裁、观察生命周期、五区调度器)。每个都能用 `MOCODE_*=false` 单独关;即使五个全关,观察结果仍按生命周期老化。token 估算带 EWMA 自动校准真实 provider 用量。
56
+
57
+ <p align="center"><img src="./assets/architecture/context-controls-zh-CN.svg" alt="MoCode 上下文控制:五个独立开关、观察生命周期、token 自校准" width="100%"></p>
58
+
59
+ ### 桌宠:WebSocket 上的被动镜像
60
+
61
+ 可选 Electron 子包(`packages/pet-app`)用一个悬浮小角色镜像 agent 状态:单向 WebSocket 推送事件帧,`/pet quit` 完全关闭。渲染层零业务逻辑,无论桌宠是否在跑,主 agent 循环一字不改。
62
+
63
+ <p align="center"><img src="./assets/architecture/pet-bridge-zh-CN.svg" alt="MoCode 桌宠桥:hooks、事件帧、Electron 客户端" width="100%"></p>
64
+
11
65
  ## 为什么用 mocode
12
66
 
13
67
  mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
14
68
 
15
69
  - **自主多步推进** — 一次对话里连续多步:读代码、改代码、跑测试、根据报错再改……agent 自己决定下一步,中途不用你反复催。遇到卡点会调 `ask_human` 弹面板问你(阻塞到回应)。
16
70
  - **只读工具并行执行** — 一轮里连续的只读操作(读文件、grep、glob、codegraph、联网搜索/抓取)自动并发跑,总耗时 ≈ 最慢一个,而不是逐个排队。写文件 / 改文件这类有副作用的操作仍串行,保快照顺序与数据安全。
17
- - **子 agent 分而治之** — 复杂任务可派生独立子 agent:各自有自己的对话历史(不污染主线),可限定工具集和步数上限。共享主工作区期间多个 task 串行执行,避免并发写冲突;每个子任务最后只把摘要回灌主线。
71
+ - **子 agent 分而治之** — 复杂任务可派生拥有独立历史与受限工具集的子 agent。只读 worker 可并行扇出;写 worker 在私有文件系统 overlay 中运行,返回的 ChangeSet 经过 expected hash 校验与规范化资源锁后才合并。主线只接收结构化发现,不接收过程噪声。
18
72
  - **计划 / 执行双模式** — `plan` 模式下只读探查(读代码、查索引、搜索,绝不写盘、不跑命令、不派生子 agent),产出计划;`auto` 模式全量工具放开。agent 还能在两者间自切换——先把陌生代码库摸清,再动手改。
19
73
  - **上下文自动压缩** — 接近窗口上限时三层压缩(单条结果裁剪 → 旧工具结果原地微压缩 → 旧对话摘要),长会话也不爆窗口;`/context` 实时显示 token 用量,`/compact` 可手动压缩(能带焦点指令聚焦保留)。
20
74
  - **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
@@ -110,8 +164,8 @@ LLM_MODEL=glm-4.6 # 换成你的模型名
110
164
  | `ANYSEARCH_BASE_URL` | 搜索 API 端点 | `https://api.anysearch.com` |
111
165
  | `SKILLS_DIRS` | 覆盖默认 skill 扫描目录(平台分隔符) | 三目录自动扫描 |
112
166
  | `MOCODE_CONTEXT_OPTIMIZE` | 工具结果进 LLM 前的类型化编码(树/搜索/日志…),关掉则原样进(仅长度裁剪) | `true` |
113
- | `MAX_STEPS` | 每轮 agent 循环最大步数(防无限循环) | `200` |
114
- | `SUB_AGENT_MAX_STEPS` | 子 agent(task 工具派生)默认步数上限 | `50` |
167
+ | `MAX_STEPS` | 每轮 Agent 循环最大步数(仅防无限循环) | `1000` |
168
+ | `SUB_AGENT_MAX_STEPS` | 子 Agent 循环安全上限,默认与主 Agent 一致 | `1000` |
115
169
  | `SANDBOX_ROOT` | 沙箱根目录(文件操作边界;未配则用 cwd 兜底) | 无 |
116
170
  | `MOCODE_THEME` | 颜色主题(default/dark/light…;shell 设置优先于文件) | `default` |
117
171
 
@@ -147,7 +201,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
147
201
  | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
148
202
  | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
149
203
  | `drop_context` | 把历史里无关的旧工具结果替换为存根释放上下文(保 tool_call_id 配对,不动 system 与当前轮;幂等) |
150
- | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);共享工作区期间串行执行,只回摘要 |
204
+ | `sub-agent` | 派生具备完整能力的隔离子 Agent;只读任务可并发,写任务通过 overlay + ChangeSet 安全合并 |
151
205
 
152
206
  | `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
153
207
  | `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
@@ -13,7 +13,7 @@ import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constant
13
13
  import { getAgentMode, setAgentMode } from './mode.js';
14
14
  import { maybeCompact, contextState, dropContextFromHistory, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
15
15
  import { createBudgetScheduler } from '../session/scheduler.js';
16
- import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary } from '../context/index.js';
16
+ import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary, recordArtifact, invalidateArtifacts, rehydrateArtifacts, } from '../context/index.js';
17
17
  import { createAgeAwareEncodingState, } from '../context/age-aware.js';
18
18
  import { createRelevancePruner } from '../context/relevance.js';
19
19
  import { isToolResultSuccess } from '../context/utils.js';
@@ -51,7 +51,7 @@ function parseArgs(raw) {
51
51
  * (直接拼,不哈希——避免热路径开销;args 长度本身有限,内存压力可忽略)。
52
52
  * null 表示未触发,不污染输出。
53
53
  */
54
- const THRASH_THRESHOLD = 3;
54
+ const THRASH_THRESHOLD = 2;
55
55
  function thrashHint(name, args, count) {
56
56
  if (count < THRASH_THRESHOLD)
57
57
  return null;
@@ -59,9 +59,34 @@ function thrashHint(name, args, count) {
59
59
  'either failing or returning the same content. STOP retrying and switch strategy:\n' +
60
60
  '- read_file / glob → path likely wrong; call `glob` to discover paths, or `ask_human`\n' +
61
61
  '- run_command → Windows path-escaping issue; use `read_file` / `glob` with absolute paths instead\n' +
62
- '- edit_file → old_string mismatch; re-read the file to find the exact text\n' +
62
+ '- write_file / edit_file CHANGE_CONFLICT do not resend; read_file the same path and use its latest hash (use null only when read_file says the path is missing)\n' +
63
+ '- edit_file old_string mismatch → re-read the exact region and copy it verbatim\n' +
63
64
  '- otherwise → re-read the tool description; the argument shape may be wrong');
64
65
  }
66
+ /**
67
+ * Detect only a strict streak of identical failures. Successful calls, or a
68
+ * different tool/argument pair, end the streak. This keeps intentional phased
69
+ * read_file/glob calls from being mislabeled after the underlying files change.
70
+ */
71
+ export function createThrashTracker() {
72
+ let lastFailedFingerprint = null;
73
+ let consecutiveFailures = 0;
74
+ return (name, args, succeeded) => {
75
+ if (succeeded) {
76
+ lastFailedFingerprint = null;
77
+ consecutiveFailures = 0;
78
+ return null;
79
+ }
80
+ const fingerprint = `${name}\x00${args}`;
81
+ if (fingerprint === lastFailedFingerprint)
82
+ consecutiveFailures += 1;
83
+ else {
84
+ lastFailedFingerprint = fingerprint;
85
+ consecutiveFailures = 1;
86
+ }
87
+ return thrashHint(name, args, consecutiveFailures);
88
+ };
89
+ }
65
90
  /** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
66
91
  function isParallelTool(name) {
67
92
  const tool = tools.find((candidate) => candidate.name === name);
@@ -73,6 +98,15 @@ function isResourceLockedTool(name) {
73
98
  const tool = tools.find((candidate) => candidate.name === name);
74
99
  return !!tool && getToolCapabilities(tool).concurrency === 'resource-locked';
75
100
  }
101
+ function isResourceLockedCall(call) {
102
+ if (!isResourceLockedTool(call.name))
103
+ return false;
104
+ if (call.name !== 'sub-agent')
105
+ return true;
106
+ const args = parseArgs(call.arguments);
107
+ // Unknown write sets stay on the serial path. Read tasks and known disjoint write sets may batch.
108
+ return args?.mode !== 'write' || (Array.isArray(args.writeSet) && args.writeSet.length > 0);
109
+ }
76
110
  /** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
77
111
  const isMutationTool = (name) => isFileMutationTool(name);
78
112
  function deniedOutcome(name) {
@@ -142,11 +176,13 @@ function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runt
142
176
  content: optimizeToolResult(tc.name, output, tc.arguments, encodingContext),
143
177
  };
144
178
  history.push(msg);
179
+ const messageIndex = history.length - 1;
180
+ recordArtifact(runtimeContextState, history, messageIndex, output, succeeded);
145
181
  // 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
146
182
  if (pruner)
147
183
  pruner.observePush(history, msg, succeeded);
148
184
  if (lifecycle)
149
- lifecycle.pushTool(history, history.length - 1, succeeded);
185
+ lifecycle.pushTool(history, messageIndex, succeeded);
150
186
  runtimeContextState.lifecycleStats = lifecycle?.stats();
151
187
  }
152
188
  /**
@@ -219,15 +255,10 @@ export async function runAgentCore(opts) {
219
255
  }
220
256
  : u;
221
257
  };
222
- // Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)
223
- // 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
224
- const recentToolCalls = new Map();
225
- const recordAndHint = (name, args) => {
226
- const fp = `${name}\x00${args}`;
227
- const c = (recentToolCalls.get(fp) ?? 0) + 1;
228
- recentToolCalls.set(fp, c);
229
- return thrashHint(name, args, c);
230
- };
258
+ const addToolUsage = (outcome) => addUsage(outcome.usage);
259
+ // Only consecutive identical failures are thrashing. Any success (including
260
+ // a mutation between two reads) or different call resets the streak.
261
+ const recordAndHint = createThrashTracker();
231
262
  history.push({ role: 'user', content: userInput });
232
263
  // 中断回滚快照:push 用户消息后整段浅拷贝。abort 时 length=0;push(...saved) 还原。
233
264
  // 这样中断时至少保留用户消息(及之前的历史);每步工具全部执行完毕后刷新快照,
@@ -250,6 +281,7 @@ export async function runAgentCore(opts) {
250
281
  ? createLifecycleEngine(history)
251
282
  : null;
252
283
  runtimeContextState.lifecycleStats = lifecycle?.stats();
284
+ rehydrateArtifacts(runtimeContextState, history);
253
285
  // 预算调度器:每个 runAgentCore 实例一个,在 age-aware sweep 后评估并执行 warn / compact。
254
286
  // contextBudget 开关关闭时为 null。
255
287
  const scheduler = config.contextBudget !== false
@@ -310,6 +342,7 @@ export async function runAgentCore(opts) {
310
342
  completed: false,
311
343
  terminationReason: 'aborted',
312
344
  finalText: null,
345
+ usage: turnUsage,
313
346
  validation: latestValidation,
314
347
  changedFiles: mutation.changedFiles.map((item) => item.path),
315
348
  };
@@ -362,6 +395,7 @@ export async function runAgentCore(opts) {
362
395
  runtimeContextState.lifecycleStats = lifecycle.stats();
363
396
  }
364
397
  ageAware?.rehydrate(history);
398
+ rehydrateArtifacts(runtimeContextState, history);
365
399
  }
366
400
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
367
401
  mode = 'idle';
@@ -410,6 +444,7 @@ export async function runAgentCore(opts) {
410
444
  completed: false,
411
445
  terminationReason: 'aborted',
412
446
  finalText: null,
447
+ usage: turnUsage,
413
448
  validation: latestValidation,
414
449
  changedFiles: mutation.changedFiles.map((item) => item.path),
415
450
  };
@@ -507,6 +542,9 @@ export async function runAgentCore(opts) {
507
542
  retry: Math.max(0, (outcome.attempts ?? 1) - 1),
508
543
  retryDelayMs: outcome.retryDelayMs ?? 0,
509
544
  changedFiles: outcome.changedFiles ?? [],
545
+ staleFiles: outcome.staleFiles ?? [],
546
+ ...(outcome.changeSet ? { changeSet: outcome.changeSet } : {}),
547
+ ...(outcome.usage ? { nestedUsage: outcome.usage } : {}),
510
548
  }, {
511
549
  toolCallId: traceCall.toolCallId,
512
550
  ...(tc.id ? { providerToolCallId: tc.id } : {}),
@@ -527,7 +565,7 @@ export async function runAgentCore(opts) {
527
565
  durationMs: 0,
528
566
  };
529
567
  hooks.onToolResult?.(currentCall, error, null, null, 1);
530
- const hint = recordAndHint(currentCall.name, currentCall.arguments);
568
+ const hint = recordAndHint(currentCall.name, currentCall.arguments, false);
531
569
  pushToolResult(history, currentCall, hint ? `${error}${hint}` : error, relprune, lifecycle, scheduler, runtimeContextState, false);
532
570
  traceToolEnd(currentCall, i, outcome);
533
571
  i++;
@@ -553,23 +591,25 @@ export async function runAgentCore(opts) {
553
591
  for (let k = 0; k < batch.length; k++) {
554
592
  const tc = batch[k];
555
593
  const outcome = await started[k];
594
+ addToolUsage(outcome);
595
+ opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
556
596
  traceToolEnd(tc, i + k, outcome);
557
597
  const output = outcome.output;
558
598
  hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
559
599
  // Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
560
- const hint = recordAndHint(tc.name, tc.arguments);
600
+ const hint = recordAndHint(tc.name, tc.arguments, outcome.status === 'success');
561
601
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
562
602
  }
563
603
  hooks.onToolDone?.();
564
604
  i = j;
565
605
  }
566
- else if (isResourceLockedTool(currentCall.name) &&
606
+ else if (isResourceLockedCall(currentCall) &&
567
607
  !(getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
568
608
  // 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
569
609
  // 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
570
610
  let j = i;
571
611
  while (j < calls.length &&
572
- isResourceLockedTool(calls[j].name) &&
612
+ isResourceLockedCall(calls[j]) &&
573
613
  !getRuntimeDisabledTools().has(calls[j].name) &&
574
614
  !(getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
575
615
  j++;
@@ -621,17 +661,23 @@ export async function runAgentCore(opts) {
621
661
  for (let k = 0; k < entries.length; k++) {
622
662
  const entry = entries[k];
623
663
  const outcome = await started[k];
664
+ addToolUsage(outcome);
665
+ opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
624
666
  traceToolEnd(entry.tc, i + k, outcome);
625
667
  hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
626
- const hint = recordAndHint(entry.tc.name, entry.tc.arguments);
668
+ const hint = recordAndHint(entry.tc.name, entry.tc.arguments, outcome.status === 'success');
627
669
  pushToolResult(history, entry.tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
628
- if (isMutationTool(entry.tc.name) && outcome.status === 'success') {
629
- const mutationPath = entry.parsed?.path;
630
- if (typeof mutationPath === 'string' && mutationPath) {
631
- relprune?.observeMutation(history, mutationPath);
632
- lifecycle?.pushMutation(history, history.length - 1, mutationPath);
633
- runtimeContextState.lifecycleStats = lifecycle?.stats();
670
+ const invalidatedFiles = [...new Set([
671
+ ...(outcome.changedFiles ?? []),
672
+ ...(outcome.staleFiles ?? []),
673
+ ])];
674
+ if (invalidatedFiles.length > 0) {
675
+ for (const changedFile of invalidatedFiles) {
676
+ relprune?.observeMutation(history, changedFile);
677
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
634
678
  }
679
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
680
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
635
681
  }
636
682
  }
637
683
  if (firstAllowed)
@@ -656,7 +702,7 @@ export async function runAgentCore(opts) {
656
702
  };
657
703
  hooks.onToolResult?.(tc, err, null, null, 1);
658
704
  // Thrashing:同上
659
- const hint = recordAndHint(tc.name, tc.arguments);
705
+ const hint = recordAndHint(tc.name, tc.arguments, false);
660
706
  pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
661
707
  traceToolEnd(tc, i, outcome);
662
708
  i++;
@@ -684,7 +730,7 @@ export async function runAgentCore(opts) {
684
730
  hooks.onToolHeader?.(tc);
685
731
  const outcome = deniedOutcome(tc.name);
686
732
  hooks.onToolResult?.(tc, outcome.output, null, null, 1);
687
- const hint = recordAndHint(tc.name, tc.arguments);
733
+ const hint = recordAndHint(tc.name, tc.arguments, false);
688
734
  pushToolResult(history, tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
689
735
  traceToolEnd(tc, i, outcome);
690
736
  i++;
@@ -705,21 +751,26 @@ export async function runAgentCore(opts) {
705
751
  },
706
752
  onRetry: (retry) => traceToolRetry(tc, i, retry),
707
753
  });
754
+ addToolUsage(outcome);
755
+ opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
708
756
  traceToolEnd(tc, i, outcome);
709
757
  const output = outcome.output;
710
758
  hooks.onToolDone?.();
711
759
  hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
712
760
  // Thrashing:同上(history 附 hint,UI 干净)
713
- const hint = recordAndHint(tc.name, tc.arguments);
761
+ const hint = recordAndHint(tc.name, tc.arguments, outcome.status === 'success');
714
762
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
715
- // 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
716
- if (isMutationTool(tc.name) && outcome.status === 'success') {
717
- const mp = mutationParsed?.path;
718
- if (typeof mp === 'string' && mp) {
719
- relprune?.observeMutation(history, mp);
720
- lifecycle?.pushMutation(history, history.length - 1, mp);
721
- runtimeContextState.lifecycleStats = lifecycle?.stats();
763
+ const invalidatedFiles = [...new Set([
764
+ ...(outcome.changedFiles ?? []),
765
+ ...(outcome.staleFiles ?? []),
766
+ ])];
767
+ if (invalidatedFiles.length > 0) {
768
+ for (const changedFile of invalidatedFiles) {
769
+ relprune?.observeMutation(history, changedFile);
770
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
722
771
  }
772
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
773
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
723
774
  }
724
775
  i++;
725
776
  }
@@ -829,6 +880,7 @@ export async function runAgentCore(opts) {
829
880
  completed: false,
830
881
  terminationReason: 'aborted',
831
882
  finalText: null,
883
+ usage: turnUsage,
832
884
  validation: latestValidation,
833
885
  changedFiles: mutation.changedFiles.map((item) => item.path),
834
886
  };
@@ -17,6 +17,44 @@ import { isToolErrorOutput } from '../tools/result.js';
17
17
  import { appendCurrentSessionTraceEvent } from '../session/index.js';
18
18
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
19
19
  let currentBatchId = null;
20
+ let turnFileChanges = [];
21
+ function lineDelta(oldText, newText) {
22
+ const before = oldText ? oldText.split('\n') : [];
23
+ const after = newText ? newText.split('\n') : [];
24
+ let head = 0;
25
+ while (head < before.length && head < after.length && before[head] === after[head])
26
+ head++;
27
+ let tail = 0;
28
+ while (tail < before.length - head
29
+ && tail < after.length - head
30
+ && before[before.length - 1 - tail] === after[after.length - 1 - tail])
31
+ tail++;
32
+ return { added: after.length - head - tail, removed: before.length - head - tail };
33
+ }
34
+ function writeChangeOverview() {
35
+ if (turnFileChanges.length === 0)
36
+ return;
37
+ const merged = new Map();
38
+ for (const change of turnFileChanges) {
39
+ const current = merged.get(change.path);
40
+ if (current) {
41
+ current.added += change.added;
42
+ current.removed += change.removed;
43
+ if (change.kind === 'A')
44
+ current.kind = 'A';
45
+ }
46
+ else {
47
+ merged.set(change.path, { ...change });
48
+ }
49
+ }
50
+ const changes = [...merged.values()];
51
+ const added = changes.reduce((n, c) => n + c.added, 0);
52
+ const removed = changes.reduce((n, c) => n + c.removed, 0);
53
+ layout.contentWrite(` ${ui.dim}├─${ui.reset} ${ui.bold}${ui.green}◆${ui.reset} ${t('agent.changes')} ${t('agent.files', { count: changes.length })} ${ui.green}+${added}${ui.reset} ${ui.red}−${removed}${ui.reset}\n`);
54
+ for (const change of changes) {
55
+ layout.contentWrite(` ${ui.dim}│ ${change.kind}${ui.reset} ${change.path} ${ui.green}+${change.added}${ui.reset} ${ui.red}−${change.removed}${ui.reset}\n`);
56
+ }
57
+ }
20
58
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
21
59
  function firstLineOf(ui) {
22
60
  if (typeof ui === 'string')
@@ -43,7 +81,9 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
43
81
  if (!currentBatchId)
44
82
  return;
45
83
  let diff = null;
46
- if (isMutationTool(tc.name) && parsed && !isToolErrorOutput(output)) {
84
+ if ((tc.name === 'edit_file' || tc.name === 'write_file') && parsed && !isToolErrorOutput(output)) {
85
+ const oldText = tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld;
86
+ const newText = String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? '');
47
87
  diff = renderFileChange({
48
88
  path: String(parsed.path ?? ''),
49
89
  kind: tc.name === 'edit_file' ? 'edit' : 'write',
@@ -53,9 +93,15 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
53
93
  newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? ''),
54
94
  startLine: tc.name === 'edit_file' ? editStartLine : 1,
55
95
  });
96
+ turnFileChanges.push({
97
+ path: String(parsed.path ?? ''),
98
+ kind: tc.name === 'write_file' && preWriteOld == null ? 'A' : 'M',
99
+ ...lineDelta(oldText, newText),
100
+ });
56
101
  }
57
102
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
58
- batch.recordResult(currentBatchId, tc.name, preview, diff, output);
103
+ batch.recordResult(currentBatchId, tc.name, preview, diff, output, isToolErrorOutput(output));
104
+ batch.showLiveBatch(currentBatchId, layout);
59
105
  // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
60
106
  if (isMutationTool(tc.name))
61
107
  flushToolBatch(true);
@@ -96,6 +142,7 @@ onContextUpdate) {
96
142
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
97
143
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
98
144
  currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
145
+ turnFileChanges = [];
99
146
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
100
147
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
101
148
  // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
@@ -121,6 +168,8 @@ onContextUpdate) {
121
168
  // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
122
169
  // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
123
170
  const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
171
+ // 正文是工具批次边界:只有“连续且中间没有正文”的工具调用才合并。
172
+ // 一旦模型开始解释阶段结果,立即收尾当前摘要;后续工具重新建立批次。
124
173
  if (s)
125
174
  flushToolBatch();
126
175
  spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
@@ -203,12 +252,14 @@ onContextUpdate) {
203
252
  const detail = validation.status === 'skipped' && validation.skipReason
204
253
  ? `${validation.status}: ${validation.skipReason}`
205
254
  : validation.status;
206
- layout.contentWrite(` ${color}●${ui.reset} ${t('agent.validationResult', { command, status: detail })}\n`);
255
+ const symbol = validation.status === 'passed' ? '◆' : validation.status === 'failed' ? '×' : '!';
256
+ layout.contentWrite(` ${ui.dim}├─${ui.reset} ${color}${symbol}${ui.reset} ${t('agent.validationResult', { command, status: detail })}\n\n`);
207
257
  },
208
258
  onDone: (elapsedMs, usage) => {
209
259
  flushToolBatch();
260
+ writeChangeOverview();
210
261
  const tok = formatTurnTokens(usage);
211
- layout.contentWrite(` ${ui.dim} ${t('agent.workedFor', { elapsed: fmtElapsed(elapsedMs) })}${tok}${ui.reset}\n`);
262
+ layout.contentWrite(` ${ui.bold}${ui.green}◆${ui.reset} ${t('agent.complete')} ${fmtElapsed(elapsedMs)}${tok}\n`);
212
263
  // 内容区触底时,DECSTBM 增量滚屏可能只推进物理终端,未把 Worked 前已在
213
264
  // buffer 中的空行完整画出来;用户滚动/点击触发 repaint 后才“突然”出现。
214
265
  // 轮次收尾立即按 buffer 原子重画,使未满屏与触底滚屏的布局一致。
@@ -279,9 +330,9 @@ function formatTurnTokens(usage) {
279
330
  const billablePrompt = usage.promptTokens - cached;
280
331
  const extras = [];
281
332
  if (cached > 0)
282
- extras.push(`↻ ${fmt(cached)} cached`);
333
+ extras.push(`${Math.round((cached / Math.max(1, usage.promptTokens)) * 100)}% cached`);
283
334
  if (reasoning > 0)
284
335
  extras.push(`reasoning ${fmt(reasoning)}`);
285
336
  const extrasStr = extras.length > 0 ? ` · ${extras.join(' · ')}` : '';
286
- return ` · ${fmt(total)} tokens (↑ ${fmt(billablePrompt)} ↓ ${fmt(usage.completionTokens)})${extrasStr}`;
337
+ return ` ${fmt(total)} tokens${extrasStr} ${ui.dim}(↑ ${fmt(billablePrompt)} ↓ ${fmt(usage.completionTokens)})${ui.reset}`;
287
338
  }