mocode-ai 1.6.4 → 1.6.6

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 (91) hide show
  1. package/README.md +382 -365
  2. package/README.zh-CN.md +19 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +7 -1
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +1 -0
  8. package/dist/agent/tool-turn.js +2 -0
  9. package/dist/agent/work-discipline.js +3 -1
  10. package/dist/bots/bus.js +117 -0
  11. package/dist/bots/cli.js +102 -0
  12. package/dist/bots/store.js +100 -0
  13. package/dist/config/index.js +53 -2
  14. package/dist/config/presets.js +40 -1
  15. package/dist/config/profiles.js +9 -1
  16. package/dist/context/clearing.js +53 -0
  17. package/dist/context/relevance.js +6 -0
  18. package/dist/context/text-search.js +113 -0
  19. package/dist/headless.js +302 -0
  20. package/dist/index.js +171 -3
  21. package/dist/jobs/approval.js +153 -0
  22. package/dist/jobs/approve-cli.js +30 -0
  23. package/dist/jobs/attach.js +61 -0
  24. package/dist/jobs/checkpoint.js +33 -0
  25. package/dist/jobs/launch.js +107 -0
  26. package/dist/jobs/runner.js +59 -0
  27. package/dist/jobs/store.js +101 -0
  28. package/dist/jobs/worktree.js +63 -0
  29. package/dist/llm/index.js +35 -8
  30. package/dist/llm/providers/anthropic.js +14 -4
  31. package/dist/llm/reasoning.js +141 -0
  32. package/dist/memory/reflect.js +1 -1
  33. package/dist/memory/store.js +31 -28
  34. package/dist/models/budgets.js +11 -0
  35. package/dist/models/catalog.js +130 -0
  36. package/dist/models/map-preset.js +50 -0
  37. package/dist/models/model-panel.js +227 -0
  38. package/dist/models/protocol.js +44 -0
  39. package/dist/models/reasoning-cap.js +88 -0
  40. package/dist/models/search.js +58 -0
  41. package/dist/models/types.js +8 -0
  42. package/dist/notify/index.js +144 -0
  43. package/dist/repl/commands/bots.js +32 -0
  44. package/dist/repl/commands/effort.js +81 -0
  45. package/dist/repl/commands/jobs.js +109 -0
  46. package/dist/repl/commands/model-actions.js +138 -0
  47. package/dist/repl/commands/model-catalog.js +67 -0
  48. package/dist/repl/commands/model-panel-ui.js +107 -0
  49. package/dist/repl/commands/model.js +22 -20
  50. package/dist/repl/commands/registry.js +12 -0
  51. package/dist/repl/commands/schedule.js +37 -0
  52. package/dist/repl/commands/session.js +54 -0
  53. package/dist/repl/commands/stats.js +28 -0
  54. package/dist/repl/commands.js +6 -58
  55. package/dist/repl/runtime.js +5 -1
  56. package/dist/repl/status-bar.js +4 -2
  57. package/dist/rollback/store.js +17 -0
  58. package/dist/runtime/runtime.js +31 -1
  59. package/dist/schedule/cli.js +114 -0
  60. package/dist/schedule/cron.js +79 -0
  61. package/dist/schedule/daemon.js +166 -0
  62. package/dist/schedule/store.js +98 -0
  63. package/dist/session/compact.js +84 -3
  64. package/dist/session/index.js +2 -0
  65. package/dist/session/retention.js +297 -0
  66. package/dist/session/scheduler.js +30 -3
  67. package/dist/session/store.js +108 -6
  68. package/dist/session/usage-stats.js +90 -0
  69. package/dist/skills/activation.js +2 -0
  70. package/dist/skills/builtin-skills.js +153 -0
  71. package/dist/skills/discover.js +3 -1
  72. package/dist/skills/runner.js +4 -1
  73. package/dist/tools/builtins/arena.js +156 -0
  74. package/dist/tools/builtins/index.js +8 -0
  75. package/dist/tools/builtins/message-bus.js +98 -0
  76. package/dist/tools/builtins/read-file.js +19 -1
  77. package/dist/tools/builtins/run-command.js +2 -1
  78. package/dist/tools/builtins/session-search.js +33 -0
  79. package/dist/tools/builtins/task.js +2 -0
  80. package/dist/tools/constants.js +8 -0
  81. package/dist/tools/read-dedup.js +62 -0
  82. package/dist/tools/tool-runtime.js +1 -0
  83. package/dist/ui/batch.js +140 -104
  84. package/dist/ui/composer.js +4 -18
  85. package/dist/ui/fuzzy-picker.js +227 -0
  86. package/dist/ui/hierarchical-picker.js +302 -0
  87. package/dist/ui/intervention.js +17 -1
  88. package/dist/ui/layout-internal/statusbar.js +18 -7
  89. package/dist/ui/prompt-internal/editor.js +10 -27
  90. package/dist/ui/render.js +2 -0
  91. package/package.json +1 -1
package/README.zh-CN.md CHANGED
@@ -25,6 +25,15 @@ mocode 自己探索代码、读写改文件、执行命令、联网查资料,以
25
25
 
26
26
  <p align="center"><img src="./assets/demo-build-pomodoro.gif" alt="mocode 从零搭建番茄钟并通过浏览器截图自检" width="100%"></p>
27
27
 
28
+ ## 效率:更快、更省 token
29
+
30
+ - **两级模型面板** — 裸 `/model` 先列厂商、Enter 进入后再选模型;搜索是作用域内过滤——顶层只匹配**厂商名**,厂商内只匹配**模型名**,不再把厂商和模型混在一个结果里。
31
+ - **思考强度 `/effort`** — `off / low / medium / high / auto` 五档归一,按目标模型自动翻译成各家方言(Anthropic / OpenAI o·gpt-5 / Qwen3 / GLM / DeepSeek-R1 / 豆包);不认识的模型或第三方网关**不下发**未知字段,避免 400。
32
+ - **用量统计 `/stats`** — 本会话缓存命中率、分层 token 用量与压缩次数,基于后端真实上报,不靠估算。
33
+ - **缓存安全的压缩分叉** — 能整段复用旧对话时优先走 fork,保住 prompt-cache 前缀、减少重复计费;不满足条件再回落摘要(`MOCODE_COMPACT_FORK=false` 关闭)。
34
+ - **重复读取去重** — 同一轮对同一文件的重复读取命中缓存,不再为相同内容重复付费(`MOCODE_READ_DEDUP=false` 关闭)。
35
+ - **子 Agent 深度闸** — 递归派生默认最多 3 层(`SUB_AGENT_MAX_DEPTH` 可调),防止子 agent 无限膨胀。
36
+
28
37
  ## 架构
29
38
 
30
39
  MoCode 是一个分层的自治运行时:终端交互层驱动 Agent 内核,内核通过受控能力平面执行真实操作,持久化认知层则让长任务和跨会话工作保持连贯。
@@ -107,9 +116,15 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
107
116
  - **流式输出 + 思考可见** — 回复边生成边显示;模型支持 reasoning 时思考过程实时可见,思考段自动折叠(不占屏)
108
117
  - **全屏 TUI** — 备用屏(alt screen)+ 固定底栏状态行 + 滚动回看(PgUp/PgDn),运行中可打字(typeahead),下一轮自动预填
109
118
  - **会话持久化** — 每轮自动落盘,`--resume` / `/resume` 续接历史会话
119
+ - **后台任务** — `mocode run --bg "任务"` 派 detached 子进程,关终端不死;状态与日志落 `.mocode/jobs/`,`/jobs` 可列表/看日志/kill。设 `MOCODE_NOTIFY_WEBHOOK` 后,任务结束自动推送通知(ntfy/Bark/Telegram/generic)。无人值守任务遇到未授权的 confirm/dangerous 动作时会挂起(状态 `paused`)并通知你;在另一终端敲 `mocode approve <id>` 批准(`mocode deny` 拒绝,或 TUI 内 `/jobs approve`),任务在同一进程内续跑。用 `mocode attach <id>` 可实时跟随运行中(或已结束)的任务。长任务在每个工具批次后落 checkpoint;进程挂掉或机器重启后,`mocode resume-job <id>` 从最后 checkpoint 重放(在途动作会重跑)。`MOCODE_JOB_MAX_MS` / `MOCODE_JOB_MAX_TOKENS` 提供硬性时长/token 上限。
120
+ - **具名 Bot** — `mocode bots add` 定义岗位 Bot(专属系统提示 + 可选工具白名单 + 沙箱范围),项目/全局两级;`--bot <name>` 按名运行,可与后台/schedules 组合。
121
+ - **Arena 竞技场** — `arena` 工具把同一任务并行跑 N 遍(2-6,各自独立),再由 judge 模型按你给的标准对所有候选打分排序,返回排名和最优方案。适合设计/探索/解题这类“多跑几遍选最好”的场景。
122
+ - **持久 Bot 消息协作** — `message_bus` 工具给具名 bot 提供持久的异步消息存储(`send`/`inbox`/`ack`/`history`):主管 bot 可把活交给当前没在运行的 bot;worker 之后(如被计划任务唤起)拉 inbox、干活、回复。身份随 `--bot` 走,每个 bot 只能看到/确认发给自己的消息。
123
+ - **计划任务** — `mocode schedule add` 注册 cron / webhook 触发;本地 detached 守护(`schedule start`,仅回环)到点或被 `POST /trigger/<token>` 触发即派后台 job,分钟去重;另有 `schedule tick` 供系统任务计划调用。
124
+ - **Headless 一次性执行** — `mocode -p "任务"` 或管道 `echo "任务" | mocode`,可选 `--json` 结构化输出;非交互下 confirm/dangerous 操作默认拒绝(`--dangerously-skip-permissions` 显式放开;`--verbose` 追加工具结果摘要、`--session-dir <目录>`、`--worktree` git 隔离工作树),会话仍自动落盘可 `--resume`
110
125
  - **Skills 系统** — 自动扫描 `~/.mocode/skills/` 等目录,description 注入系统提示,模型按需调 `use_skill` 加载完整指令(渐进式披露:先看简介,任务相关才加载正文)
111
126
  - **可选桌宠** — 独立悬浮窗(`/pet`)显示一个小角色,镜像 agent 活动(空闲 / 思考 / 跑工具 / 等人工),独立进程走 WebSocket,`/pet quit` 完全关闭。挂在终端外,绝不挡终端。
112
- - **斜杠命令** — `/exit` `/clear` `/cd` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`,输入时下拉过滤
127
+ - **斜杠命令** — `/exit` `/clear` `/cd` `/context` `/skills` `/compact` `/resume` `/rollback` `/jobs` `/schedules` `/bots` `/memory` `/reflect` `/init` `/theme` `/model` `/effort` `/stats` `/plan` `/auto` `/pet`,输入时下拉过滤
113
128
 
114
129
  ## 使用文档
115
130
 
@@ -295,7 +310,9 @@ dev_server stop id=srv-xxxx
295
310
  | `/memory` | 看记忆库:条目数 + 近期索引 |
296
311
  | `/memory_switch` | 允许/禁止 memory 自动路由并切换 Memory Index;下一真实用户轮生效 |
297
312
  | `/reflect` | 手动触发一次后台记忆反思 pass |
298
- | `/model` | 配置大模型(baseURL / apiKey / model / 上下文窗口),即时生效 + 持久化 |
313
+ | `/model` | 两级面板切换模型(先选厂商再选模型;顶层按厂商名、厂商内按模型名过滤);也可配置 baseURL / apiKey / 上下文窗口,即时生效 + 持久化 |
314
+ | `/effort` | 设置思考强度 off/low/medium/high/auto(如 `/effort high`);未识别模型不下发参数 |
315
+ | `/stats` | 本会话用量:缓存命中率 / 分层 token / 压缩次数 |
299
316
  | `/init` | 扫描项目生成 `AGENTS.md` 项目记忆(发给 agent 执行) |
300
317
  | `/theme` | 切换颜色主题(↑↓ · Enter,或 `/theme <name>` 直切) |
301
318
  | `/plan` | 切到 plan 模式(只读探查 + 产出计划,审批后切 auto 执行) |
@@ -1,5 +1,8 @@
1
1
  import { estimatePromptTokens, estimateTokens, isContextLengthError, } from '../llm/index.js';
2
2
  import { visionBatch, visionKeep } from '../config/index.js';
3
+ import { getActiveSessionStore } from '../session/store.js';
4
+ import { getCurrentSessionId } from '../session/state.js';
5
+ import { toUsageRecord } from '../session/usage-stats.js';
3
6
  /**
4
7
  * 沿 cause 链(≤3 层)取第一个 errno,供 trace 取证。
5
8
  * undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` → cause `read ECONNRESET`),
@@ -18,7 +21,7 @@ function traceCauseCode(err) {
18
21
  }
19
22
  /** Executes context preparation plus exactly one model step, including the single overflow retry path. */
20
23
  export async function runModelTurn(input) {
21
- const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
24
+ const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, readDedup, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
22
25
  const { signal, onContextUpdate, hooks } = opts;
23
26
  const { usageMeter, emitTrace } = turnLifecycle;
24
27
  const requestBaseURL = ctx.config.baseURL;
@@ -37,6 +40,8 @@ export async function runModelTurn(input) {
37
40
  // 视觉滑动窗口**必须剪在 trim 之前**:contextTrimmer.trim() 拿的是 historyManager.snapshot(),
38
41
  // 若先 trim,预算口径还是未剪的旧数组,80% 压力线照旧被图像撑爆(design-notes/vision-window.md §2.1)。
39
42
  // 只在这一个地方剪;下面的 overflow 重试路径读同一个 snapshot,会自动受益。
43
+ runtimeContextState.currentStep = step;
44
+ readDedup.beginStep(step);
40
45
  const visionKeepN = visionKeep();
41
46
  if (visionKeepN > 0 && historyManager.pruneVisionWindow({ keep: visionKeepN, batch: visionBatch(), step })) {
42
47
  rebuildHistoryIndexes();
@@ -52,6 +57,8 @@ export async function runModelTurn(input) {
52
57
  signal,
53
58
  });
54
59
  historyRebuilt = trimResult.kind === 'rebuild';
60
+ if (trimResult.kind === 'rebuild' || trimResult.kind === 'content')
61
+ readDedup.markContextChanged();
55
62
  const trimStats = trimResult.kind === 'aborted' ? {} : trimResult.stats;
56
63
  if (scheduler && trimStats.compactHistoryCalled) {
57
64
  emitTrace('compact', {
@@ -244,6 +251,12 @@ export async function runModelTurn(input) {
244
251
  });
245
252
  runtimeContextState.lastUsage = result.usage;
246
253
  usageMeter.add(result.usage);
254
+ // P1:per-step 用量落盘(失败静默,见 appendUsage)。estimatedTotal 用本次裸估算,
255
+ // 供 /stats 校验估算偏差。
256
+ const record = toUsageRecord(step, 'main', result.usage, stepPromptEst);
257
+ const sessionId = getCurrentSessionId();
258
+ if (record && sessionId)
259
+ getActiveSessionStore().appendUsage(sessionId, record);
247
260
  if (result.usage) {
248
261
  cacheState.lastStepPromptTokens = result.usage.promptTokens;
249
262
  if (result.usage.cachedTokens > 0)
@@ -13,6 +13,7 @@ import { runToolTurn } from './tool-turn.js';
13
13
  import { createTurnLifecycle } from './turn-lifecycle.js';
14
14
  import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
15
15
  import { contextState, summarizeToolArguments } from '../session/index.js';
16
+ import { createReadDedup } from '../tools/read-dedup.js';
16
17
  import { createBudgetScheduler } from '../session/scheduler.js';
17
18
  import { invalidateArtifacts, rehydrateArtifacts } from '../context/index.js';
18
19
  import { createRelevancePruner } from '../context/relevance.js';
@@ -63,7 +64,10 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
63
64
  const turnLifecycle = createTurnLifecycle(opts, ctx, stages, savedMode);
64
65
  const { usageMeter, emitTrace, traceTurnId } = turnLifecycle;
65
66
  const toolTurnPlanState = { stepsSincePlanTouch: 0 };
66
- historyManager.appendUserTurn(userInput);
67
+ if (!opts.continueFromHistory)
68
+ historyManager.appendUserTurn(userInput);
69
+ // P2:每用户 turn 一个重复读 scope,经 dispatcher → ToolContext 透传给 read_file。
70
+ const readDedup = createReadDedup();
67
71
  // The initial cancellation checkpoint is captured after the user turn and before any model/tool work.
68
72
  // Relevance and lifecycle collect provenance during normal work. Neither path
69
73
  // rewrites history; exact supersession is applied only by the pressure scheduler.
@@ -173,6 +177,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
173
177
  activeTools,
174
178
  runPolicy,
175
179
  step,
180
+ readDedup,
176
181
  cacheState: modelCacheState,
177
182
  turnLifecycle,
178
183
  cancellationLifecycle,
@@ -318,6 +323,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
318
323
  isDenied: isToolDeniedForStep,
319
324
  currentAllowedToolNames,
320
325
  delegation: delegationForOrchestrator,
326
+ readDedup,
321
327
  argumentErrorHint: (name) => argumentErrorHint(name, runtimeContextState),
322
328
  ...(opts.toolPolicy
323
329
  ? {
@@ -10,6 +10,7 @@
10
10
  // - 主屏渲染可选:TUI 激活时把子 agent 内部工具调用实时写入主内容区并复用 batch 折叠;
11
11
  // TUI 未激活(host 嵌入 / 非 TTY)时纯静默,中间过程只缓冲进 transcript。
12
12
  // - 独立 history 分支:子任务的工具噪声不回灌主对话,只有最终摘要回灌。
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
14
  import { buildMocodeCorePrompt, isSubAgentHardDisabled } from '../config/index.js';
14
15
  import { getToolChatSchema } from '../tools/policy.js';
15
16
  import { effectiveSystemPrompt } from '../skills/index.js';
@@ -47,6 +48,11 @@ You are executing one delegated sub-task with the same engineering standards and
47
48
  * 中断:opts.signal 透传给子 runAgentCore——主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
48
49
  * 子 agent 跑在主 signal 下,主 abort 即子 abort;子 agent 的 abortRestore 还原子 history + 模式。
49
50
  */
51
+ /** 当前委派深度(P4 闸2):根 agent 无 store=0,每进一层 spawnAgent +1。 */
52
+ const spawnDepthScope = new AsyncLocalStorage();
53
+ function currentSpawnDepth() {
54
+ return spawnDepthScope.getStore() ?? 0;
55
+ }
50
56
  export async function spawnAgent(opts) {
51
57
  const activeRuntime = opts.runtime ?? getActiveRuntime();
52
58
  const runtimeContext = opts.runtimeContext ?? activeRuntime?.context ?? getActiveAgentRuntimeContext() ?? defaultAgentRuntimeContext;
@@ -60,6 +66,20 @@ export async function spawnAgent(opts) {
60
66
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, reasoningTokens: 0 },
61
67
  };
62
68
  }
69
+ // 深度闸:达上限直接结构化失败(模型看到后会改用直接工具完成)。执行层拦截,不裁 schema。
70
+ const depth = currentSpawnDepth() + 1;
71
+ if (depth > runtimeContext.config.subAgentMaxDepth) {
72
+ const msg = `Sub-agent depth limit ${runtimeContext.config.subAgentMaxDepth} reached ` +
73
+ '(nested delegation blocked to prevent fork-style recursion); complete the task directly with your own tools.';
74
+ return {
75
+ summary: msg,
76
+ completed: false,
77
+ transcript: msg,
78
+ status: 'failed',
79
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, reasoningTokens: 0 },
80
+ changedFiles: [],
81
+ };
82
+ }
63
83
  const maxSteps = opts.maxSteps ?? runtimeContext.config.subAgentMaxSteps;
64
84
  const requested = opts.tools === undefined ? null : new Set(opts.tools);
65
85
  const shared = opts.delegation && opts.delegation.history.length > 0 && opts.delegation.history[0]?.role === 'system'
@@ -172,8 +192,8 @@ export async function spawnAgent(opts) {
172
192
  const childIndex = parentId ? batch.getGroupChildIndex(opts.callId) : undefined;
173
193
  liveBatchId = batch.beginBatch(t('subagent.running'), {
174
194
  parentId,
175
- // 子批摘要行缩进 = 两层 entry 缩进(随 ENTRY_INDENT 联动,不硬编码空格数)。
176
- indent: parentId ? batch.SUB_BATCH_INDENT : undefined,
195
+ // 缩进由 beginBatch 按父层级自动推导:组容器批下 = 两层 entry 缩进;
196
+ // 普通子 agent 批下再嵌套 = 父缩进 + 两层(树状逐层加深)。
177
197
  groupChildIndex: childIndex,
178
198
  running: true,
179
199
  });
@@ -205,6 +225,8 @@ export async function spawnAgent(opts) {
205
225
  layout.contentWrite('\n');
206
226
  };
207
227
  let lastChar = '';
228
+ // P4 闸3:只收集本 agent 树自己产生的改动(钩子按真实执行触发),不靠整轮全局快照。
229
+ const nestedChangedFiles = new Set();
208
230
  const hooks = {
209
231
  onText: (s) => {
210
232
  writeBuf(s); // 缓冲流式正文(无 markdown 渲染,原始文本)
@@ -225,7 +247,10 @@ export async function spawnAgent(opts) {
225
247
  ensureQuietLine(opts.quietLabel ?? t('skill.executing', { name: opts.prompt.slice(0, 40) }));
226
248
  ensureLiveBatch();
227
249
  if (liveBatchId) {
228
- batch.recordCall(liveBatchId, tc.name, summary);
250
+ // 登记内部调用的 tool_call id:子 agent 嵌套派生时,孙 spawnAgent 据此反查到
251
+ // 本子批,把孙批挂到对应的内部 sub-agent entry 下(而非游离到 buffer 末尾)。
252
+ batch.bindCall(tc.id, liveBatchId);
253
+ batch.recordCall(liveBatchId, tc.name, summary, tc.id);
229
254
  // 默认折叠,只刷新摘要行计数(glob/read_file 数量),不展开明细列表。
230
255
  batch.showLiveBatch(liveBatchId, liveLayout());
231
256
  }
@@ -237,7 +262,7 @@ export async function spawnAgent(opts) {
237
262
  if (liveBatchId) {
238
263
  // 子 agent 批不展示 diff(其写入已直接落工作区,由主 agent 的当前回滚事务统一追踪);
239
264
  // 空 preview 用占位标记 entry 已完成,避免折叠后摘要仍显示"运行中"。
240
- batch.recordResult(liveBatchId, tc.name, preview || t('toolSummary.noOutput'), null, output, isToolErrorOutput(output));
265
+ batch.recordResult(liveBatchId, tc.name, preview || t('toolSummary.noOutput'), null, output, isToolErrorOutput(output), tc.id);
241
266
  batch.showLiveBatch(liveBatchId, liveLayout());
242
267
  }
243
268
  },
@@ -286,7 +311,7 @@ export async function spawnAgent(opts) {
286
311
  // 与主 agent 完全同源:写操作直接落在工作区,进入主 agent 当前轮次的同一回滚事务
287
312
  // (spawn 不调 beginTurn)。没有 overlay 拷贝/ChangeSet 合并这一步——那是旧 read/write
288
313
  // 双模式的产物,子 agent 不再受限,也就不需要"先隔离再合并"。
289
- const result = await runtime.run({
314
+ const runResult = () => runtime.run({
290
315
  turn: 'inherit',
291
316
  history,
292
317
  userInput,
@@ -296,11 +321,18 @@ export async function spawnAgent(opts) {
296
321
  toolsOverride,
297
322
  runtimeAllowedToolNames,
298
323
  contextState: localContextState,
324
+ // P4 闸3:run-coordinator 只消费顶层 onToolOutcome(非 hooks),在此收集本树真实改动。
325
+ onToolOutcome: (_tool, _args, outcome) => {
326
+ for (const f of outcome.changedFiles ?? [])
327
+ nestedChangedFiles.add(f);
328
+ },
299
329
  suppressOpeningAnalysis: true, // 子代理不注入「开场分析」:仅主线面对用户的首次响应用
300
330
  // 子代理不注入主会话「会话状态」(plan + 笔记):那是主 agent 的工作面,委派消息已带齐
301
331
  // 子任务所需上下文,重复注入只白付 token。
302
332
  suppressSessionState: true,
303
333
  });
334
+ // 深度 scope:子 agent 内再派生子 agent 时 ALS 读到此 depth。
335
+ const result = await spawnDepthScope.run(depth, runResult);
304
336
  const status = opts.signal?.aborted || result.terminationReason === 'aborted'
305
337
  ? 'aborted'
306
338
  : result.completed
@@ -318,5 +350,6 @@ export async function spawnAgent(opts) {
318
350
  cachedTokens: result.usage?.cachedTokens ?? 0,
319
351
  reasoningTokens: result.usage?.reasoningTokens ?? 0,
320
352
  },
353
+ changedFiles: [...nestedChangedFiles],
321
354
  };
322
355
  }
@@ -7,7 +7,7 @@ class ChatModelRunner {
7
7
  this.transport = transport;
8
8
  }
9
9
  run(request, signal) {
10
- return this.transport(request.history.slice(), request.handlers, signal, request.tools.slice());
10
+ return this.transport(request.history.slice(), request.handlers, signal, request.tools.slice(), request.reasoningEffort ? { reasoningEffort: request.reasoningEffort } : undefined);
11
11
  }
12
12
  }
13
13
  class LegacyChatModelRunner extends ChatModelRunner {
@@ -46,6 +46,7 @@ class LegacyCompatibleToolDispatcher {
46
46
  callId: call.id,
47
47
  allowedToolNames: request.currentAllowedToolNames(),
48
48
  delegation: request.delegation(),
49
+ readDedup: request.readDedup,
49
50
  ...(hint ? { argumentErrorHint: hint } : {}),
50
51
  ...(onLockAcquired ? { onLockAcquired } : {}),
51
52
  });
@@ -78,6 +78,8 @@ export async function runToolTurn(input) {
78
78
  rebuildHistoryIndexes();
79
79
  throw error;
80
80
  }
81
+ // D3: batch committed (staged shadow pushed into backing) -> persist full history.
82
+ hooks.onCheckpoint?.(opts.history);
81
83
  hooks.onToolBatchEnd?.();
82
84
  cancellationLifecycle.checkpoint();
83
85
  const batchDecision = terminationPolicy.decide({
@@ -41,9 +41,11 @@ Call \`ask_human\` before coding only when repository evidence cannot resolve a
41
41
  3. multiple reasonable options that materially change product behavior;
42
42
  4. the request itself admits two or more materially different readings that lead to different deliverables (do not silently pick one and guess).
43
43
 
44
+ When the request is ambiguous, vague, or missing key information, first load the \`brainstorming\` skill and clarify the need through a short dialogue (one question at a time) before starting work.
45
+
44
46
  For naming and implementation details, follow repository precedent and choose the safest reversible default. Disclose any consequential assumption.
45
47
 
46
- Budget: at most 2 \`ask_human\` calls per turn. Beyond that, use the safest reversible default and disclose it in the final reply.`;
48
+ Budget: during requirement clarification (the \`brainstorming\` workflow) ask as many questions as genuinely advance understanding; during execution after requirements are clear, use at most 2 \`ask_human\` calls per turn, then fall back to the safest reversible default and disclose it in the final reply.`;
47
49
  /** Advisory guidance shared by main and sub-agents. */
48
50
  export function buildWorkDisciplineSection(_modelFamily) {
49
51
  return `${CORE_SECTION}\n\n${ASK_WHITELIST_SECTION}`;
@@ -0,0 +1,117 @@
1
+ // D1 持久 Bot 消息总线:bot 之间(以及 main 用户与 bot 之间)跨时间的异步消息。
2
+ // 与同步 sub-agent 的区别:收发双方不必同时在线——消息落盘,worker bot 之后由
3
+ // scheduler / bg 唤起时拉 inbox 处理并回结果。
4
+ //
5
+ // 存储(<sandboxRoot>/.mocode/bus/):
6
+ // <msgId>.json 一条消息(只创建、永不改写 → 并发安全,无丢消息)
7
+ // <receiver>.read.json 该接收者已读消息 id 数组(低频 RMW,原子写)
8
+ //
9
+ // 身份用 AsyncLocalStorage 注入(runAsIdentity):headless --bot X 时整段运行为 X,
10
+ // 其内派生的 sub-agent 自动继承;缺省身份为 'main'(用户主会话)。
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { getSandboxRoot } from '../sandbox/index.js';
16
+ export const DEFAULT_IDENTITY = 'main';
17
+ const identityStorage = new AsyncLocalStorage();
18
+ /** 在指定 bot 身份下执行 fn;子 agent / 工具内 getIdentity() 都返回该身份。 */
19
+ export function runAsIdentity(identity, fn) {
20
+ return identityStorage.run(identity, fn);
21
+ }
22
+ export function getIdentity() {
23
+ return identityStorage.getStore() ?? DEFAULT_IDENTITY;
24
+ }
25
+ function busRoot() {
26
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'bus');
27
+ }
28
+ function messagePath(id) {
29
+ return path.join(busRoot(), `${id}.json`);
30
+ }
31
+ function readMarkerPath(receiver) {
32
+ // receiver 只允许安全文件名段,杜绝路径穿越。
33
+ const safe = receiver.replace(/[^a-zA-Z0-9_.-]/g, '_');
34
+ return path.join(busRoot(), `${safe}.read.json`);
35
+ }
36
+ export function sendMessage(input) {
37
+ const to = input.to.trim();
38
+ const body = input.body;
39
+ if (!to)
40
+ throw new Error('message recipient (to) is required');
41
+ if (!body)
42
+ throw new Error('message body is required');
43
+ const msg = {
44
+ id: `msg-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
45
+ from: (input.from ?? getIdentity()).trim() || DEFAULT_IDENTITY,
46
+ to,
47
+ body,
48
+ createdAt: new Date().toISOString(),
49
+ ...(input.inReplyTo ? { inReplyTo: input.inReplyTo } : {}),
50
+ };
51
+ fs.mkdirSync(busRoot(), { recursive: true });
52
+ const target = messagePath(msg.id);
53
+ fs.writeFileSync(`${target}.tmp`, `${JSON.stringify(msg, null, 2)}\n`, 'utf8');
54
+ fs.renameSync(`${target}.tmp`, target);
55
+ return msg;
56
+ }
57
+ function getReadSet(receiver) {
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(readMarkerPath(receiver), 'utf8'));
60
+ return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []);
61
+ }
62
+ catch {
63
+ return new Set();
64
+ }
65
+ }
66
+ function saveReadSet(receiver, set) {
67
+ const target = readMarkerPath(receiver);
68
+ fs.writeFileSync(`${target}.tmp`, `${JSON.stringify([...set], null, 2)}\n`, 'utf8');
69
+ fs.renameSync(`${target}.tmp`, target);
70
+ }
71
+ function allMessages() {
72
+ let files;
73
+ try {
74
+ files = fs.readdirSync(busRoot());
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ const out = [];
80
+ for (const f of files) {
81
+ if (!f.endsWith('.json') || f.endsWith('.read.json'))
82
+ continue;
83
+ try {
84
+ out.push(JSON.parse(fs.readFileSync(path.join(busRoot(), f), 'utf8')));
85
+ }
86
+ catch {
87
+ // skip corrupt
88
+ }
89
+ }
90
+ return out.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
91
+ }
92
+ /** 某身份的未读收件箱。 */
93
+ export function inbox(identity = getIdentity()) {
94
+ const read = getReadSet(identity);
95
+ return allMessages().filter((m) => m.to === identity && !read.has(m.id));
96
+ }
97
+ /** 标记消息已读(ack)。只能 ack 发给自己的消息。 */
98
+ export function markRead(ids, identity = getIdentity()) {
99
+ const read = getReadSet(identity);
100
+ const mine = new Set(allMessages()
101
+ .filter((m) => m.to === identity)
102
+ .map((m) => m.id));
103
+ const marked = [];
104
+ for (const id of ids) {
105
+ if (mine.has(id) && !read.has(id)) {
106
+ read.add(id);
107
+ marked.push(id);
108
+ }
109
+ }
110
+ if (marked.length)
111
+ saveReadSet(identity, read);
112
+ return marked;
113
+ }
114
+ /** 与某身份相关的全部消息(发出或收到),按时间排序。 */
115
+ export function messageHistory(identity = getIdentity()) {
116
+ return allMessages().filter((m) => m.from === identity || m.to === identity);
117
+ }
@@ -0,0 +1,102 @@
1
+ // `mocode bots …` 管理:
2
+ // add --name <n> (--prompt "<text>" | --prompt-file <path>) [--description <d>]
3
+ // [--tools "a,b,c"] [--sandbox-path <p>] [--global]
4
+ // list | show <name> | rm <name> [--global]
5
+ // 作用域默认项目(.mocode/bots);--global 写 ~/.mocode/bots。
6
+ import fs from 'node:fs';
7
+ import { saveBot, listBots, getBot, deleteBot } from './store.js';
8
+ function flagValue(args, flag) {
9
+ const i = args.indexOf(flag);
10
+ if (i === -1)
11
+ return undefined;
12
+ const v = args[i + 1];
13
+ return v && !v.startsWith('-') ? v : undefined;
14
+ }
15
+ export async function runBotsCli(rawArgs) {
16
+ const sub = rawArgs[0] ?? 'list';
17
+ const args = rawArgs.slice(1);
18
+ switch (sub) {
19
+ case 'add': {
20
+ const name = flagValue(args, '--name');
21
+ const description = flagValue(args, '--description') ?? '';
22
+ const inline = flagValue(args, '--prompt');
23
+ const promptFile = flagValue(args, '--prompt-file');
24
+ const toolsRaw = flagValue(args, '--tools');
25
+ const sandboxPath = flagValue(args, '--sandbox-path');
26
+ const scope = args.includes('--global') ? 'global' : 'project';
27
+ if (!name) {
28
+ process.stderr.write('usage: mocode bots add --name <n> (--prompt "<text>" | --prompt-file <path>) ' +
29
+ '[--description <d>] [--tools "a,b"] [--sandbox-path <p>] [--global]\n');
30
+ return 1;
31
+ }
32
+ let systemPrompt = inline ?? '';
33
+ if (promptFile) {
34
+ try {
35
+ systemPrompt = fs.readFileSync(promptFile, 'utf8');
36
+ }
37
+ catch (e) {
38
+ process.stderr.write(`mocode: cannot read prompt file: ${e instanceof Error ? e.message : String(e)}\n`);
39
+ return 1;
40
+ }
41
+ }
42
+ if (!systemPrompt.trim()) {
43
+ process.stderr.write('mocode: bot prompt is empty (use --prompt or --prompt-file)\n');
44
+ return 1;
45
+ }
46
+ const tools = toolsRaw
47
+ ? toolsRaw
48
+ .split(',')
49
+ .map((t) => t.trim())
50
+ .filter(Boolean)
51
+ : undefined;
52
+ try {
53
+ const rec = saveBot({ name, description, systemPrompt, tools, sandboxPath, scope });
54
+ process.stdout.write(`Bot saved: ${rec.name} (${rec.scope})\n`);
55
+ return 0;
56
+ }
57
+ catch (e) {
58
+ process.stderr.write(`mocode: ${e instanceof Error ? e.message : String(e)}\n`);
59
+ return 1;
60
+ }
61
+ }
62
+ case 'show': {
63
+ const name = args[0];
64
+ if (!name) {
65
+ process.stderr.write('usage: mocode bots show <name>\n');
66
+ return 1;
67
+ }
68
+ const rec = getBot(name);
69
+ if (!rec) {
70
+ process.stderr.write(`mocode: bot "${name}" not found\n`);
71
+ return 1;
72
+ }
73
+ process.stdout.write(`${JSON.stringify(rec, null, 2)}\n`);
74
+ return 0;
75
+ }
76
+ case 'rm':
77
+ case 'remove': {
78
+ const name = args[0];
79
+ if (!name) {
80
+ process.stderr.write('usage: mocode bots rm <name> [--global]\n');
81
+ return 1;
82
+ }
83
+ const scope = args.includes('--global') ? 'global' : 'project';
84
+ const ok = deleteBot(name, scope);
85
+ process.stdout.write(ok ? `Removed bot ${name} (${scope})\n` : `mocode: bot ${name} (${scope}) not found\n`);
86
+ return ok ? 0 : 1;
87
+ }
88
+ case 'list':
89
+ default: {
90
+ const all = listBots();
91
+ if (all.length === 0) {
92
+ process.stdout.write('(no bots; create one with `mocode bots add …`)\n');
93
+ return 0;
94
+ }
95
+ for (const b of all) {
96
+ process.stdout.write(`${b.scope === 'global' ? 'g' : 'p'} ${b.name}` +
97
+ `${b.tools && b.tools.length ? ` tools:${b.tools.length}` : ''} ${b.description}\n`);
98
+ }
99
+ return 0;
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,100 @@
1
+ // 具名 Bot 存储:一个 Bot = 岗位系统提示 + 可选工具白名单 + 可选沙箱范围。
2
+ //
3
+ // 两级存储(与 AGENTS.md 发现规则同风格):
4
+ // - 全局:~/.mocode/bots/<name>.json
5
+ // - 项目:<sandboxRoot>/.mocode/bots/<name>.json
6
+ // 合并时全局先、项目后,项目同名覆盖全局。
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { getSandboxRoot } from '../sandbox/index.js';
11
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
12
+ export function globalBotsDir() {
13
+ return path.join(os.homedir(), '.mocode', 'bots');
14
+ }
15
+ export function projectBotsDir() {
16
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'bots');
17
+ }
18
+ function botPath(dir, name) {
19
+ return path.join(dir, `${name}.json`);
20
+ }
21
+ function assertValidName(name) {
22
+ if (!NAME_RE.test(name)) {
23
+ throw new Error(`bad bot name "${name}": use 1-32 chars, lowercase letters/digits/dash, cannot start with dash`);
24
+ }
25
+ }
26
+ /** 校验并落盘一个 bot(scope 决定写全局还是项目)。 */
27
+ export function saveBot(input) {
28
+ assertValidName(input.name);
29
+ if (!input.systemPrompt.trim())
30
+ throw new Error('bot systemPrompt must not be empty');
31
+ const scope = input.scope ?? 'project';
32
+ const record = {
33
+ name: input.name,
34
+ description: input.description ?? '',
35
+ systemPrompt: input.systemPrompt,
36
+ ...(input.tools && input.tools.length ? { tools: input.tools } : {}),
37
+ ...(input.sandboxPath ? { sandboxPath: input.sandboxPath } : {}),
38
+ scope,
39
+ createdAt: new Date().toISOString(),
40
+ };
41
+ const dir = scope === 'global' ? globalBotsDir() : projectBotsDir();
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const target = botPath(dir, record.name);
44
+ const tmp = `${target}.tmp`;
45
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
46
+ fs.renameSync(tmp, target);
47
+ return record;
48
+ }
49
+ function readOne(dir, file) {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function readDir(dir) {
58
+ let files;
59
+ try {
60
+ files = fs.readdirSync(dir);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ const out = [];
66
+ for (const f of files) {
67
+ if (!f.endsWith('.json'))
68
+ continue;
69
+ const rec = readOne(dir, f);
70
+ if (rec)
71
+ out.push(rec);
72
+ }
73
+ return out;
74
+ }
75
+ /** 列出全部 bot(全局 + 项目,项目同名覆盖)。 */
76
+ export function listBots() {
77
+ const byName = new Map();
78
+ for (const b of readDir(globalBotsDir()))
79
+ byName.set(b.name, b);
80
+ for (const b of readDir(projectBotsDir()))
81
+ byName.set(b.name, b);
82
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
83
+ }
84
+ export function getBot(name) {
85
+ // 项目优先(覆盖语义):先找项目再找全局。
86
+ const project = readOne(projectBotsDir(), `${name}.json`);
87
+ if (project)
88
+ return project;
89
+ return readOne(globalBotsDir(), `${name}.json`);
90
+ }
91
+ export function deleteBot(name, scope = 'project') {
92
+ assertValidName(name);
93
+ try {
94
+ fs.rmSync(botPath(scope === 'global' ? globalBotsDir() : projectBotsDir(), name));
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }