mocode-ai 1.1.8 → 1.1.9

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,8 +8,6 @@ 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
11
  ## Engineering discipline
14
12
 
15
13
  MoCode keeps code-level control light and leaves task strategy to the agent:
@@ -82,7 +80,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
82
80
  - **Pressure-driven context compression** — Normal history keeps full tool evidence. At 80% occupancy, one scheduler event runs all enabled cleanup and always follows with a history summary. `/context` shows live usage and `/compact` remains an explicit manual override.
83
81
  - **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.
84
82
  - **Project context (`MOCODE.md`)** — A single project-level memory file at `MOCODE.md` captures both static facts (project description, commands, module list, directory tree) and human/AI-written insights (conventions, architectural decisions, pitfalls). Generate it once with `/init`, then keep it up to date by hand or by asking the agent to refresh it. Loaded automatically into the system prompt on every turn.
85
- - **Session notepad (notes.md)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent maintains a working notepad at `.mocode/sessions/<sessionId>/notes.md` (file-based, survives context compression). It can record intermediate findings, design decisions, open questions, and structured plans. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]` when a `## Plan:` section is present. The agent manages the file directly with write_file/edit_file/read_file.
83
+ - **Session notepad (notes.md)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent maintains a working notepad at `.mocode/sessions/<sessionId>/notes.md` (file-based, survives context compression). It records the execution plan with the dedicated `plan_update` tool — a three-state step machine (`pending`/`in_progress`/`completed`, at most one `in_progress`) that auto-settles to `## Done:` when finished. The active plan is re-injected into the system prompt after compaction and re-synced into context whenever notes.md changes, and a gentle reminder nudges the agent if it goes several tool-steps without updating the plan. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]`.
86
84
  - **Interruptible and reversible** — Ctrl+C interrupts the current turn at any time (kills child processes recursively, rolls history back to before the turn started, leaves no half-finished tool calls). `/rollback` restores file changes from per-turn snapshots, with a per-file keep/undo choice — no git dependency required.
87
85
  - **Sandbox protection** — File reads/writes go through a sandbox that blocks out-of-bounds paths (`../../`, absolute paths outside the root, symlink escapes, etc.), so the agent never touches files outside your working directory.
88
86
 
@@ -210,6 +208,7 @@ The agent operates in **the working directory it was launched from** — to have
210
208
  | `web_fetch` | Fetch a URL, cleaning HTML into plain text |
211
209
  | `use_skill` | Load the full SKILL.md instructions for a given skill |
212
210
  | `ask_human` | Pop up a Q&A panel at decision points; user picks a preset or types freely (blocks until answered) |
211
+ | `plan_update` | Record/update the session execution plan (the `## Plan:` block in notes.md); three-state steps, at most one in_progress, auto-settles to `## Done:` when all complete |
213
212
  | `switch_mode` | Switch between `plan` (read-only planning) and `auto` (full execution); the agent can call this itself to explore before acting |
214
213
  | `sub-agent` | Spawn a capable isolated worker; read tasks can run concurrently and writes use overlay + ChangeSet safe merge |
215
214
 
@@ -221,12 +220,31 @@ The agent operates in **the working directory it was launched from** — to have
221
220
 
222
221
  The five `memory_*` tools are gated on `MEMORY_ENABLED=true` at startup; toggle at runtime with `/memory_switch` (REPL restart required, by design — see Skills section for the difference between Tier-1 `MOCODE.md` and Tier-2 memory).
223
222
 
223
+ The four frontend tools — `browser`, `dev_server`, `screenshot`, `view_image` — are **off by default** (they depend on the Playwright binary, spawn long-lived processes, or capture the desktop). Enable the whole cluster at runtime with `/fe on`; the model only sees them once enabled. Toggle with `/fe on|off|status`.
224
+
225
+ ### Frontend / UI loop
226
+
227
+ `dev_server` + `browser` form a loop of "start it → open the page → see the rendered result":
228
+
229
+ ```
230
+ dev_server start command="npm run dev" readyUrl="http://localhost:5173"
231
+ browser open → navigate → click / fill → screenshot
232
+ dev_server stop id=srv-xxxx
233
+ ```
234
+
235
+ - `dev_server` processes survive across tool calls (`run_command` can't — it tree-kills children on timeout or when the turn is interrupted). Readiness waiting supports `readyUrl` (loopback only) or `readyPattern` (matches startup logs); logs go to `.mocode/dev-servers/<id>.log` and support incremental reads via `offset`.
236
+ - `browser` page sessions also persist across calls; screenshots feed back to the model through the multimodal channel, along with recent console output, page errors, and failed requests.
237
+ - Safe defaults: `browser` only allows `http/https` on `localhost / 127.0.0.1 / ::1`, rejecting `file:` and credentialed URLs; set `MOCODE_BROWSER_ALLOW_REMOTE=true` to reach remote hosts. `dev_server` runs arbitrary commands and shares `run_command`'s `dangerous` risk class — requires user confirmation before execution.
238
+ - Both are disabled in plan mode; on exit mocode tree-kills background processes and closes the browser.
239
+ - The browser binary is not bundled with the npm package; run `npx playwright install chromium` before first use.
240
+
224
241
  ## Slash commands
225
242
 
226
243
  | Command | Purpose |
227
244
  | ------------------ | ----------------------------------------------------------------------- |
228
245
  | `/exit` `/quit` | Exit MoCode |
229
246
  | `/clear` | Clear history (keeps the system prompt) + clear screen |
247
+ | `/image` | Attach a local image to the next message; supports `attach <path>` / `list` / `clear` |
230
248
  | `/context` | Show a context usage bar (tokens / message count, estimated or measured) |
231
249
  | `/skills` | List discovered skills |
232
250
  | `/compact` | Compress history (optionally with a focus hint: `/compact …`) |
@@ -241,6 +259,7 @@ The five `memory_*` tools are gated on `MEMORY_ENABLED=true` at startup; toggle
241
259
  | `/plan` | Switch to plan mode (read-only exploration + plan output, approve to switch to auto) |
242
260
  | `/auto` | Switch back to auto mode (full toolset execution) |
243
261
  | `/pet` | Toggle the optional desktop pet (floating window mirroring agent state) |
262
+ | `/fe` | Toggle the frontend tool cluster `browser` / `dev_server` / `screenshot` / `view_image` on/off (off by default) |
244
263
  | `/pet skin` | Pick a pet skin (↑↓ · Enter) |
245
264
  | `/pet quit` | Fully shut down the pet process (not just disconnect) |
246
265
 
package/README.zh-CN.md CHANGED
@@ -60,8 +60,6 @@ mocode 不会在任务结束时暗中启动验证瀑布。agent 可以根据任
60
60
 
61
61
  <p align="center"><img src="./assets/architecture/pet-bridge-zh-CN.svg" alt="MoCode 桌宠桥:hooks、事件帧、Electron 客户端" width="100%"></p>
62
62
 
63
- ## 为什么用 mocode
64
-
65
63
  ## 工程化纪律
66
64
 
67
65
  mocode 把代码层控制保持得尽量轻,把任务策略交给 agent:
@@ -81,7 +79,7 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
81
79
  - **计划 / 执行双模式** — `plan` 模式下只读探查(读代码、查索引、搜索,绝不写盘、不跑命令、不派生子 agent),产出计划;`auto` 模式全量工具放开。agent 还能在两者间自切换——先把陌生代码库摸清,再动手改。
82
80
  - **统一压力驱动压缩** — 正常 history 保留完整工具证据;达到 80% 后由一次调度事件运行所有已启用的清理,并始终继续 history 摘要。`/context` 显示实时用量,`/compact` 仍是用户显式覆盖。
83
81
  - **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
84
- - **会话记事本(notes.md)** — 复杂多步任务(≥3 处文件改动 / ≥5 步工具调用)时,agent 在 `.mocode/sessions/<sessionId>/notes.md` 维护一个工作记事本(落盘抗压缩),可记录中间发现、设计决策、待验证问题和结构化计划。TUI 状态栏实时显示进度 chip:`plan: [标题] (3/7) ▸ [当前步]`(当存在 `## Plan:` 段时)。agent 直接用 write_file/edit_file/read_file 管理此文件。
82
+ - **会话记事本(notes.md)** — 复杂多步任务(≥3 处文件改动 / ≥5 步工具调用)时,agent 在 `.mocode/sessions/<sessionId>/notes.md` 维护一个工作记事本(落盘抗压缩),可记录中间发现、设计决策、待验证问题和结构化计划。执行计划由专用 `plan_update` 工具维护——三态步骤机(`pending`/`in_progress`/`completed`,同一时刻至多一个 `in_progress`),全部完成自动结算为 `## Done:`。活跃 plan 在压缩后重注入系统提示、notes.md 一变就重同步进上下文,若连续多步未更新还会有温和提醒。TUI 状态栏实时显示进度 chip:`plan: [标题] (3/7) ▸ [当前步]`。
85
83
  - **可中断、可回滚** — Ctrl+C 随时打断当前轮次(树杀子进程,历史还原到本轮开始前,不留残半的工具调用);`/rollback` 按轮次快照恢复文件改动,逐个文件「保留/撤销」,不依赖 git。
86
84
  - **沙箱防护** — 文件读写经沙箱拦截,挡掉越界路径(`../../`、绝对外圈、软链出圈等),不碰工作目录之外的文件。
87
85
 
@@ -213,6 +211,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
213
211
  | `web_fetch` | 抓取指定 URL,HTML 清洗成纯文本 |
214
212
  | `use_skill` | 加载某 skill 的完整 SKILL.md 指令 |
215
213
  | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
214
+ | `plan_update` | 记录/更新会话执行计划(notes.md 的 `## Plan:` 段);三态步骤机,同一时刻至多一个 in_progress,全部完成自动结算为 `## Done:` |
216
215
  | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
217
216
  | `sub-agent` | 派生具备完整能力的隔离子 Agent;只读任务可并发,写任务通过 overlay + ChangeSet 安全合并 |
218
217
 
@@ -238,6 +237,8 @@ dev_server stop id=srv-xxxx
238
237
  - 两者在 plan 模式下均被禁用;mocode 退出时会树杀后台进程并关闭浏览器。
239
238
  - 浏览器二进制不随 npm 包分发,首次使用前需 `npx playwright install chromium`。
240
239
 
240
+ 这 4 个前端工具 —— `browser`、`dev_server`、`screenshot`、`view_image` —— **默认关闭**(依赖 Playwright 二进制、会拉起长驻进程或截取桌面)。运行时用 `/fe on` 整体开启,开启后模型才看得到;用 `/fe on|off|status` 切换。
241
+
241
242
  5 个 `memory_*` 工具受启动时 `MEMORY_ENABLED=true` 总开关控制;运行时切换用 `/memory_switch`(需重启 REPL,刻意为之,见下「项目记忆」小节区分 Tier-1 / Tier-2)。
242
243
 
243
244
  ## 斜杠命令
@@ -261,6 +262,7 @@ dev_server stop id=srv-xxxx
261
262
  | `/plan` | 切到 plan 模式(只读探查 + 产出计划,审批后切 auto 执行) |
262
263
  | `/auto` | 切回 auto 模式(全量工具执行) |
263
264
  | `/pet` | 开关桌宠(独立悬浮窗,镜像 agent 状态动画) |
265
+ | `/fe` | 切换前端工具簇 `browser` / `dev_server` / `screenshot` / `view_image` 的开关(默认关闭) |
264
266
  | `/pet skin` | 选桌宠皮肤(↑↓ · Enter) |
265
267
  | `/pet quit` | 完全关闭桌宠进程(而非仅断开本连接) |
266
268
 
@@ -5,6 +5,7 @@
5
5
  // 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
6
6
  // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
7
  import { readFileSync } from 'node:fs';
8
+ import { getNotesMtime } from '../session/notes.js';
8
9
  import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
9
10
  import { executeToolOutcome, getToolCapabilities, isFileMutationTool, tools, } from '../tools/registry.js';
10
11
  import { checkPermission } from '../permissions/index.js';
@@ -17,13 +18,19 @@ import { createBudgetScheduler } from '../session/scheduler.js';
17
18
  import { recordArtifact, invalidateArtifacts, rehydrateArtifacts, } from '../context/index.js';
18
19
  import { createRelevancePruner } from '../context/relevance.js';
19
20
  import { isToolResultSuccess } from '../context/utils.js';
20
- import { config } from '../config/index.js';
21
+ import { config, extractActivePlanSection, reinjectActivePlanIntoSystem } from '../config/index.js';
21
22
  import { t } from '../i18n/index.js';
22
23
  import { jailResolve } from '../sandbox/index.js';
23
24
  import { createLifecycleEngine } from '../context/lifecycle.js';
24
25
  import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
25
26
  import { getCurrentTurnId, getCurrentTurnMutationState } from '../rollback/index.js';
26
27
  import { getCurrentSessionId } from '../session/state.js';
28
+ /** nag 提醒阈值:连续 N 个"执行了工具但没更新 notes.md"的步后提醒一次(对齐 Claude Code TodoWrite 的 3 轮)。 */
29
+ const PLAN_NAG_THRESHOLD = 3;
30
+ /** nag 提醒文本:注入到当前步第一条 tool_result 内容前(与最新工具输出同批被模型看到,而非单独一条易被冲淡)。 */
31
+ const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md but have not updated it recently. ' +
32
+ 'If you finished a step, call plan_update to check it off (keep at most one in_progress); ' +
33
+ 'if the whole plan is done, let plan_update settle it to ## Done:. If the plan changed scope, update it to match reality.';
27
34
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
28
35
  function parseArgs(raw) {
29
36
  try {
@@ -176,6 +183,10 @@ export async function runAgentCore(opts) {
176
183
  let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
177
184
  let traceStatus = 'error';
178
185
  let toolCallCount = 0;
186
+ // A+B(plan 可靠性):跨步计数"执行了工具但没改动 notes.md"的连续步数。
187
+ // 本步写了 notes.md(plan_update 或直接 write/edit)→ 清零并重同步 history[0];
188
+ // 否则累计,达阈值则在当前步 tool_result 前注入 nag 提醒。
189
+ let stepsSincePlanTouch = 0;
179
190
  // 本轮 token 累计:每步 chat() 返回后把 result.usage 累加,供 onDone 摘要行 + AgentRunResult.usage
180
191
  // 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
181
192
  let turnUsage;
@@ -315,6 +326,8 @@ export async function runAgentCore(opts) {
315
326
  runtimeContextState.lifecycleStats = lifecycle.stats();
316
327
  }
317
328
  rehydrateArtifacts(runtimeContextState, history);
329
+ // ② compact 后把活跃 plan 重注入系统提示,避免 agent 因上下文压缩丢失执行计划。
330
+ reinjectActivePlanIntoSystem(history);
318
331
  }
319
332
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
320
333
  mode = 'idle';
@@ -450,6 +463,10 @@ export async function runAgentCore(opts) {
450
463
  function: { name: tc.name, arguments: tc.arguments },
451
464
  })),
452
465
  });
466
+ // A+B:记录本步第一条 tool_result 的下标 + 执行前 notes.md 的 mtime,
467
+ // 工具全部执行完后据此判断"本步是否改动了 notes.md"(重同步 / nag)。
468
+ const toolResultStartIdx = history.length;
469
+ const notesMtimeBefore = getNotesMtime();
453
470
  // Record interstitial narration for observability only. It never changes tool output
454
471
  // or injects instructions back into the model context.
455
472
  const narration = result.content?.trim() ?? '';
@@ -733,6 +750,25 @@ export async function runAgentCore(opts) {
733
750
  i++;
734
751
  }
735
752
  }
753
+ // A(事件驱动重同步):本步若改动了 notes.md,把最新 plan 块刷回 history[0],
754
+ // 让模型上下文镜像当前勾选态(不再停留在轮首的旧副本)。只在 mtime 变化时触发,零额外 churn。
755
+ // B(nag 提醒):连续 N 步有工具活动但没更新 plan,在当前步第一条 tool_result 前注入提醒。
756
+ const notesMtimeAfter = getNotesMtime();
757
+ if (notesMtimeAfter !== notesMtimeBefore) {
758
+ reinjectActivePlanIntoSystem(history);
759
+ stepsSincePlanTouch = 0;
760
+ }
761
+ else {
762
+ stepsSincePlanTouch += 1;
763
+ if (stepsSincePlanTouch >= PLAN_NAG_THRESHOLD) {
764
+ const activePlan = extractActivePlanSection();
765
+ const firstToolMsg = history[toolResultStartIdx];
766
+ if (activePlan && firstToolMsg && firstToolMsg.role === 'tool' && typeof firstToolMsg.content === 'string') {
767
+ firstToolMsg.content = `${PLAN_NAG_TEXT}\n\n${firstToolMsg.content}`;
768
+ }
769
+ stepsSincePlanTouch = 0;
770
+ }
771
+ }
736
772
  if (modelAttachments.length > 0) {
737
773
  const names = modelAttachments.map((attachment) => attachment.name).join(', ');
738
774
  const content = [
@@ -117,9 +117,9 @@ function flushToolBatch(expandSingleEntry = false) {
117
117
  batch.endBatch(id, layout);
118
118
  if (expandSingleEntry)
119
119
  batch.expandSingleEntryFully(id, layout);
120
- // 普通摘要只有一个“当前空行”,再 break 一次把它提交为分隔空行。
121
- // mutation 自动展开时 content.insertAfter 已先把该当前空行提交到 rows;若这里仍补 \n,
122
- // diff 后就会固定出现两条空白行。
120
+ // 普通批:endBatch 留了 1 个 hasCurrent 空行,break 一次把它提交为分隔空行。
121
+ // mutation 自动展开:expandSingleEntryFully 自己补 separator (\n),这里不能再补 \n,
122
+ // 不然 diff 后面就会出现两条空白行。
123
123
  if (!expandSingleEntry)
124
124
  layout.contentWrite('\n');
125
125
  }
@@ -68,6 +68,8 @@ export async function spawnAgent(opts) {
68
68
  const requested = opts.tools?.length ? new Set(opts.tools) : null;
69
69
  const readOnly = new Set(['read_file', 'glob', 'grep', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
70
70
  toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
71
+ // plan_update 直写主会话 notes.md(不走 overlay),子代理不应改动主计划——统一排除。
72
+ tool.function.name !== 'plan_update' &&
71
73
  (!requested || requested.has(tool.function.name)) &&
72
74
  (mode === 'write' || readOnly.has(tool.function.name)));
73
75
  // 独立 history(子 agent 自己持有,不共享主对话)。
@@ -2,8 +2,8 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import dotenv from 'dotenv';
5
- import { getSandboxRoot } from '../sandbox/root.js';
6
5
  import { getCurrentSessionId } from '../session/state.js';
6
+ import { getNotesFilePath } from '../session/notes.js';
7
7
  import { buildWorkDisciplineSection, inferModelFamily } from '../agent/work-discipline.js';
8
8
  import { detectLanguage, setLanguage, t, } from '../i18n/index.js';
9
9
  /**
@@ -89,11 +89,8 @@ const PLATFORM_NOTE = (() => {
89
89
  * 这样比纯目录列表更显眼,降低 agent 在长上下文里扫过去就忘了的概率。
90
90
  */
91
91
  export function buildNotepadSection(sessionId = getCurrentSessionId()) {
92
- if (!sessionId)
93
- return '';
94
- const root = getSandboxRoot() ?? process.cwd();
95
- const p = path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
96
- if (!fs.existsSync(p))
92
+ const p = getNotesFilePath(sessionId);
93
+ if (!p || !fs.existsSync(p))
97
94
  return '';
98
95
  try {
99
96
  const content = fs.readFileSync(p, 'utf8').trim();
@@ -133,6 +130,55 @@ export function buildNotepadSection(sessionId = getCurrentSessionId()) {
133
130
  return '';
134
131
  }
135
132
  }
133
+ /**
134
+ * 抽取 notes.md 中**唯一活跃**的 `## Plan:` 段原文(含标题行到下一个 `## ` 之前)。
135
+ * 用于 compact 后把计划重注入系统提示,避免 agent 因上下文压缩丢失执行计划。
136
+ * 已结算(`## Done:`)或无 plan 时返回 null。
137
+ */
138
+ export function extractActivePlanSection(sessionId = getCurrentSessionId()) {
139
+ const p = getNotesFilePath(sessionId);
140
+ if (!p || !fs.existsSync(p))
141
+ return null;
142
+ try {
143
+ const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
144
+ const lines = normalized.split('\n');
145
+ const start = lines.findIndex((l) => /^## Plan:\s*.+$/.test(l));
146
+ if (start < 0)
147
+ return null;
148
+ const endOffset = lines.slice(start + 1).findIndex((l) => /^##\s/.test(l));
149
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
150
+ return lines.slice(start, end).join('\n').trimEnd();
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** compact 重注入用的幂等标记:history[0] 中夹住活跃 plan 块,重复注入只替换不累积。 */
157
+ const ACTIVE_PLAN_MARKER = '\n\n<!-- mocode:active-plan -->\n';
158
+ /**
159
+ * 把活跃 `## Plan:` 段重注入系统提示(history[0])。compact 后调用:
160
+ * 若 notes.md 有活跃 plan,则覆盖旧标记块写入最新内容;若无,则清掉残留标记块。
161
+ * 直接改 history[0].content(compact 不破坏 index 0),幂等,返回是否改动。
162
+ */
163
+ export function reinjectActivePlanIntoSystem(history) {
164
+ const sys = history[0];
165
+ if (!sys || sys.role !== 'system' || typeof sys.content !== 'string')
166
+ return false;
167
+ let content = sys.content;
168
+ const markerIdx = content.indexOf(ACTIVE_PLAN_MARKER);
169
+ if (markerIdx >= 0) {
170
+ content = content.slice(0, markerIdx).replace(/\s+$/, '');
171
+ }
172
+ const plan = extractActivePlanSection();
173
+ if (!plan) {
174
+ if (markerIdx < 0)
175
+ return false;
176
+ sys.content = content;
177
+ return true;
178
+ }
179
+ sys.content = `${content}${ACTIVE_PLAN_MARKER}${plan}\n`;
180
+ return true;
181
+ }
136
182
  const SYSTEM_PROMPT_MEMORY_SECTION = `
137
183
  ## Memory (cross-session facts)
138
184
  - The prompt may contain a title/summary index; retrieve details with memory_search or inspect all with memory_list.
@@ -221,31 +267,30 @@ ${buildCodegraphSection()}
221
267
  - **No flattery / no preamble in conclusions**: skip "Sure", "好的", "我已经完成了" and similar no-information prefixes — jump straight to substance.
222
268
  - Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.
223
269
  ${t('assistant.languageInstruction')}`;
224
- // 动态段(置于末尾):memory 索引 + notepad 目录与使用说明。
225
- // 按需注入(#13):仅当有内容才拼对应标题/说明,避免空标题与无文件时的模板噪声。
226
- // - "## Project context" 仅当 memorySection/notepadSection 非空;
227
- // - notepad 使用说明仅当 notes.md 文件存在(notepadSection 非空)—
228
- // 无文件时连 marker 都不拼,既省 token 也让前缀缓存更稳。core 切片
229
- // 回退到 MARKER_DYNAMIC_SECTION 或整段(见 buildMocodeCorePrompt)。
270
+ // 动态段(置于末尾):memory 索引 + notepad 索引 + notepad 使用说明。
271
+ // 按需注入(#13):有内容的索引才拼对应标题,避免空标题噪声。
272
+ // - "## Project context" 仅当 memorySection/notepadSection 非空(notepad 索引依赖 notes.md 存在);
273
+ // - notepad 使用说明**无条件**注入:否则会陷入"说明依赖 notes.md 存在 → 模型不知要建 → 文件永不存在"的鸡生蛋循环,功能对模型不可见。说明放在 prompt 末尾,不影响 staticBody 的前缀缓存。
230
274
  const dynamicParts = [];
231
275
  const ctxContent = `${memorySection}${notepadSection}`.trimEnd();
232
276
  if (ctxContent) {
233
277
  dynamicParts.push(`## Project context (dynamic reference)\n${ctxContent}`);
234
278
  }
235
- if (notepadSection) {
236
- dynamicParts.push(`## Session Notepad (\`.mocode/sessions/${sessionId ?? '<id>'}/notes.md\`)\n` +
237
- 'Use this compact, persistent working surface for tasks with at least three steps or context-loss risk; skip it for simple work.\n\n' +
238
- 'Keep at most one active plan:\n' +
239
- '```\n' +
240
- '## Plan: <title>\n' +
241
- 'Goal: <outcome>\n' +
242
- '### Steps\n' +
243
- '- [ ] 1. <verifiable step>\n' +
244
- '### Progress\n' +
245
- '- <completed phase and evidence>\n' +
246
- '```\n' +
247
- 'Update checkboxes and Progress after each completed phase. Before the final reply, reconcile the plan with actual work, then rename it to `## Done:` or remove it. Keep other notes concise and session-specific; use memory for stable cross-session facts.');
248
- }
279
+ dynamicParts.push(`## Session Notepad (\`.mocode/sessions/${sessionId ?? '<id>'}/notes.md\`)\n` +
280
+ 'Use this compact, persistent working surface for tasks with at least three steps or context-loss risk; skip it for simple work.\n\n' +
281
+ 'Record and update the execution plan with the `plan_update` tool (preferred over editing checkboxes by hand); it keeps at most one active plan as a `## Plan:` section:\n' +
282
+ '```\n' +
283
+ '## Plan: <title>\n' +
284
+ 'Goal: <outcome>\n' +
285
+ '### Steps\n' +
286
+ '- [ ] 1. <self-contained step: target file/symbol, the change, and how to verify>\n' +
287
+ '### Progress\n' +
288
+ '- <completed/total>\n' +
289
+ '```\n' +
290
+ 'Keep at most one step in_progress, and mark a step completed as soon as its work is done — do not batch updates to the end of the turn. ' +
291
+ 'Write each step so a teammate who lost the conversation could pick it up cold: name the file or symbol, the exact change, and the verification, so the plan survives context compaction. ' +
292
+ 'plan_update creates notes.md for you when the task warrants it; read_file the full notes.md whenever you need to recover context after compaction. ' +
293
+ 'When every step is completed, plan_update settles the plan to `## Done:` automatically. Keep other notes concise and session-specific; use memory for stable cross-session facts.');
249
294
  return `${staticBody}\n\n${dynamicParts.join('\n\n')}`;
250
295
  }
251
296
  /** 静态主体结束 + 会话私有段起点标记,供 buildMocodeCorePrompt 稳健切片(#17)。 */
@@ -312,6 +357,7 @@ export const config = {
312
357
  maxSteps: Number(process.env.MAX_STEPS) || 1000,
313
358
  subAgentEnabled: process.env.MOCODE_SUBAGENT_ENABLED === 'true',
314
359
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || Number(process.env.MAX_STEPS) || 1000,
360
+ frontendToolsEnabled: process.env.MOCODE_FRONTEND_TOOLS_ENABLED === 'true',
315
361
  sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
316
362
  searchApiKey: process.env.ANYSEARCH_API_KEY,
317
363
  sandboxRoot: process.env.SANDBOX_ROOT || undefined,
@@ -360,6 +406,15 @@ export function updateSubAgentConfig(enabled) {
360
406
  config.subAgentEnabled = enabled;
361
407
  process.env.MOCODE_SUBAGENT_ENABLED = enabled ? 'true' : 'false';
362
408
  }
409
+ /** 前端工具簇总开关;默认 false,关闭时 browser/dev_server/screenshot/view_image 不进入模型工具表。 */
410
+ export function isFrontendToolsEnabled() {
411
+ return config.frontendToolsEnabled;
412
+ }
413
+ /** 运行时切换前端工具簇;工具 schema 刷新与持久化由 REPL 调用方完成。 */
414
+ export function updateFrontendToolsConfig(enabled) {
415
+ config.frontendToolsEnabled = enabled;
416
+ process.env.MOCODE_FRONTEND_TOOLS_ENABLED = enabled ? 'true' : 'false';
417
+ }
363
418
  /**
364
419
  * 记忆子系统总开关:单一来源。/memory_switch、/memory_status、buildSystemPrompt、
365
420
  * tools/builtins/index.ts、tools/constants.ts 的 plan-mode 列表都从这里查。
@@ -23,6 +23,10 @@ const zhCN = {
23
23
  'commands.subagentOn': '开启子 Agent',
24
24
  'commands.subagentOff': '关闭子 Agent',
25
25
  'commands.subagentStatus': '查看子 Agent 状态',
26
+ 'commands.fe': '前端工具簇开关 browser/dev_server/screenshot/view_image(默认关闭)',
27
+ 'commands.feOn': '开启前端工具簇',
28
+ 'commands.feOff': '关闭前端工具簇',
29
+ 'commands.feStatus': '查看前端工具簇状态',
26
30
  'commands.theme': '切换颜色主题(↑↓·Enter)',
27
31
  'commands.model': '模型配置与预设管理',
28
32
  'commands.modelConfigure': '配置新模型(向导)',
@@ -211,6 +215,12 @@ const zhCN = {
211
215
  'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
212
216
  'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
213
217
  'subagent.usage': '用法:/subagent on|off|status',
218
+ 'fe.status': '前端工具簇:{state}',
219
+ 'fe.stateOn': '开启',
220
+ 'fe.stateOff': '关闭',
221
+ 'fe.changedOn': '已开启前端工具簇;browser / dev_server / screenshot / view_image 将从下一次模型请求起可用。',
222
+ 'fe.changedOff': '已关闭前端工具簇;browser / dev_server / screenshot / view_image 已从模型工具表移除。',
223
+ 'fe.usage': '用法:/fe on|off|status',
214
224
  'plan.ready': '计划已就绪',
215
225
  'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
216
226
  'plan.execute': '切 auto 执行',
@@ -265,6 +275,10 @@ const en = {
265
275
  'commands.subagentOn': 'Enable sub-agents',
266
276
  'commands.subagentOff': 'Disable sub-agents',
267
277
  'commands.subagentStatus': 'Show sub-agent status',
278
+ 'commands.fe': 'Frontend tools: browser/dev_server/screenshot/view_image (disabled by default)',
279
+ 'commands.feOn': 'Enable frontend tools',
280
+ 'commands.feOff': 'Disable frontend tools',
281
+ 'commands.feStatus': 'Show frontend tools status',
268
282
  'commands.theme': 'Switch color theme (↑↓·Enter)',
269
283
  'commands.model': 'Model configuration and presets',
270
284
  'commands.modelConfigure': 'Configure a new model (wizard)',
@@ -453,6 +467,12 @@ const en = {
453
467
  'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
454
468
  'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
455
469
  'subagent.usage': 'Usage: /subagent on|off|status',
470
+ 'fe.status': 'Frontend tools: {state}',
471
+ 'fe.stateOn': 'enabled',
472
+ 'fe.stateOff': 'disabled',
473
+ 'fe.changedOn': 'Frontend tools enabled; browser / dev_server / screenshot / view_image will be available from the next model request.',
474
+ 'fe.changedOff': 'Frontend tools disabled; browser / dev_server / screenshot / view_image have been removed from the model tool list.',
475
+ 'fe.usage': 'Usage: /fe on|off|status',
456
476
  'plan.ready': 'Plan ready',
457
477
  'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
458
478
  'plan.execute': 'Switch to auto and execute',
package/dist/llm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import OpenAI from 'openai';
2
- import { config, isSubAgentEnabled } from '../config/index.js';
2
+ import { config, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
- import { getPlanDisabledTools } from '../tools/constants.js';
4
+ import { getPlanDisabledTools, FRONTEND_TOOLS } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
6
6
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
7
7
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
@@ -159,10 +159,14 @@ export function __setChatCreateImpl(impl) {
159
159
  export const chatTools = [];
160
160
  export const planChatTools = [];
161
161
  export function refreshChatTools() {
162
- // sub-agent 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
163
- const visibleTools = isSubAgentEnabled()
164
- ? tools
165
- : tools.filter((tool) => tool.name !== 'sub-agent');
162
+ // sub-agent 与前端工具簇常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
163
+ const visibleTools = tools.filter((tool) => {
164
+ if (tool.name === 'sub-agent')
165
+ return isSubAgentEnabled();
166
+ if (FRONTEND_TOOLS.has(tool.name))
167
+ return isFrontendToolsEnabled();
168
+ return true;
169
+ });
166
170
  const next = visibleTools.map((t) => ({
167
171
  type: 'function',
168
172
  function: {
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, reinjectActivePlanIntoSystem, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
@@ -72,6 +72,13 @@ function buildSlashCommands() {
72
72
  { name: 'status', value: '/subagent status', desc: d('commands.subagentStatus') },
73
73
  ],
74
74
  },
75
+ {
76
+ name: '/fe', desc: d('commands.fe'), children: [
77
+ { name: 'on', value: '/fe on', desc: d('commands.feOn') },
78
+ { name: 'off', value: '/fe off', desc: d('commands.feOff') },
79
+ { name: 'status', value: '/fe status', desc: d('commands.feStatus') },
80
+ ],
81
+ },
75
82
  { name: '/theme', desc: d('commands.theme') },
76
83
  {
77
84
  name: '/model', desc: d('commands.model'), children: [
@@ -1469,6 +1476,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1469
1476
  // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1470
1477
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1471
1478
  const log = await manualCompact(history, focus, { force });
1479
+ // ② compact 后把活跃 plan 重注入系统提示(history[0]),避免 agent 因上下文压缩丢失执行计划。
1480
+ if (log.compactHistoryCalled)
1481
+ reinjectActivePlanIntoSystem(history);
1472
1482
  const d = log.compactDetail;
1473
1483
  appendCurrentSessionRuntimeEvent('compact', {
1474
1484
  source: 'manual',
@@ -2012,6 +2022,44 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2012
2022
  layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'subagent.changedOn' : 'subagent.changedOff')}${ui.reset}\n`);
2013
2023
  continue;
2014
2024
  }
2025
+ if (line === '/fe' ||
2026
+ line.startsWith('/fe ') ||
2027
+ line === '/frontend' ||
2028
+ line.startsWith('/frontend ')) {
2029
+ // /fe — 前端工具簇总开关(browser / dev_server / screenshot / view_image)。
2030
+ // 无参切换 on/off;有参 on|off 显式;status 只读。设计同 /subagent:单一来源
2031
+ // isFrontendToolsEnabled();关闭时 4 个工具不进模型 schema(refreshChatTools 过滤)、
2032
+ // 运行时 getRuntimeDisabledTools 兜底拦截、plan 模式 getPlanDisabledTools 也剔除。默认 false。
2033
+ const raw = line.startsWith('/fe ')
2034
+ ? line.slice('/fe '.length)
2035
+ : line.startsWith('/frontend ')
2036
+ ? line.slice('/frontend '.length)
2037
+ : line === '/frontend'
2038
+ ? ''
2039
+ : line.slice('/fe'.length);
2040
+ const arg = raw.trim().toLowerCase();
2041
+ if (arg === '' || arg === 'status') {
2042
+ const enabled = isFrontendToolsEnabled();
2043
+ const state = t(enabled ? 'fe.stateOn' : 'fe.stateOff');
2044
+ layout.contentWrite(`${ui.accent}${t('fe.status', { state })}${ui.reset}\n` +
2045
+ `${ui.dim}MOCODE_FRONTEND_TOOLS_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
2046
+ continue;
2047
+ }
2048
+ if (arg !== 'on' && arg !== 'off') {
2049
+ layout.contentWrite(`${ui.yellow}${t('fe.usage')}${ui.reset}\n`);
2050
+ continue;
2051
+ }
2052
+ const enabled = arg === 'on';
2053
+ if (enabled !== isFrontendToolsEnabled()) {
2054
+ updateFrontendToolsConfig(enabled);
2055
+ updateConfigKey('MOCODE_FRONTEND_TOOLS_ENABLED', enabled ? 'true' : 'false');
2056
+ refreshChatTools();
2057
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
2058
+ layout.rewriteBanner(bannerLines(banner()));
2059
+ }
2060
+ layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'fe.changedOn' : 'fe.changedOff')}${ui.reset}\n`);
2061
+ continue;
2062
+ }
2015
2063
  if (line === '/memory_switch' ||
2016
2064
  line.startsWith('/memory_switch ') ||
2017
2065
  line === '/memory_status' ||
@@ -0,0 +1,107 @@
1
+ // session/notes.ts - Session Notepad(notes.md)的 plan 状态单一事实源。
2
+ // 负责:canonical 文件路径、plan 结构化模型、markdown 渲染、以及"只替换活跃 ## Plan: 段、
3
+ // 保留其它笔记段"的写入。被 plan_update 工具、agent core(事件驱动重同步/nag)、
4
+ // config(notepad 索引 / compact 重注入)共用,避免路径与段解析逻辑散落多处。
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { getSandboxRoot } from '../sandbox/root.js';
8
+ import { getCurrentSessionId } from './state.js';
9
+ /** 单个活跃 plan 允许的最大步骤数(防无限膨胀,对齐 Claude Code TodoWrite 的 20 上限)。 */
10
+ export const PLAN_MAX_STEPS = 20;
11
+ /** 当前会话 notes.md 的绝对路径;无会话时返 null。 */
12
+ export function getNotesFilePath(sessionId = getCurrentSessionId()) {
13
+ if (!sessionId)
14
+ return null;
15
+ const root = getSandboxRoot() ?? process.cwd();
16
+ return path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
17
+ }
18
+ /** notes.md 的 mtime(ms),不存在或不可读返 null。供 core 判断"本步是否改动了 notes.md"。 */
19
+ export function getNotesMtime(sessionId = getCurrentSessionId()) {
20
+ const p = getNotesFilePath(sessionId);
21
+ if (!p)
22
+ return null;
23
+ try {
24
+ return fs.statSync(p).mtimeMs;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * 把 plan 渲染为 canonical markdown 段(不含首尾多余空行)。
32
+ * 复选框格式 `- [ ] N.` / `- [x] N.` 与 repl 状态栏 chip 的计数正则严格对齐;
33
+ * in_progress 步骤追加 ` ◀ 当前` 后缀(仍计为未完成),让 compact 重注入后模型能直接续上。
34
+ * 全部完成时标题写为 `## Done:`(自动结算),extractActivePlanSection 随之返回 null。
35
+ */
36
+ export function renderPlanSection(plan) {
37
+ const allDone = plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed');
38
+ const header = allDone ? `## Done: ${plan.title}` : `## Plan: ${plan.title}`;
39
+ const lines = [header];
40
+ if (plan.goal)
41
+ lines.push(`Goal: ${plan.goal}`);
42
+ lines.push('', '### Steps');
43
+ plan.steps.forEach((s, i) => {
44
+ const box = s.status === 'completed' ? '[x]' : '[ ]';
45
+ const suffix = s.status === 'in_progress'
46
+ ? ` ◀ ${(s.activeForm ?? '').trim() || 'current'}`
47
+ : '';
48
+ lines.push(`- ${box} ${i + 1}. ${s.content}${suffix}`);
49
+ });
50
+ const done = plan.steps.filter((s) => s.status === 'completed').length;
51
+ lines.push('', '### Progress', `- ${done}/${plan.steps.length} steps complete`);
52
+ return lines.join('\n');
53
+ }
54
+ /** 读取当前活跃 `## Plan:` 段的标题;无活跃 plan 返 null。 */
55
+ export function readActivePlanTitle(sessionId = getCurrentSessionId()) {
56
+ const p = getNotesFilePath(sessionId);
57
+ if (!p)
58
+ return null;
59
+ try {
60
+ const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
61
+ const headerLine = normalized.split('\n').find((l) => /^## Plan:\s*.+$/.test(l));
62
+ return headerLine?.match(/^## Plan:\s*(.+)$/)?.[1].trim() ?? null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * 把 plan 写入 notes.md:替换已有活跃 `## Plan:` 段(原位),否则插到文件顶部;
70
+ * 其它所有笔记段(findings / Open Questions / 已结算 ## Done:)原样保留。
71
+ * 文件不存在则创建(含父目录)。返回 { path, settled } 或 { error }。
72
+ */
73
+ export function writePlanToNotes(plan, sessionId = getCurrentSessionId()) {
74
+ const p = getNotesFilePath(sessionId);
75
+ if (!p)
76
+ return { error: 'no active session' };
77
+ const section = renderPlanSection(plan);
78
+ let existing = '';
79
+ try {
80
+ existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
81
+ }
82
+ catch {
83
+ existing = '';
84
+ }
85
+ let next;
86
+ const lines = existing.split('\n');
87
+ const start = lines.findIndex((l) => /^## Plan:\s*.+$/.test(l));
88
+ if (start >= 0) {
89
+ const endOffset = lines.slice(start + 1).findIndex((l) => /^##\s/.test(l));
90
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
91
+ const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
92
+ const after = lines.slice(end).join('\n').replace(/^\s+/, '');
93
+ next = [before, section, after].filter((s) => s.length > 0).join('\n\n') + '\n';
94
+ }
95
+ else {
96
+ const rest = existing.trim();
97
+ next = rest ? `${section}\n\n${rest}\n` : `${section}\n`;
98
+ }
99
+ try {
100
+ fs.mkdirSync(path.dirname(p), { recursive: true });
101
+ fs.writeFileSync(p, next, 'utf8');
102
+ }
103
+ catch (e) {
104
+ return { error: e instanceof Error ? e.message : String(e) };
105
+ }
106
+ return { path: p, settled: plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed') };
107
+ }
@@ -12,6 +12,7 @@ import { webSearchTool } from './web-search.js';
12
12
  import { webFetchTool } from './web-fetch.js';
13
13
  import { useSkillTool } from './use-skill.js';
14
14
  import { askHumanTool } from './ask-human.js';
15
+ import { planUpdateTool } from './plan-update.js';
15
16
  import { memorySaveTool } from './memory-save.js';
16
17
  import { memorySearchTool } from './memory-search.js';
17
18
  import { memoryListTool } from './memory-list.js';
@@ -60,6 +61,9 @@ const CAPABILITIES = {
60
61
  web_fetch: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
61
62
  use_skill: { effect: 'read', concurrency: 'serial' },
62
63
  ask_human: { effect: 'read', concurrency: 'serial' },
64
+ // plan_update 只写内部 notes.md(session 工作面),不作为用户代码 mutation 追踪/回滚/diff;
65
+ // 串行即可(调用不频繁),固定资源键让并发调用排队。
66
+ plan_update: { effect: 'write', concurrency: 'serial', resources: () => ['session-notepad'] },
63
67
  memory_save: { effect: 'write', concurrency: 'serial', resources: memoryResource },
64
68
  memory_search: { effect: 'write', concurrency: 'serial', resources: memoryResource },
65
69
  memory_list: { effect: 'read', concurrency: 'serial', resources: memoryResource },
@@ -91,6 +95,7 @@ const rawBuiltinTools = [
91
95
  webFetchTool,
92
96
  useSkillTool,
93
97
  askHumanTool,
98
+ planUpdateTool,
94
99
  ..._memoryTools,
95
100
  subAgentTool,
96
101
  ];
@@ -0,0 +1,144 @@
1
+ import { PLAN_MAX_STEPS, readActivePlanTitle, renderPlanSection, writePlanToNotes, } from '../../session/notes.js';
2
+ const VALID_STATUSES = ['pending', 'in_progress', 'completed'];
3
+ function err(message) {
4
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: `错误:${message}` };
5
+ }
6
+ /** 归一化模型常见的 status 变体,降低调用摩擦(无损兼容,不补造业务值)。 */
7
+ function normalizeStatus(raw) {
8
+ const s = String(raw ?? 'pending').trim().toLowerCase();
9
+ if (['in_progress', 'in-progress', 'inprogress', 'doing', 'current', 'active', 'wip'].includes(s))
10
+ return 'in_progress';
11
+ if (['completed', 'complete', 'done', 'finished', 'checked'].includes(s))
12
+ return 'completed';
13
+ if (['pending', 'todo', 'to-do', 'open', 'not_started', 'not-started'].includes(s))
14
+ return 'pending';
15
+ return s;
16
+ }
17
+ export const planUpdateTool = {
18
+ name: 'plan_update',
19
+ description: 'Record and update the session execution plan (the `## Plan:` block in `.mocode/sessions/<id>/notes.md`). ' +
20
+ 'Use for any task with 3+ steps or context-loss risk. This REPLACES the whole plan each call, so always pass the full steps array. ' +
21
+ 'Rules: at most one step may be in_progress; mark a step completed as soon as its work is done — do not batch updates to end of turn. ' +
22
+ 'Write each step self-contained enough to survive context compaction: name the target file/symbol, the change, and how to verify. ' +
23
+ 'When every step is completed the plan auto-settles to `## Done:`. Creates notes.md if missing. Safe to call in PLAN mode (writes only the session notepad, never project files).',
24
+ risk: 'safe',
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {
28
+ title: {
29
+ type: 'string',
30
+ description: 'Plan title. Required when creating a plan; omit on update to keep the current title.',
31
+ },
32
+ goal: {
33
+ type: 'string',
34
+ description: 'One-line outcome / definition of done (optional).',
35
+ },
36
+ steps: {
37
+ type: 'array',
38
+ description: `Full replacement step list (1-${PLAN_MAX_STEPS}). At most one step may be in_progress.`,
39
+ items: {
40
+ type: 'object',
41
+ properties: {
42
+ content: {
43
+ type: 'string',
44
+ description: 'Self-contained step: target file/symbol, the change, and how to verify it.',
45
+ },
46
+ status: {
47
+ type: 'string',
48
+ enum: ['pending', 'in_progress', 'completed'],
49
+ description: 'pending | in_progress | completed',
50
+ },
51
+ active_form: {
52
+ type: 'string',
53
+ description: 'Optional present-continuous label shown while in_progress (e.g. "Rewriting parser").',
54
+ },
55
+ },
56
+ required: ['content', 'status'],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ },
61
+ required: ['steps'],
62
+ additionalProperties: false,
63
+ },
64
+ // 兼容模型的字段命名偏差:activeForm→active_form;step 用 text/task/description 代替 content。
65
+ normalizeArguments(args) {
66
+ if (args.activeForm !== undefined && args.active_form === undefined) {
67
+ args.active_form = args.activeForm;
68
+ delete args.activeForm;
69
+ }
70
+ if (Array.isArray(args.steps)) {
71
+ for (const s of args.steps) {
72
+ if (!s || typeof s !== 'object')
73
+ continue;
74
+ const step = s;
75
+ if (step.content === undefined) {
76
+ for (const alt of ['text', 'task', 'description', 'title']) {
77
+ if (typeof step[alt] === 'string') {
78
+ step.content = step[alt];
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ if (step.activeForm !== undefined && step.active_form === undefined) {
84
+ step.active_form = step.activeForm;
85
+ delete step.activeForm;
86
+ }
87
+ if (step.status !== undefined)
88
+ step.status = normalizeStatus(step.status);
89
+ }
90
+ }
91
+ },
92
+ async execute(args) {
93
+ const rawSteps = Array.isArray(args.steps) ? args.steps : null;
94
+ if (!rawSteps || rawSteps.length === 0)
95
+ return err('steps 不能为空(至少 1 步)。');
96
+ if (rawSteps.length > PLAN_MAX_STEPS)
97
+ return err(`steps 最多 ${PLAN_MAX_STEPS} 条,当前 ${rawSteps.length} 条——请合并或拆分阶段。`);
98
+ const steps = [];
99
+ let inProgressCount = 0;
100
+ for (let i = 0; i < rawSteps.length; i++) {
101
+ const raw = rawSteps[i];
102
+ const content = String(raw?.content ?? '').trim();
103
+ if (!content)
104
+ return err(`第 ${i + 1} 步 content 为空。`);
105
+ const status = normalizeStatus(raw?.status);
106
+ if (!VALID_STATUSES.includes(status)) {
107
+ return err(`第 ${i + 1} 步 status 非法:"${String(raw?.status)}"(仅 pending/in_progress/completed)。`);
108
+ }
109
+ if (status === 'in_progress')
110
+ inProgressCount++;
111
+ const activeForm = typeof raw?.active_form === 'string' && raw.active_form.trim()
112
+ ? raw.active_form.trim()
113
+ : undefined;
114
+ steps.push({ content, status, ...(activeForm ? { activeForm } : {}) });
115
+ }
116
+ if (inProgressCount > 1) {
117
+ return err(`同一时刻只能有一个 in_progress 步骤,当前 ${inProgressCount} 个——请把其余改回 pending。`);
118
+ }
119
+ // title:新建必填;更新时缺省则沿用当前活跃 plan 的标题。
120
+ let title = typeof args.title === 'string' ? args.title.trim() : '';
121
+ if (!title) {
122
+ const existing = readActivePlanTitle();
123
+ if (!existing)
124
+ return err('新建 plan 需要提供 title(更新已有 plan 时可省略)。');
125
+ title = existing;
126
+ }
127
+ const goal = typeof args.goal === 'string' && args.goal.trim() ? args.goal.trim() : undefined;
128
+ const plan = { title, ...(goal ? { goal } : {}), steps };
129
+ const result = writePlanToNotes(plan);
130
+ if ('error' in result) {
131
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: `错误:写入 notes.md 失败: ${result.error}` };
132
+ }
133
+ const done = steps.filter((s) => s.status === 'completed').length;
134
+ const rendered = renderPlanSection(plan);
135
+ const settledNote = result.settled ? '已全部完成,自动结算为 `## Done:`。' : '';
136
+ return {
137
+ status: 'success',
138
+ code: 'OK',
139
+ retryable: false,
140
+ // plan_update 写内部 notes.md,不作为用户代码 mutation 上报 changedFiles。
141
+ output: `已更新执行计划 "${title}"(${done}/${steps.length} 完成)${settledNote}\n\n${rendered}`,
142
+ };
143
+ },
144
+ };
@@ -1,5 +1,5 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
- import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
2
+ import { isMemoryEnabled, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
3
  export const MAX_FILE_LINES = 2000;
4
4
  export const MAX_OUTPUT = 20000;
5
5
  export const MAX_RESULTS = 100;
@@ -23,6 +23,18 @@ export const GC_DAYS = 90;
23
23
  /** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
24
24
  export const MAX_MEMORY_RESULT = 64000;
25
25
  export const IGNORE = ['**/node_modules/**', '**/.git/**'];
26
+ // ── 前端工具簇(默认关闭,显式开启)─────────────────────────────────────────
27
+ /**
28
+ * 前端开发相关工具簇:browser / dev_server 依赖 playwright 二进制且拉起长驻进程,
29
+ * screenshot 抓整个桌面(隐私敏感),view_image 仅视觉模型有用。这 4 个默认不进入
30
+ * 模型工具表,由 isFrontendToolsEnabled() 单一来源控制;/fe on|off 切换。
31
+ */
32
+ export const FRONTEND_TOOLS = new Set([
33
+ 'browser',
34
+ 'dev_server',
35
+ 'screenshot',
36
+ 'view_image',
37
+ ]);
26
38
  // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
27
39
  /**
28
40
  * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
@@ -50,15 +62,30 @@ export const PLAN_DISABLED_TOOLS = new Set([
50
62
  * 调用方(agent/core 串行分支、llm/planChatTools)每次 chat 时调本函数拿当前值。
51
63
  */
52
64
  export function getPlanDisabledTools() {
53
- if (isMemoryEnabled())
54
- return PLAN_DISABLED_TOOLS;
55
- const next = new Set(PLAN_DISABLED_TOOLS);
56
- next.delete('memory_save');
57
- next.delete('memory_update');
58
- next.delete('memory_forget');
65
+ const next = isMemoryEnabled()
66
+ ? new Set(PLAN_DISABLED_TOOLS)
67
+ : (() => {
68
+ const n = new Set(PLAN_DISABLED_TOOLS);
69
+ n.delete('memory_save');
70
+ n.delete('memory_update');
71
+ n.delete('memory_forget');
72
+ return n;
73
+ })();
74
+ // 前端工具簇关闭时,plan 模式 schema 也一并剔除(与 auto 模式一致)。
75
+ if (!isFrontendToolsEnabled()) {
76
+ for (const name of FRONTEND_TOOLS)
77
+ next.add(name);
78
+ }
59
79
  return next;
60
80
  }
61
81
  /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
62
82
  export function getRuntimeDisabledTools() {
63
- return isSubAgentEnabled() ? new Set() : new Set(['sub-agent']);
83
+ const disabled = new Set();
84
+ if (!isSubAgentEnabled())
85
+ disabled.add('sub-agent');
86
+ if (!isFrontendToolsEnabled()) {
87
+ for (const name of FRONTEND_TOOLS)
88
+ disabled.add(name);
89
+ }
90
+ return disabled;
64
91
  }
package/dist/ui/batch.js CHANGED
@@ -249,6 +249,9 @@ export function expandSingleEntryFully(id, layout) {
249
249
  ...buildEntryDetailLines(b.entries[0], entryDetailIndent(b.entries, 0)),
250
250
  ];
251
251
  layout.contentInsertAfter(b.summaryAbsIdx, lines);
252
+ // 修复:contentInsertAfter 不再 commit was-current(避免 collapse 后留孤儿空行);
253
+ // mutation 路径不再由 flushToolBatch 写 \n separator,所以这里手动补一个 \n。
254
+ layout.contentWrite('\n');
252
255
  expandedBatches.add(id);
253
256
  b.expandedEntries.add(0);
254
257
  absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
@@ -227,18 +227,21 @@ export function lastUserMessageBefore(absStart) {
227
227
  * 若 segMark 活跃且插入点在 segMark.rowIdx 之前,则 segMark.rowIdx 跟着平移(否则
228
228
  * 流式 md 段活跃期间展开/折叠 batch 后,下一个 md chunk 的 setLines 截断到错误位置)。
229
229
  *
230
- * 后置条件:插入后 hasCurrent=false(新空行不由本函数建立);调用方须自行决定续写位
231
- * (BatchRenderer 在插入后调 layout.contentWrite 续写,新 \n 自然在详情块后建新行)。
230
+ * 后置条件:插入后 hasCurrent 保持不变(若进来时 hasCurrent=true,则仍为 true),
231
+ * curRaw 也保持不变 —— cursor 概念上跟到 inserted block 末尾,后续 contentWrite 自然续写。
232
+ * 关键:不再"先 commit 当前行到 rows 末尾再 splice",那样会在 rows 末尾留下
233
+ * 一条 was-current 孤儿空行,在后续 collapse 删除 inserted lines 时无法被对称清掉,
234
+ * 导致下一段文本与该批之间多出 1 行视觉空白(用户报告:「展开又关闭工具信息后
235
+ * 下面空两行」)。现在 leave cursor 不动,由调用方按需补 separator。
236
+ * 例外:expandSingleEntryFully (mutation 自动展开) 在调用本函数后
237
+ * 主动 layout.contentWrite('\n') 写 separator blank,
238
+ * 因为 mutation 不走 flushToolBatch 的 \n。
232
239
  */
233
240
  export function insertAfter(after, lines) {
234
241
  if (lines.length === 0)
235
242
  return;
236
- if (hasCurrent) {
237
- rows.push(rowStartSgr + curRaw + '\x1B[0m');
238
- curRaw = '';
239
- rowStartSgr = curSgr;
240
- hasCurrent = false;
241
- }
243
+ // 修复:不再提交 was-current 行到 rows 末尾(避免后续 collapse 后留孤儿空白)
244
+ // cursor 留在原位置(指向 spliced block 之后);调用方按需显式 contentWrite('\n') 补 separator。
242
245
  const committed = rows.length;
243
246
  // after 是绝对行索引;若超过 committed(例如快照时 hasCurrent=true),钳到末尾
244
247
  const target = after < 0 ? 0 : Math.min(after + 1, committed);
@@ -254,19 +257,19 @@ export function insertAfter(after, lines) {
254
257
  /**
255
258
  * 从绝对行索引 startIdx(0-based,已 commit)起删 n 行。
256
259
  * 用于「已展开明细折回摘要」——把详情行从中段裁掉,保留摘要行和后续内容。
257
- * startIdx 越界或 n <= 0 直接 no-op。hasCurrent 时先 commit(同 insertAfter)。
260
+ * startIdx 越界或 n <= 0 直接 no-op。
258
261
  *
259
- * 后置条件:删除后 hasCurrent=false。后续 layout.contentWrite 自然续写。
262
+ * 不动当前行(同 insertAfter 修复后的语义):cursor 概念上仍指向 spliced
263
+ * 区间后的同一绝对位置(若 startIdx+1+lines 数 ≥ 新 rows.length,curRaw
264
+ * 代表的就是 spliced 区间内的逻辑行,内容保留)。
265
+ * 后置条件:hasCurrent 与 curRaw 与调用前一致。
260
266
  */
261
267
  export function deleteFrom(startIdx, n) {
262
268
  if (n <= 0)
263
269
  return;
264
- if (hasCurrent) {
265
- rows.push(rowStartSgr + curRaw + '\x1B[0m');
266
- curRaw = '';
267
- rowStartSgr = curSgr;
268
- hasCurrent = false;
269
- }
270
+ // 修复:保持与 insertAfter 对称——不动 hasCurrent/curRaw,避免 splice 后
271
+ // was-current 空行当成普通行 commit 变成孤儿(与 insertAfter 的孤儿
272
+ // bug 是同一个根因的两个对称面)。
270
273
  const committed = rows.length;
271
274
  if (startIdx >= committed)
272
275
  return;
package/dist/ui/layout.js CHANGED
@@ -83,11 +83,12 @@ let statusText = '';
83
83
  let spinnerFrame;
84
84
  let turnStart = null; // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
85
85
  let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
86
- // 运行态状态行 chip 心跳帧(♥/♡ 明灭)。turnTimer 每 tick 推进一帧,让状态行前导符在 agent
87
- // 运行时跳动——agent spinner 走内容区续写位(paintLiveAtCursor),不调 setStatus,
88
- // 故状态行 chip turnTimer 独立驱动。INPUT runningFrame=-1,composeStatus 退回静态 ●。
86
+ /** 当前 plan chip 占用脚栏行数(1 或 2)。composePlanLines 每次重算后写入。
87
+ * 驱动 paintInput setRegion(fh) 动态撑高脚栏;drawStatusBar 据此画 1/2 行 plan,
88
+ * 与 spinner/上线/输入/下线/model 行的 +1/+2/+3/+4/+5 偏移天然一致(contentBottom 自动重算)。 */
89
+ let planRows = 1;
90
+ let runningFrame = -1; // 运行态状态行 chip 心跳帧(♥/♡ 明灭);INPUT 态 -1 退回静态 ●
89
91
  const RUNNING_FRAMES = ['♥', '♡'];
90
- let runningFrame = -1;
91
92
  // 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
92
93
  // 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。
93
94
  let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
@@ -179,12 +180,12 @@ export function setRegion(fh) {
179
180
  contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
180
181
  return g;
181
182
  }
182
- /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+4(resize 安全)。
183
+ /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+3+planRows(resize 安全)。
183
184
  * 非 dim 运行态(与空闲态同色、可任意位置编辑)→ 归当前编辑位,供 IME 锚定气泡到光标处;
184
185
  * dim 占位(空 + placeholder)兼容态→ 归输入框起点。供 IME 锚定。 */
185
186
  function runningCaretPos() {
186
187
  const g = getGeo();
187
- const row = g.contentBottom + 4;
188
+ const row = g.contentBottom + 3 + planRows;
188
189
  if (lastView && !lastView.dim) {
189
190
  const promptW = displayWidth(lastView.prompt);
190
191
  const line = lastView.lines[lastView.cursorLine] ?? '';
@@ -956,8 +957,8 @@ function clearInputSelection() {
956
957
  /** 屏行是否落在底栏输入行范围内(paintInput 的 firstInputRow..firstInputRow+inputRowsAvail-1)。 */
957
958
  function isInputRow(row) {
958
959
  const g = getGeo();
959
- const firstInputRow = g.contentBottom + 4;
960
- const inputRowsAvail = Math.max(0, g.footerH - 5);
960
+ const firstInputRow = g.contentBottom + 3 + planRows;
961
+ const inputRowsAvail = Math.max(0, g.footerH - 4 - planRows);
961
962
  return row >= firstInputRow && row < firstInputRow + inputRowsAvail;
962
963
  }
963
964
  /** 右键单击输入行(未拖动的 press→release):读剪贴板 + 回调 pasteHandler 贴入。异步但不阻塞其他事件。 */
@@ -1023,8 +1024,8 @@ function inputScreenToInputPos(screenRow, screenCol) {
1023
1024
  return null;
1024
1025
  const g = getGeo();
1025
1026
  const promptW = displayWidth(lastView.prompt);
1026
- const firstInputRow = g.contentBottom + 4;
1027
- const inputRowsAvail = Math.max(0, g.footerH - 5);
1027
+ const firstInputRow = g.contentBottom + 3 + planRows;
1028
+ const inputRowsAvail = Math.max(0, g.footerH - 4 - planRows);
1028
1029
  // 输入框可视区的 (visRow, visCol) 屏幕坐标 → 0-based。
1029
1030
  // 点到可视区末行之下(空白区)→ 落到最末可视行(光标归最后一段);isInputRow 已挡可视区之上的点击。
1030
1031
  const visRow = Math.max(0, Math.min(screenRow - firstInputRow, Math.max(0, inputRowsAvail - 1)));
@@ -1454,34 +1455,62 @@ function formatLiveUsageChip(u) {
1454
1455
  return `${ui.dim}↑ ${fmt(billable)} ↓ ${fmt(u.completionTokens)}${cacheTag}${ui.reset}`;
1455
1456
  }
1456
1457
  /** spinner 行上方的「虚拟空行」(contentBottom+1)。
1457
- * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow + dim)
1458
+ * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow)
1458
1459
  * - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
1460
+ * - 过长(> cols):始终**截断保 1 行 + "…"**,不撑高脚栏、不拆 2 行——
1461
+ * 步进式任务标题/current step 通常是自然语言,即使切「 ▸ 」拆 2 行,第 2 行也大概率仍超长
1462
+ * (实测「第三步:实现核心业务模块。…」本身就比 cols 长),拆完依然覆盖 spinner/输入区,得不偿失。
1463
+ * 截断 + "…" 一行内永远不溢出,脚栏恒 6 行,spinner/输入/下线/model 行偏移稳定。
1459
1464
  * 这行在 DECSTBM 滚动区外([1, contentBottom]),稳定不滚。 */
1460
- function composePlanLine(status, cols) {
1465
+ function composePlanLines(status, cols) {
1461
1466
  const plan = (status.planSummary ?? '').trim();
1462
- if (!plan)
1463
- return ''; // 无 plan:画空,等 paint 路径 clearLine
1464
- // 整行左对齐,不留右段(plan 自带进度信息,不需要 cwd)
1465
- return `${ui.yellow}${plan}${ui.reset}`;
1467
+ if (!plan) {
1468
+ planRows = 1;
1469
+ return [];
1470
+ }
1471
+ const w = displayWidth(stripAnsi(plan));
1472
+ if (w <= cols) {
1473
+ planRows = 1;
1474
+ return [`${ui.yellow}${plan}${ui.reset}`];
1475
+ }
1476
+ // 溢出:截断为 1 行 + "…" 后缀(留 1 列空间给省略号)。
1477
+ planRows = 1;
1478
+ return [`${ui.yellow}${truncateDisplay(plan, Math.max(1, cols - 1))}…${ui.reset}`];
1466
1479
  }
1467
1480
  /** 画状态行(plan 行 + spinner 行 + model 行,三行)。RUNNING 态 spinner 频繁调。
1468
- * 行号(footerH=6):
1469
- * plan 行 = contentBottom+1 (活跃 plan 时显 chip;无则空)
1470
- * spinner = contentBottom+2 (● 空闲 / ⠹ 思考中… / etc)
1471
- * 上线 = contentBottom+3 (画在 paintInput)
1472
- * 输入行 = contentBottom+4
1473
- * 下线 = contentBottom+5
1474
- * model 行 = rows (屏底:auto + ctx + cwd) */
1481
+ * 行号(footerH=6 当 plan 单行;footerH=7 当 plan 撑 2 行):
1482
+ * plan 行 = contentBottom+1 (单行)
1483
+ * plan 第 2 = contentBottom+2 (双行,可选)
1484
+ * spinner 行 = contentBottom+1+planRows ( 空闲 / ⠹ 思考中… / etc)
1485
+ * 上线 = contentBottom+2+planRows (画在 paintInput)
1486
+ * 输入行 = contentBottom+3+planRows
1487
+ * 下线 = contentBottom+4+planRows
1488
+ * model 行 = rows (屏底:auto + ctx + cwd) */
1475
1489
  export function drawStatusBar(status) {
1476
1490
  if (!active || !base)
1477
1491
  return;
1478
1492
  const s = status ?? { ...base, status: statusText, spinnerFrame };
1479
1493
  const g = getGeo();
1480
- const planRow = g.contentBottom + 1;
1481
- const spinnerRow = g.contentBottom + 2;
1494
+ const planLines = composePlanLines(s, g.cols);
1495
+ const planRow1 = g.contentBottom + 1;
1496
+ const spinnerRow = g.contentBottom + 1 + planRows;
1482
1497
  const modelRow = g.rows; // 屏底:model 行
1483
- // 一次写入:三行 cup+clear+内容,末尾 cup 回续写位/输入框光标
1484
- let out = cup(planRow, 1) + esc.clearLine + composePlanLine(s, g.cols) +
1498
+ // plan 可能占 1 或 2 行(过长自动撑开);spinner/model 行随之平移。
1499
+ // 一次写入:plan 1/2 + spinner + model 行,末尾 cup 回续写位/输入框光标。
1500
+ let planBuf = '';
1501
+ for (let i = 0; i < planLines.length; i++) {
1502
+ planBuf += cup(planRow1 + i, 1) + esc.clearLine + planLines[i];
1503
+ }
1504
+ // 计划行从 2 行降到 1 行时,清掉残留的第 2 行(撑高后回退不留尾巴)。
1505
+ if (planRows === 1 && g.footerH > 6) {
1506
+ planBuf += cup(planRow1 + 1, 1) + esc.clearLine;
1507
+ }
1508
+ // plan 从非空变成空(已结算为 ## Done:/ notes 里无活跃 ## Plan: 段)时,清掉残留的 plan 行 1。
1509
+ // 否则上轮渲染的「plan: 标题 (N/M) ▸ 当前步」会卡在底栏「虚拟空行」位置直到下一次重启 REPL。
1510
+ if (planLines.length === 0) {
1511
+ planBuf += cup(planRow1, 1) + esc.clearLine;
1512
+ }
1513
+ let out = planBuf +
1485
1514
  cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
1486
1515
  cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
1487
1516
  if (mode === 'running') {
@@ -1743,7 +1772,7 @@ export function paintInput(view) {
1743
1772
  startVis: 0,
1744
1773
  }
1745
1774
  : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
1746
- const needFooterH = 5 + vis.inputRows; // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行
1775
+ const needFooterH = 5 + vis.inputRows + (planRows - 1); // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行 + plan 多出的行数
1747
1776
  let g = preGeo;
1748
1777
  if (needFooterH !== footerH) {
1749
1778
  // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
@@ -1764,23 +1793,35 @@ export function paintInput(view) {
1764
1793
  // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):
1765
1794
  // - 无活跃 plan:清空保留作分隔(原设计)
1766
1795
  // - 有活跃 plan:渲染 plan chip(整帧重画时也要更新,避免 listener 漏触发后残留)
1796
+ // - plan 过长:占 2 行(planRows=2),spinner/上线/输入行随之整体下移 1 行
1797
+ // paintInput 与 drawStatusBar 共享同一 planRows 模块态,setRegion 已据此把脚栏撑高。
1767
1798
  {
1768
- const plan = (base.planSummary ?? '').trim();
1769
- buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
1770
- if (plan)
1771
- buf += `${ui.yellow}${plan}${ui.reset}`;
1799
+ const planBuf = composePlanLines(base, preGeo.cols);
1800
+ for (let i = 0; i < planBuf.length; i++) {
1801
+ buf += cup(g.contentBottom + 1 + i, 1) + esc.clearLine + planBuf[i];
1802
+ }
1803
+ // 计划行从 2 行降到 1 行(plan 当前很短):清掉残留的第 2 行,不留尾巴。
1804
+ if (planBuf.length < planRows && planRows === 2) {
1805
+ buf += cup(g.contentBottom + 2, 1) + esc.clearLine;
1806
+ planRows = 1;
1807
+ }
1808
+ // plan 从非空变成空(已结算/无活跃段)时,清掉残留的 plan 行 1。
1809
+ // 上面 for 循环在 planBuf.length===0 时不写任何 cup,需要显式清一行回到「虚拟空行」。
1810
+ if (planBuf.length === 0) {
1811
+ buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
1812
+ }
1772
1813
  }
1773
1814
  // 3. 状态行:spinner 行 + model 行(两行式底栏)
1774
- const spinnerRow = g.contentBottom + 2; // +1 虚拟空行(plan ),+2 spinner
1815
+ const spinnerRow = g.contentBottom + 1 + planRows; // plan 占 planRows 行(1 或 2),spinner 紧跟其后
1775
1816
  const modelRow = g.rows; // 屏底:model 行
1776
1817
  const status = { ...base, status: statusText, spinnerFrame };
1777
1818
  buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
1778
1819
  buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
1779
1820
  // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
1780
- buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1781
- // 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
1782
- const firstInputRow = g.contentBottom + 4;
1783
- const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
1821
+ buf += cup(g.contentBottom + 2 + planRows, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1822
+ // 4. 输入行(g.contentBottom+3+planRows .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
1823
+ const firstInputRow = g.contentBottom + 3 + planRows;
1824
+ const inputRowsAvail = vis.inputRows; // 脚栏总高 - 1(spinner) - 1(上 sep) - 1(下 sep) - 1(model) - planRows
1784
1825
  const indent = ' '.repeat(promptW);
1785
1826
  // 光标:不画反白块/假光标——输入框走终端真光标(WT / VSCode 终端默认竖线/闪烁块,
1786
1827
  // 各终端表现略不同但都贴合 IME 候选气泡且不再"挡住字符")。真光标位置由下方第 6 步 cup 写。
@@ -1919,7 +1960,7 @@ export function paintRunningInput(text, cursor, placeholder) {
1919
1960
  if (!active || !base)
1920
1961
  return;
1921
1962
  const g = getGeo();
1922
- const inputRow = g.contentBottom + 4; // 运行态 footerH 6(单行,无 setRegion)
1963
+ const inputRow = g.contentBottom + 3 + planRows; // 运行态 footerH plan 行数动态(6 或 7)
1923
1964
  const promptW = displayWidth('❯ ');
1924
1965
  const contentW = Math.max(1, g.cols - promptW);
1925
1966
  let outLine;
@@ -1976,7 +2017,10 @@ export function enterInputMode(status = t('repl.idle')) {
1976
2017
  repaintViewport();
1977
2018
  }
1978
2019
  if (active && base) {
1979
- setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
2020
+ // 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
2021
+ // 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
2022
+ composePlanLines(base, (getGeo()).cols);
2023
+ setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
1980
2024
  paintInput({
1981
2025
  prompt: '❯ ',
1982
2026
  lines: [''],
@@ -1996,7 +2040,10 @@ export function enterRunningMode(status, placeholder) {
1996
2040
  resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
1997
2041
  lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
1998
2042
  if (active && base) {
1999
- setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行
2043
+ // 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
2044
+ // 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
2045
+ composePlanLines(base, (getGeo()).cols);
2046
+ setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
2000
2047
  paintInput({
2001
2048
  prompt: '❯ ',
2002
2049
  lines: [''],
@@ -2021,7 +2068,11 @@ export function enterAltScreen() {
2021
2068
  applyTerminalBackground();
2022
2069
  stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
2023
2070
  mouse.setHandler(handleMouseEvent);
2024
- setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
2071
+ // 进入 alt screen base 可能已设了 planSummary;按当前 planSummary 重算 planRows,
2072
+ // 让首次 setRegion 撑出正确脚栏高(否则 plan 撑 2 行时会被 spinner 行覆盖)。
2073
+ if (base)
2074
+ composePlanLines(base, (getGeo()).cols);
2075
+ setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
2025
2076
  contentRow = 1;
2026
2077
  contentCol = 1;
2027
2078
  segmentStartRow = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {