mocode-ai 1.6.5 → 1.6.7

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 (45) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +57 -30
  3. package/dist/agent/run-coordinator.js +2 -1
  4. package/dist/agent/tool-turn.js +2 -0
  5. package/dist/bots/bus.js +117 -0
  6. package/dist/bots/cli.js +102 -0
  7. package/dist/bots/store.js +100 -0
  8. package/dist/config/index.js +2 -1
  9. package/dist/headless.js +302 -0
  10. package/dist/index.js +171 -3
  11. package/dist/jobs/approval.js +153 -0
  12. package/dist/jobs/approve-cli.js +30 -0
  13. package/dist/jobs/attach.js +61 -0
  14. package/dist/jobs/checkpoint.js +33 -0
  15. package/dist/jobs/launch.js +107 -0
  16. package/dist/jobs/runner.js +59 -0
  17. package/dist/jobs/store.js +101 -0
  18. package/dist/jobs/worktree.js +63 -0
  19. package/dist/notify/index.js +144 -0
  20. package/dist/repl/commands/bots.js +32 -0
  21. package/dist/repl/commands/jobs.js +109 -0
  22. package/dist/repl/commands/model.js +5 -20
  23. package/dist/repl/commands/registry.js +6 -0
  24. package/dist/repl/commands/schedule.js +37 -0
  25. package/dist/repl/commands.js +3 -58
  26. package/dist/schedule/cli.js +114 -0
  27. package/dist/schedule/cron.js +79 -0
  28. package/dist/schedule/daemon.js +166 -0
  29. package/dist/schedule/store.js +98 -0
  30. package/dist/session/scheduler.js +0 -3
  31. package/dist/session/store.js +1 -1
  32. package/dist/tools/builtins/arena.js +156 -0
  33. package/dist/tools/builtins/glob.js +64 -16
  34. package/dist/tools/builtins/grep.js +176 -122
  35. package/dist/tools/builtins/index.js +13 -2
  36. package/dist/tools/builtins/message-bus.js +98 -0
  37. package/dist/tools/builtins/run-command.js +2 -1
  38. package/dist/tools/constants.js +11 -0
  39. package/dist/tools/ripgrep-client.js +180 -0
  40. package/dist/ui/batch.js +31 -1
  41. package/dist/ui/composer.js +4 -18
  42. package/dist/ui/fuzzy-picker.js +1 -3
  43. package/dist/ui/intervention.js +6 -0
  44. package/dist/ui/prompt-internal/editor.js +10 -27
  45. package/package.json +3 -2
@@ -0,0 +1,180 @@
1
+ // ripgrep 子进程客户端:grep 工具的快速路径。
2
+ //
3
+ // 为什么不是 Node 逐文件读:fast-glob 枚举 + readFile 全量进内存 + 逐行正则是串行的,
4
+ // .venv / node_modules 这类目录能把一次全库 grep 拖到 7 分钟以上(Windows Defender 还会
5
+ // 对每个被读文件再扫一遍)。ripgrep 是流式并行目录遍历,默认尊重 .gitignore、跳过二进制
6
+ // 与隐藏目录,速度快一到两个数量级。二进制随 @vscode/ripgrep 按平台安装,不依赖用户 PATH。
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
10
+ import { IGNORE } from './constants.js';
11
+ import { getSandboxRoot, isInsideRoot } from '../sandbox/index.js';
12
+ const require = createRequire(import.meta.url);
13
+ const isWin = process.platform === 'win32';
14
+ /** JSON 采集上限:约可容纳数万条命中,超出即杀进程并标 truncated。 */
15
+ const MAX_JSON_BYTES = 8 * 1024 * 1024;
16
+ /**
17
+ * 定位 rg 可执行文件:MOCODE_RG_PATH 显式覆盖 → @vscode/ripgrep 随包二进制 → PATH 上的 rg。
18
+ * 返 null = 三条路都没有,调用方应退 Node 兜底。
19
+ */
20
+ export function resolveRgPath() {
21
+ const override = process.env.MOCODE_RG_PATH;
22
+ if (override && existsSync(override))
23
+ return override;
24
+ try {
25
+ const mod = require('@vscode/ripgrep');
26
+ if (typeof mod.rgPath === 'string' && existsSync(mod.rgPath))
27
+ return mod.rgPath;
28
+ }
29
+ catch {
30
+ // 可选依赖没装上(如 --no-optional / 不支持的平台),落到 PATH 探测。
31
+ }
32
+ const probe = spawnSync(isWin ? 'rg.exe' : 'rg', ['--version'], { stdio: 'ignore', windowsHide: true });
33
+ return probe.status === 0 ? (isWin ? 'rg.exe' : 'rg') : null;
34
+ }
35
+ function killTree(child) {
36
+ try {
37
+ if (isWin && child.pid != null) {
38
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
39
+ }
40
+ else {
41
+ child.kill('SIGTERM');
42
+ }
43
+ }
44
+ catch {
45
+ // 进程已退出或终止失败,尽力而为。
46
+ }
47
+ }
48
+ /** 去掉 rg 路径前缀 ./ 并把反斜杠归一为 /(与旧 fast-glob 输出口径一致)。 */
49
+ function normalizePath(p) {
50
+ return p.replace(/^\.[\\/]/, '').replace(/\\/g, '/');
51
+ }
52
+ /**
53
+ * 跑一次 ripgrep(--json NDJSON),聚合成每文件事件。永不抛错:
54
+ * 进程起不来 → unavailable;非法正则 → regex_error;signal 中止 → aborted。
55
+ */
56
+ export function runRipgrep(params, signal) {
57
+ const rgPath = resolveRgPath();
58
+ if (!rgPath || signal?.aborted)
59
+ return Promise.resolve({ status: 'aborted' });
60
+ const cwd = getSandboxRoot() ?? process.cwd();
61
+ // --no-require-git:rg 默认只在 git 仓库内才读 .gitignore;加上后非 git 项目的 .gitignore
62
+ // 同样生效(实测不加时 ignored/ 会被扫到)。
63
+ const args = ['--json', '--hidden', '--no-require-git', '--path-separator', '/'];
64
+ if (params.context > 0)
65
+ args.push('-C', String(params.context));
66
+ if (params.glob && params.glob !== '**/*')
67
+ args.push('-g', params.glob);
68
+ for (const entry of IGNORE)
69
+ args.push('-g', `!${entry}`);
70
+ args.push('-e', params.pattern);
71
+ return new Promise((resolve) => {
72
+ const child = spawn(rgPath, args, {
73
+ cwd,
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ windowsHide: true,
76
+ });
77
+ let settled = false;
78
+ let collected = 0;
79
+ let truncated = false;
80
+ let stderr = '';
81
+ let buffer = '';
82
+ const byPath = new Map();
83
+ let scanned = 0;
84
+ const finish = (result) => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ signal?.removeEventListener('abort', onAbort);
89
+ resolve(result);
90
+ };
91
+ const onAbort = () => {
92
+ killTree(child);
93
+ finish({ status: 'aborted' });
94
+ };
95
+ const handleLine = (line) => {
96
+ let evt;
97
+ try {
98
+ evt = JSON.parse(line);
99
+ }
100
+ catch {
101
+ return; // 末行被截断,忽略
102
+ }
103
+ const data = evt.data;
104
+ if (!data)
105
+ return;
106
+ if (evt.type === 'end') {
107
+ scanned++;
108
+ return;
109
+ }
110
+ if (evt.type !== 'match' && evt.type !== 'context')
111
+ return;
112
+ const pathText = data.path?.text;
113
+ const lineNo = data.line_number;
114
+ const linesText = data.lines?.text;
115
+ if (typeof pathText !== 'string' || typeof lineNo !== 'number' || typeof linesText !== 'string')
116
+ return;
117
+ const path = normalizePath(pathText);
118
+ if (!isInsideRoot(path))
119
+ return; // 后置兜底:仅留牢内
120
+ let bucket = byPath.get(path);
121
+ if (!bucket) {
122
+ bucket = { path, matches: [], contexts: [] };
123
+ byPath.set(path, bucket);
124
+ }
125
+ const event = { line: lineNo, text: linesText.replace(/\r?\n$/, '') };
126
+ if (evt.type === 'match')
127
+ bucket.matches.push(event);
128
+ else
129
+ bucket.contexts.push(event);
130
+ };
131
+ child.stdout.on('data', (chunk) => {
132
+ if (settled)
133
+ return;
134
+ collected += chunk.length;
135
+ if (collected > MAX_JSON_BYTES && !truncated) {
136
+ truncated = true;
137
+ killTree(child); // 输出已超预算,没必要继续
138
+ return;
139
+ }
140
+ buffer += chunk.toString('utf8');
141
+ let idx;
142
+ while ((idx = buffer.indexOf('\n')) >= 0) {
143
+ const line = buffer.slice(0, idx);
144
+ buffer = buffer.slice(idx + 1);
145
+ if (line)
146
+ handleLine(line);
147
+ }
148
+ });
149
+ child.stderr.on('data', (chunk) => {
150
+ stderr += chunk.toString('utf8');
151
+ if (stderr.length > 4000)
152
+ stderr = stderr.slice(-4000);
153
+ });
154
+ child.on('error', (error) => {
155
+ // ENOENT / EACCES / exec 格式错误 = 二进制不可用,交给 Node 兜底。
156
+ const code = error.code;
157
+ finish({ status: code === 'ENOENT' || code === 'EACCES' ? 'unavailable' : 'aborted' });
158
+ });
159
+ child.on('close', (code) => {
160
+ if (buffer.trim())
161
+ handleLine(buffer.trim());
162
+ // exit 2 + stderr = 正则/参数错误;无命中事件时按 regex_error 上报。
163
+ if (code === 2 && byPath.size === 0 && stderr.trim()) {
164
+ finish({ status: 'regex_error', message: stderr.trim() });
165
+ return;
166
+ }
167
+ if (byPath.size === 0) {
168
+ finish({ status: 'no-match', scanned, truncated });
169
+ return;
170
+ }
171
+ finish({ status: 'ok', files: [...byPath.values()], scanned, truncated });
172
+ });
173
+ if (signal) {
174
+ if (signal.aborted)
175
+ onAbort();
176
+ else
177
+ signal.addEventListener('abort', onAbort, { once: true });
178
+ }
179
+ });
180
+ }
package/dist/ui/batch.js CHANGED
@@ -72,6 +72,8 @@ export function reset() {
72
72
  expandedBatches.clear();
73
73
  callToBatch.clear();
74
74
  groupChildIndexByCall.clear();
75
+ // 复位外部暂停态(兜底:面板异常退出未走 cleanup 时,避免暂停泄漏到后续 turn)。
76
+ _sweepSuspended = false;
75
77
  // 扫光心跳随运行态批自停:batch 全清后 hasRunningBatch 必为 false。
76
78
  syncSweepTimer();
77
79
  }
@@ -234,6 +236,12 @@ let _sweepTimer = null;
234
236
  * 存 layout 而不自己 import 判断 TUI 是否激活,是沿用「agent 侧注入」的既有形态。
235
237
  */
236
238
  let _sweepLayout = null;
239
+ /**
240
+ * 扫光被外部暂停(介入面板 ask_human 期间):摘要行已在内容区底部,而菜单同样从
241
+ * contentBottom 向上展开,心跳若继续逐帧重画该行会写进菜单区、把「◇ 正在探索」
242
+ * 泄漏到选项之间(用户截图实证)。暂停只冻结心跳,不清状态;resumeSweep 后立即恢复。
243
+ */
244
+ let _sweepSuspended = false;
237
245
  /**
238
246
  * 批是否「在飞」(需要扫光与实时耗时)。判据是**已收口即落定**:
239
247
  * `endBatch` 必设 finishedAt,故先看它 —— 否则任何「endBatch 了但 running 标志残留」
@@ -257,7 +265,7 @@ function hasLiveBatch() {
257
265
  }
258
266
  /** 扫光可用性:TUI(非 TTY 输出纯文本,不能塞动画转义)+ 有在飞批 + 有可写回的 layout。 */
259
267
  function sweepEnabled() {
260
- return ui.isTTY && _sweepLayout != null && hasLiveBatch();
268
+ return !_sweepSuspended && ui.isTTY && _sweepLayout != null && hasLiveBatch();
261
269
  }
262
270
  /**
263
271
  * 同步扫光心跳开关(建批/收批时调)。心跳随最后一个运行态批结束自停,不常驻空转。
@@ -302,6 +310,28 @@ export function syncSweepTimer() {
302
310
  return timer;
303
311
  })();
304
312
  }
313
+ /**
314
+ * 暂停扫光心跳(介入面板进入时调):syncSweepTimer 经 sweepEnabled 判定立即清 timer;
315
+ * 暂停期间 showLiveBatch/endBatch 再调 syncSweepTimer 也不会重启。幂等:重复挂起不抖状态。
316
+ */
317
+ export function suspendSweep() {
318
+ if (_sweepSuspended)
319
+ return;
320
+ _sweepSuspended = true;
321
+ syncSweepTimer();
322
+ }
323
+ /** 恢复扫光(介入面板退出时调):若仍有在飞批则心跳立即重建;无在飞批则保持停止。幂等。 */
324
+ export function resumeSweep() {
325
+ if (!_sweepSuspended)
326
+ return;
327
+ _sweepSuspended = false;
328
+ syncSweepTimer();
329
+ }
330
+ /** 测试钩子:扫光当前是否处于外部暂停态(介入面板泄漏回归用)。 */
331
+ export const __sweepTest = {
332
+ isSuspended: () => _sweepSuspended,
333
+ timerActive: () => _sweepTimer !== null,
334
+ };
305
335
  /**
306
336
  * 计算本帧高亮带覆盖的字符区间 `[start, end)`(纯函数,导出供帧推进/带宽测试)。
307
337
  *
@@ -344,22 +344,6 @@ export async function promptComposer(opts = {}) {
344
344
  }
345
345
  return rows.length - 1;
346
346
  }
347
- /** 上下移动一行(按展示行)。 */
348
- function moveVertically(delta, extend) {
349
- const rows = dispRows();
350
- const idx = cursorDispIdx(rows);
351
- const target = Math.max(0, Math.min(rows.length - 1, idx + delta));
352
- const r = rows[target];
353
- if (!r)
354
- return;
355
- // 保持视觉列:当前光标视觉列 → 目标展示行同列
356
- const curRow = rows[idx];
357
- const curText = curRow ? curRow.text : '';
358
- const curOff = cur.col - (curRow ? curRow.start : 0);
359
- const vis = displayWidth([...curText].slice(0, Math.max(0, curOff)).join(''));
360
- const charCol = visColToCharCol(r.text, vis);
361
- moveTo({ line: r.li, col: r.start + charCol }, extend);
362
- }
363
347
  function moveByPage(delta, extend) {
364
348
  const rows = dispRows();
365
349
  const visible = Math.max(1, textRowCount());
@@ -716,10 +700,12 @@ export async function promptComposer(opts = {}) {
716
700
  }
717
701
  return;
718
702
  case 'up':
719
- moveVertically(-1, extend);
703
+ // ↑ 始终跳到全文最前(Shift 扩展选区)
704
+ moveTo({ line: 0, col: 0 }, extend);
720
705
  return;
721
706
  case 'down':
722
- moveVertically(1, extend);
707
+ // ↓ 始终跳到全文最后(Shift 扩展选区)
708
+ moveTo({ line: lines.length - 1, col: lineLen(lines.length - 1) }, extend);
723
709
  return;
724
710
  case 'home':
725
711
  if (key.ctrl) {
@@ -45,7 +45,6 @@ export async function promptFuzzyPicker(opts) {
45
45
  let cursor = query.length;
46
46
  let matches = [];
47
47
  let selected = 0; // 在 matches 中的下标(可能落在禁用项,移动时跳过)
48
- let menuTop = 0;
49
48
  let resolved = false;
50
49
  let resolve;
51
50
  /** 可选项(非禁用)下标集合。 */
@@ -83,9 +82,8 @@ export async function promptFuzzyPicker(opts) {
83
82
  // 开窗:以 selected 为中心。
84
83
  const visibleCount = Math.min(maxVisible, en.length);
85
84
  const selPos = en.indexOf(selected);
86
- let page = Math.floor(selPos / visibleCount);
85
+ const page = Math.floor(selPos / visibleCount);
87
86
  const winEn = en.slice(page * visibleCount, page * visibleCount + visibleCount);
88
- menuTop = winEn[0];
89
87
  const hasMore = page * visibleCount + visibleCount < en.length;
90
88
  const hasAbove = page > 0;
91
89
  winEn.forEach((idx, i) => {
@@ -5,6 +5,7 @@ import { displayWidth, truncateDisplay, visColToCharCol, wrapByDisplayWidth } fr
5
5
  import * as layout from './layout.js';
6
6
  import * as mouse from './mouse.js';
7
7
  import { Spinner } from './spinner.js';
8
+ import { resumeSweep, suspendSweep } from './batch.js';
8
9
  import { t } from '../i18n/index.js';
9
10
  const emitter = stdin;
10
11
  /** choice 末尾的本地化“自定义输入”项标签。 */
@@ -221,6 +222,8 @@ async function promptInterventionInner(req) {
221
222
  layout.resetScroll();
222
223
  else
223
224
  layout.repaintViewport();
225
+ // 恢复扫光心跳(若仍有在飞批):必须在 repaintViewport 之后——先恢复内容区,再让心跳重画摘要行。
226
+ resumeSweep();
224
227
  // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
225
228
  layout.startTurnTimerIfRunning();
226
229
  }
@@ -354,6 +357,9 @@ async function promptInterventionInner(req) {
354
357
  // 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
355
358
  Spinner.pauseCurrent();
356
359
  layout.stopTurnTimer();
360
+ // 暂停 batch 摘要行扫光:摘要行在内容区底,菜单向上展开会与之重叠,心跳继续重画会
361
+ // 把「◇ 正在探索」帧泄漏到选项之间(cleanup 退出时 resumeSweep 恢复)。
362
+ suspendSweep();
357
363
  layout.setMouseEnabled(false);
358
364
  layout.resetScroll();
359
365
  // 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
@@ -159,12 +159,6 @@ export async function promptWithSlashMenu(opts) {
159
159
  }
160
160
  return { line: dl, col: displayWidth(dispLines[dl].slice(0, dispColChars)) };
161
161
  }
162
- /** 展示 (行, 显示列) → 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
163
- function dispColToCharInLine(dispLine, dispCol) {
164
- const { lines } = buildDisplay();
165
- const ln = lines[dispLine] ?? '';
166
- return visColToCharCol(ln, dispCol);
167
- }
168
162
  /** 展示 (行, 字符内偏移) → 段+偏移。光标永远落在可编辑段;点中 chip 则吸附到最近的可编辑边界。 */
169
163
  function mapDispToCursor(dispLine, charInLine) {
170
164
  const { lines: dispLines, segStart } = buildDisplay();
@@ -732,16 +726,11 @@ export async function promptWithSlashMenu(opts) {
732
726
  redraw();
733
727
  }
734
728
  else {
735
- const { line, col } = cursorDisp();
736
- if (line > 0) {
737
- const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
738
- curSeg = m.seg;
739
- curOff = m.off;
740
- redraw();
741
- }
742
- else {
743
- layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
744
- }
729
+ // ↑ 始终跳到全文最前(含跨只读粘贴块,落到首个可编辑段开头)
730
+ const p = firstEditableStart();
731
+ curSeg = p.seg;
732
+ curOff = p.off;
733
+ redraw();
745
734
  }
746
735
  return;
747
736
  case 'down':
@@ -750,17 +739,11 @@ export async function promptWithSlashMenu(opts) {
750
739
  redraw();
751
740
  }
752
741
  else {
753
- const { lines: dispLines } = buildDisplay();
754
- const { line, col } = cursorDisp();
755
- if (line < dispLines.length - 1) {
756
- const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
757
- curSeg = m.seg;
758
- curOff = m.off;
759
- redraw();
760
- }
761
- else {
762
- layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
763
- }
742
+ // ↓ 始终跳到全文最后(含跨只读粘贴块,落到末个可编辑段末尾)
743
+ const p = lastEditableEnd();
744
+ curSeg = p.seg;
745
+ curOff = p.off;
746
+ redraw();
764
747
  }
765
748
  return;
766
749
  case 'tab':
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.6.5",
4
- "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 25 个工具,接任意 OpenAI 兼容后端。",
3
+ "version": "1.6.7",
4
+ "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mocode": "bin/mocode.js",
@@ -44,6 +44,7 @@
44
44
  "prepare": "node -e \"try{require('child_process').execFileSync('git',['config','core.hooksPath','.githooks'],{stdio:'ignore'})}catch(e){}\""
45
45
  },
46
46
  "dependencies": {
47
+ "@vscode/ripgrep": "^1.18.0",
47
48
  "ajv": "8.20.0",
48
49
  "cli-highlight": "^2.1.11",
49
50
  "dotenv": "^16.0.0",