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
@@ -0,0 +1,61 @@
1
+ // C4 Reattach:`mocode attach [id前缀]`——从头回放 job 日志,并实时跟随新输出,
2
+ // 直到 job 进入终态(succeeded/failed/killed)且日志排空后退出。
3
+ //
4
+ // 纯 stdout 流式(与 headless 同性质),故不嵌进全屏 TUI:TUI 内 /jobs attach
5
+ // 只提示在另一终端运行。轮询而非持久句柄——跨平台、且 job 文件由别的进程写入。
6
+ import fs from 'node:fs';
7
+ import { getJob, listJobs } from './store.js';
8
+ const TERMINAL = new Set(['succeeded', 'failed', 'killed']);
9
+ const POLL_MS = 500;
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ function resolveJob(idPrefix) {
12
+ if (!idPrefix) {
13
+ const running = listJobs().filter((j) => !TERMINAL.has(j.status));
14
+ return running.length === 1 ? running[0] : (listJobs()[0] ?? null);
15
+ }
16
+ const matches = listJobs().filter((j) => j.id.startsWith(idPrefix));
17
+ return matches.length === 1 ? matches[0] : null;
18
+ }
19
+ export async function runAttachCli(rawArgs) {
20
+ const idPrefix = rawArgs[0];
21
+ const initial = resolveJob(idPrefix);
22
+ if (!initial) {
23
+ process.stderr.write('mocode: no unique matching job to attach\n');
24
+ return 1;
25
+ }
26
+ process.stdout.write(`── attach ${initial.id} ──\n`);
27
+ let offset = 0;
28
+ let stableRounds = 0;
29
+ for (;;) {
30
+ // 1) 排空当前新增日志。
31
+ try {
32
+ const stat = fs.statSync(initial.logPath);
33
+ if (stat.size > offset) {
34
+ const buf = Buffer.alloc(stat.size - offset);
35
+ const fd = fs.openSync(initial.logPath, 'r');
36
+ fs.readSync(fd, buf, 0, buf.length, offset);
37
+ fs.closeSync(fd);
38
+ offset = stat.size;
39
+ process.stdout.write(buf);
40
+ stableRounds = 0;
41
+ }
42
+ }
43
+ catch {
44
+ // 日志暂时不可读(尚未创建/被锁),下轮再试。
45
+ }
46
+ // 2) 查最新状态。
47
+ const job = getJob(initial.id) ?? initial;
48
+ if (TERMINAL.has(job.status)) {
49
+ // 终态后再多等两轮,确保末尾输出已落盘并排空,避免丢最后几行。
50
+ stableRounds += 1;
51
+ if (stableRounds >= 2) {
52
+ if (!process.stdout.isTTY)
53
+ process.stdout.write(`\n[${job.status}]`);
54
+ else
55
+ process.stdout.write(`\n${job.status}\n`);
56
+ return job.status === 'succeeded' ? 0 : 1;
57
+ }
58
+ }
59
+ await sleep(POLL_MS);
60
+ }
61
+ }
@@ -0,0 +1,33 @@
1
+ // D3 checkpoint 存储:每个 bg job 在工具批次提交后把完整 history 快照
2
+ // 原子写到 <id>.checkpoint.json。崩溃 / 机器重启后可用
3
+ // `mocode resume-job <id>` 从最后一个 checkpoint 在新进程断点续跑。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { jobsRoot } from './store.js';
7
+ function checkpointPath(jobId) {
8
+ return path.join(jobsRoot(), `${jobId}.checkpoint.json`);
9
+ }
10
+ export function writeCheckpoint(jobId, history) {
11
+ fs.mkdirSync(jobsRoot(), { recursive: true });
12
+ const target = checkpointPath(jobId);
13
+ const tmp = `${target}.tmp`;
14
+ fs.writeFileSync(tmp, `${JSON.stringify(history)}\n`, 'utf8');
15
+ fs.renameSync(tmp, target);
16
+ }
17
+ export function readCheckpoint(jobId) {
18
+ try {
19
+ const parsed = JSON.parse(fs.readFileSync(checkpointPath(jobId), 'utf8'));
20
+ return Array.isArray(parsed) ? parsed : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function deleteCheckpoint(jobId) {
27
+ try {
28
+ fs.rmSync(checkpointPath(jobId));
29
+ }
30
+ catch {
31
+ // already gone
32
+ }
33
+ }
@@ -0,0 +1,107 @@
1
+ // 后台任务 launcher / kill:spawn 一个 detached 子进程,父进程退出不影响它
2
+ //(detached + 独立进程组),stdio 重定向到日志文件。
3
+ //
4
+ // 形态自适应:编译产物 src/index.js 存在 → node 直跑;否则(开发态)用 node 加载
5
+ // tsx loader 单进程跑 src/index.ts(不用 tsx CLI:它会 respawn 一个不 hide 的弹窗 node)。
6
+ //
7
+ // 通用件 buildEntryArgs / spawnDetached 也供 schedule 守护进程复用(src/schedule/daemon.ts)。
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import { openSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { existsSync } from 'node:fs';
13
+ import { createJobId, saveJob, updateJob, logPathFor, getJob } from './store.js';
14
+ const jobsDir = path.dirname(fileURLToPath(import.meta.url));
15
+ const srcDir = path.resolve(jobsDir, '..');
16
+ const projectRoot = path.resolve(srcDir, '..');
17
+ const compiledEntry = path.join(srcDir, 'index.js');
18
+ const sourceEntry = path.join(srcDir, 'index.ts');
19
+ const tsxLoader = path.join(projectRoot, 'node_modules', 'tsx', 'dist', 'loader.mjs');
20
+ const tsxPreflight = path.join(projectRoot, 'node_modules', 'tsx', 'dist', 'preflight.cjs');
21
+ /**
22
+ * 构造「node …入口… internalArgs」的完整参数。
23
+ * 生产/开发形态自动选择,调用方只需给入口之后的内部参数(如 ['--job-runner', id])。
24
+ */
25
+ export function buildEntryArgs(internalArgs) {
26
+ if (existsSync(compiledEntry))
27
+ return [compiledEntry, ...internalArgs];
28
+ return ['--require', tsxPreflight, '--import', pathToFileURL(tsxLoader).href, sourceEntry, ...internalArgs];
29
+ }
30
+ /**
31
+ * detached spawn 一个 node 子进程。stdio 重定向到 logPath(stdout+stderr 同一 fd)。
32
+ * 调用方负责后续 saveJob 等状态回写。
33
+ */
34
+ export function spawnDetached(internalArgs, logPath, cwd = process.cwd()) {
35
+ const outFd = openSync(logPath, 'a');
36
+ const child = spawn(process.execPath, buildEntryArgs(internalArgs), {
37
+ detached: true,
38
+ stdio: ['ignore', outFd, outFd],
39
+ cwd,
40
+ env: process.env,
41
+ windowsHide: true,
42
+ });
43
+ child.unref();
44
+ return child;
45
+ }
46
+ /** 创建记录并启动 detached job。spawn 失败时记录置 failed。 */
47
+ export function launchBackgroundJob(prompt, opts = {}) {
48
+ const id = createJobId();
49
+ const logPath = logPathFor(id);
50
+ const record = {
51
+ id,
52
+ prompt,
53
+ status: 'running',
54
+ cwd: process.cwd(),
55
+ logPath,
56
+ startedAt: new Date().toISOString(),
57
+ ...(opts.sessionDir ? { sessionDir: opts.sessionDir } : {}),
58
+ ...(opts.worktree ? { worktree: true } : {}),
59
+ ...(opts.botName ? { botName: opts.botName } : {}),
60
+ };
61
+ saveJob(record);
62
+ const child = spawnDetached(['--job-runner', id], logPath);
63
+ if (child.pid)
64
+ saveJob({ ...record, pid: child.pid });
65
+ child.on('error', () => {
66
+ updateJob(id, { status: 'failed', finishedAt: new Date().toISOString() }, { force: true });
67
+ });
68
+ return { record: { ...record, pid: child.pid }, child };
69
+ }
70
+ /**
71
+ * 树杀进程并置 killed。
72
+ * Windows:taskkill /T /F 杀整棵进程树;POSIX:负 pid 杀整个进程组。
73
+ */
74
+ export function killTree(pid) {
75
+ if (process.platform === 'win32') {
76
+ const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
77
+ return r.status === 0;
78
+ }
79
+ try {
80
+ process.kill(-pid, 'SIGTERM');
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /** 树杀 job 并置 killed。 */
88
+ export function killBackgroundJob(record) {
89
+ if (typeof record.pid !== 'number')
90
+ return false;
91
+ if (!killTree(record.pid))
92
+ return false;
93
+ updateJob(record.id, { status: 'killed', finishedAt: new Date().toISOString() }, { force: true });
94
+ return true;
95
+ }
96
+ /** D3: 从最后 checkpoint 在新 detached 进程中断点续跑同一 job。 */
97
+ export function resumeBackgroundJob(id) {
98
+ const record = getJob(id);
99
+ if (!record)
100
+ return null;
101
+ updateJob(id, { status: 'running', finishedAt: undefined, exitCode: undefined }, { force: true });
102
+ const child = spawnDetached(['--job-runner', id, '--resume'], record.logPath);
103
+ // 续跑是新 detached 进程,必须回写新 PID,否则后续 /jobs kill 会指向已死的旧 PID。
104
+ if (child.pid)
105
+ updateJob(id, { pid: child.pid }, { force: true });
106
+ return getJob(id);
107
+ }
@@ -0,0 +1,59 @@
1
+ // Job runner:detached 子进程的内部入口(`mocode --job-runner <id>`)。
2
+ // 复用 runHeadless 跑任务(输出已被 launcher 重定向到 <id>.log),
3
+ // 结束后按退出码回写 job 终态,并 best-effort 发结束通知(MOCODE_NOTIFY_WEBHOOK)。
4
+ import { getJob, updateJob } from './store.js';
5
+ import { deleteCheckpoint } from './checkpoint.js';
6
+ import { runHeadless } from '../headless.js';
7
+ import { sendNotification } from '../notify/index.js';
8
+ import { config } from '../config/index.js';
9
+ export async function runJobRunner(jobId, resume = false) {
10
+ const record = getJob(jobId);
11
+ if (!record) {
12
+ process.stderr.write(`job runner: unknown job ${jobId}\n`);
13
+ return 1;
14
+ }
15
+ const startedAt = Date.now();
16
+ let exitCode;
17
+ try {
18
+ exitCode = await runHeadless({
19
+ prompt: record.prompt,
20
+ json: false,
21
+ verbose: false,
22
+ skipPermissions: false,
23
+ ...(record.sessionDir ? { sessionDir: record.sessionDir } : {}),
24
+ ...(record.worktree ? { worktree: true } : {}),
25
+ ...(record.botName ? { botName: record.botName } : {}),
26
+ jobId,
27
+ ...(resume ? { resumeFromCheckpoint: true } : {}),
28
+ });
29
+ }
30
+ catch (e) {
31
+ updateJob(jobId, { status: 'failed', finishedAt: new Date().toISOString(), exitCode: 1 }, { force: true });
32
+ process.stderr.write(`job runner crashed: ${e instanceof Error ? e.message : String(e)}\n`);
33
+ await sendNotification(config.notifyWebhook, {
34
+ status: 'failed',
35
+ prompt: record.prompt,
36
+ sessionId: jobId,
37
+ elapsedMs: Date.now() - startedAt,
38
+ model: config.model,
39
+ });
40
+ return 1;
41
+ }
42
+ const status = exitCode === 0 ? 'succeeded' : 'failed';
43
+ // force:被手动 kill 后进程仍可能在此回写,killed 状态由 killBackgroundJob 强制保留。
44
+ updateJob(jobId, { status, finishedAt: new Date().toISOString(), exitCode }, { force: false });
45
+ if (status === 'succeeded')
46
+ deleteCheckpoint(jobId);
47
+ // 被 kill 的 job(记录仍是 killed)不发常规成功/失败通知,避免与用户意图冲突。
48
+ const after = getJob(jobId);
49
+ if (after?.status !== 'killed') {
50
+ await sendNotification(config.notifyWebhook, {
51
+ status,
52
+ prompt: record.prompt,
53
+ sessionId: jobId,
54
+ elapsedMs: Date.now() - startedAt,
55
+ model: config.model,
56
+ });
57
+ }
58
+ return exitCode;
59
+ }
@@ -0,0 +1,101 @@
1
+ // 后台任务存储:每个 job 一个 <id>.json 记录 + <id>.log 日志(detached 子进程的
2
+ // stdout/stderr 直接重定向到该文件)。
3
+ //
4
+ // 根目录:<sandboxRoot>/.mocode/jobs/。sandboxRoot 与 headless / REPL 同源
5
+ // (入口先 setSandboxRoot,store 动态读取,不缓存)。
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { getSandboxRoot } from '../sandbox/index.js';
10
+ /** 与 session 同风格的时间戳 id:YYYYMMDD-HHMMSS-rand。 */
11
+ export function createJobId(now = new Date()) {
12
+ const p = (n, l = 2) => String(n).padStart(l, '0');
13
+ const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}` +
14
+ `-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
15
+ return `${stamp}-${randomBytes(2).toString('hex')}`;
16
+ }
17
+ export function jobsRoot() {
18
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'jobs');
19
+ }
20
+ function recordPath(id) {
21
+ return path.join(jobsRoot(), `${id}.json`);
22
+ }
23
+ export function logPathFor(id) {
24
+ return path.join(jobsRoot(), `${id}.log`);
25
+ }
26
+ export function saveJob(record) {
27
+ fs.mkdirSync(jobsRoot(), { recursive: true });
28
+ // per-file 原子写:先写 tmp 再 rename,避免读侧看到半截 JSON。
29
+ const target = recordPath(record.id);
30
+ const tmp = `${target}.tmp`;
31
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
32
+ fs.renameSync(tmp, target);
33
+ }
34
+ export function getJob(id) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(recordPath(id), 'utf8'));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * 更新 job 状态。默认保护:记录已是终态 killed 时不被覆盖
44
+ * (runner 退出回写与手动 kill 存在竞态)。force 可绕过。
45
+ */
46
+ export function updateJob(id, patch, opts = {}) {
47
+ const current = getJob(id);
48
+ if (!current)
49
+ return null;
50
+ if (!opts.force && current.status === 'killed' && patch.status !== 'killed')
51
+ return current;
52
+ const next = { ...current, ...patch };
53
+ saveJob(next);
54
+ return next;
55
+ }
56
+ /** 列出全部 job,新的在前。 */
57
+ export function listJobs() {
58
+ let files;
59
+ try {
60
+ files = fs.readdirSync(jobsRoot());
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ const records = [];
66
+ for (const file of files) {
67
+ if (!file.endsWith('.json'))
68
+ continue;
69
+ try {
70
+ const parsed = JSON.parse(fs.readFileSync(path.join(jobsRoot(), file), 'utf8'));
71
+ // 只认完整 JobRecord,排除 <id>.approval.json / <id>.checkpoint.json 等附属文件。
72
+ if (typeof parsed.id === 'string' &&
73
+ typeof parsed.status === 'string' &&
74
+ typeof parsed.logPath === 'string' &&
75
+ typeof parsed.prompt === 'string') {
76
+ records.push(parsed);
77
+ }
78
+ }
79
+ catch {
80
+ // 跳过损坏记录
81
+ }
82
+ }
83
+ return records.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
84
+ }
85
+ /** 读日志尾部,默认最多 4KB。 */
86
+ export function readJobLog(id, maxBytes = 4096) {
87
+ const logPath = logPathFor(id);
88
+ try {
89
+ const stat = fs.statSync(logPath);
90
+ const size = stat.size;
91
+ const start = Math.max(0, size - maxBytes);
92
+ const fd = fs.openSync(logPath, 'r');
93
+ const buf = Buffer.alloc(size - start);
94
+ fs.readSync(fd, buf, 0, buf.length, start);
95
+ fs.closeSync(fd);
96
+ return buf.toString('utf8');
97
+ }
98
+ catch {
99
+ return '(no logs yet)';
100
+ }
101
+ }
@@ -0,0 +1,63 @@
1
+ // Git worktree 隔离:为一次性任务创建 detached worktree(独立工作树 + 独立
2
+ // sandboxRoot),结束后销毁。任务写盘全部落在 worktree,主工作区零污染。
3
+ //
4
+ // 附带 node_modules 链接:新 worktree 没有依赖,跑不了构建/测试;junction(Win)/
5
+ // symlink(POSIX) 指向原仓库 node_modules,删链接不影响目标。
6
+ import { spawnSync } from 'node:child_process';
7
+ import { mkdirSync, rmdirSync, existsSync, symlinkSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { randomBytes } from 'node:crypto';
10
+ function git(args, cwd) {
11
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
12
+ return { ok: r.status === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
13
+ }
14
+ /** 当前 git 仓库根;不是 git 仓库时返回 null。 */
15
+ export function gitTopLevel(cwd = process.cwd()) {
16
+ const r = git(['rev-parse', '--show-toplevel'], cwd);
17
+ return r.ok ? resolve(r.output.split('\n')[0] ?? '') : null;
18
+ }
19
+ /**
20
+ * 创建 detached worktree(基于 HEAD)。
21
+ * 失败(非 git 仓库 / 有未跟踪冲突)抛错,由调用方决定是否回退普通模式。
22
+ */
23
+ export function createWorktree() {
24
+ const repoRoot = gitTopLevel();
25
+ if (!repoRoot)
26
+ throw new Error('not a git repository (worktree isolation requires git)');
27
+ const id = `${Date.now()}-${randomBytes(2).toString('hex')}`;
28
+ const wtPath = join(repoRoot, '.mocode', 'worktrees', `wt-${id}`);
29
+ mkdirSync(join(repoRoot, '.mocode', 'worktrees'), { recursive: true });
30
+ const added = git(['worktree', 'add', '--detach', wtPath, 'HEAD'], repoRoot);
31
+ if (!added.ok)
32
+ throw new Error(`git worktree add failed: ${added.output}`);
33
+ // 链接 node_modules(若主仓库有),否则 worktree 内无法构建。
34
+ const sourceModules = join(repoRoot, 'node_modules');
35
+ if (existsSync(sourceModules)) {
36
+ const link = join(wtPath, 'node_modules');
37
+ if (process.platform === 'win32') {
38
+ // directory junction:不需要管理员;rmdir 删链接不动目标。
39
+ spawnSync('cmd', ['/c', 'mklink', '/J', link, sourceModules], { windowsHide: true });
40
+ }
41
+ else {
42
+ symlinkSync(sourceModules, link, 'dir');
43
+ }
44
+ }
45
+ return { path: wtPath, repoRoot };
46
+ }
47
+ /** 先删 node_modules 链接(防误伤目标),再移除 worktree + prune。 */
48
+ export function removeWorktree(wt) {
49
+ const link = join(wt.path, 'node_modules');
50
+ if (existsSync(link)) {
51
+ try {
52
+ if (process.platform === 'win32')
53
+ rmdirSync(link); // junction:仅删链接
54
+ else
55
+ rmdirSync(link);
56
+ }
57
+ catch {
58
+ // 链接删不掉仍尝试 worktree remove
59
+ }
60
+ }
61
+ git(['worktree', 'remove', '--force', wt.path], wt.repoRoot);
62
+ git(['worktree', 'prune'], wt.repoRoot);
63
+ }
package/dist/llm/index.js CHANGED
@@ -8,6 +8,9 @@ import { sanitizeToolSchemas } from './tool-schema.js';
8
8
  import { isMarkedStreamInterrupted } from './stream-interrupt.js';
9
9
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
10
10
  import { registerModelProvider, getModelProvider, listModelProviders } from './provider.js';
11
+ import { effectiveReasoningEffort } from '../config/index.js';
12
+ import { resolveReasoningParams } from './reasoning.js';
13
+ import { getActivePresetName, readPreset } from '../config/presets.js';
11
14
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
12
15
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
13
16
  // 仅拦截 'true' 这一开关值——保留 namespace 形式的 DEBUG(如 DEBUG=express:*) 调试能力。
@@ -394,11 +397,11 @@ export function refreshChatTools() {
394
397
  // 新增 provider 只需 registerModelProvider,不必改 chat()。
395
398
  registerModelProvider({
396
399
  name: 'openai',
397
- chatOnce: (messages, handlers, signal, tools, runtime) => chatOnce(messages, handlers, signal, tools, runtime),
400
+ chatOnce: (messages, handlers, signal, tools, runtime, overrides) => chatOnce(messages, handlers, signal, tools, runtime, overrides),
398
401
  });
399
402
  registerModelProvider({
400
403
  name: 'anthropic',
401
- chatOnce: (messages, handlers, signal, tools, runtime) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools, runtime),
404
+ chatOnce: (messages, handlers, signal, tools, runtime, overrides) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools, runtime, overrides),
402
405
  });
403
406
  /**
404
407
  * 多 provider 兼容的 cache / reasoning 字段提取。
@@ -474,14 +477,14 @@ function retryErrorCode(error) {
474
477
  }
475
478
  /** Bind chat dispatch, retries and built-in providers to one explicit runtime. */
476
479
  export function createChatTransport(runtime) {
477
- return (messages, handlers = {}, signal, toolsOverride) => chatWithRuntime(runtime, messages, handlers, signal, toolsOverride);
480
+ return (messages, handlers = {}, signal, toolsOverride, overrides) => chatWithRuntime(runtime, messages, handlers, signal, toolsOverride, overrides);
478
481
  }
479
482
  export function chat(messages, handlers = {}, signal,
480
483
  /** 覆盖默认工具 schema;plan 模式传 planChatTools(只读子集),缺省=全量 chatTools。 */
481
- toolsOverride) {
482
- return chatWithRuntime(defaultProviderRuntime, messages, handlers, signal, toolsOverride);
484
+ toolsOverride, overrides) {
485
+ return chatWithRuntime(defaultProviderRuntime, messages, handlers, signal, toolsOverride, overrides);
483
486
  }
484
- async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride) {
487
+ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride, overrides) {
485
488
  let lastErr;
486
489
  // 本次尝试是否已向调用方产出过内容(可见文本 / 工具名)。用于判断「流中途故障可否安全重试」:
487
490
  // 重试会从请求头重放,已流出并显示在内容区的半截文本会再写一遍(history 因异常未落,倒是不脏)。
@@ -511,7 +514,7 @@ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverrid
511
514
  // 未注册的 provider:给出可用名单,避免悄悄落到错误实现。
512
515
  throw new Error(`未知的 LLM provider "${runtime.config.provider}";已注册:${listModelProviders().join(', ') || '(空)'}`);
513
516
  }
514
- return await provider.chatOnce(messages, guardedHandlers, signal, toolsOverride, runtime);
517
+ return await provider.chatOnce(messages, guardedHandlers, signal, toolsOverride, runtime, overrides);
515
518
  }
516
519
  catch (err) {
517
520
  lastErr = err;
@@ -584,7 +587,7 @@ export function normalizeImageDetail(messages) {
584
587
  return changed ? next : messages;
585
588
  }
586
589
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
587
- async function chatOnce(messages, handlers, signal, toolsOverride, runtime = defaultProviderRuntime) {
590
+ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = defaultProviderRuntime, overrides) {
588
591
  // 防御:messages 必须至少含一条非空 user 消息,否则 OpenAI/Anthropic 都会 400。
589
592
  // compact force 分支曾把所有 user 丢进摘要 → 重建 history 无 user → 下一轮 400。
590
593
  // 在 transport 边界拦住所有类似回归(compact / 外部注入 / resume 损坏)。
@@ -611,6 +614,29 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
611
614
  // transport 边界消毒:剔除部分后端(kimi-k3@dashscope 实测)整请求 400 拒绝的
612
615
  // uniqueItems 关键字。无需改写时返回原引用,前缀缓存逐字节稳定不受影响。
613
616
  const activeTools = sanitizeToolSchemas(toolsOverride ?? chatTools);
617
+ const effort = effectiveReasoningEffort(overrides?.reasoningEffort);
618
+ // 实时读激活预设(#model-catalog M3):capabilities 只存在预设文件里,启动快照不反映切换。
619
+ const activePreset = (() => {
620
+ const n = getActivePresetName();
621
+ return n ? readPreset(n) : null;
622
+ })();
623
+ const reasoningParams = effort === 'auto'
624
+ ? {}
625
+ : resolveReasoningParams(effort, {
626
+ provider: runtimeConfig.provider === 'anthropic' ? 'anthropic' : 'openai',
627
+ model: runtime.getModel(),
628
+ maxTokens: runtimeConfig.maxTokens,
629
+ // 仅当激活预设来自目录且对应当前模型时携带,否则走正则兜底。
630
+ ...(activePreset && activePreset.catalogProvider && activePreset.model === runtime.getModel()
631
+ ? {
632
+ catalogProviderId: activePreset.catalogProvider,
633
+ catalogReasoning: activePreset.capabilities ? activePreset.capabilities.reasoning : undefined,
634
+ catalogReasoningOptions: activePreset.capabilities
635
+ ? activePreset.capabilities.reasoningOptions
636
+ : undefined,
637
+ }
638
+ : {}),
639
+ });
614
640
  const stream = await create({
615
641
  model: runtime.getModel(),
616
642
  messages: normalizeImageDetail(messages),
@@ -626,6 +652,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
626
652
  : {}),
627
653
  ...(runtimeConfig.maxTokens ? { max_tokens: runtimeConfig.maxTokens } : {}),
628
654
  ...(runtimeConfig.includeUsage ? { stream_options: { include_usage: true } } : {}),
655
+ ...reasoningParams,
629
656
  }, signal ? { signal } : undefined);
630
657
  // content 内嵌 think 标签由独立增量状态机过滤。它只暂存“可能组成标签”的后缀,
631
658
  // 因而既能覆盖标签任意位置跨 chunk,也不会让普通正文固定延迟数个字符。
@@ -1,4 +1,5 @@
1
- import { config, getActiveModel } from '../../config/index.js';
1
+ import { config, effectiveReasoningEffort, getActiveModel } from '../../config/index.js';
2
+ import { resolveReasoningParams } from '../reasoning.js';
2
3
  import { markStreamInterrupted } from '../stream-interrupt.js';
3
4
  let fetchImplOverride = null;
4
5
  /** 仅供单测注入;生产路径使用 Node 18+ 全局 fetch。 */
@@ -179,10 +180,18 @@ function endpoint(baseURL) {
179
180
  return base;
180
181
  return `${base}/v1/messages`;
181
182
  }
182
- export function buildAnthropicRequest(messages, tools, runtime) {
183
+ export function buildAnthropicRequest(messages, tools, runtime, overrides) {
183
184
  const runtimeConfig = runtime?.config ?? config;
184
185
  const encoded = encodeAnthropicMessages(messages, runtimeConfig.anthropicPromptCache);
185
186
  const anthropicTools = encodeAnthropicTools(tools, runtimeConfig.anthropicPromptCache);
187
+ const effort = effectiveReasoningEffort(overrides?.reasoningEffort);
188
+ const reasoningParams = effort === 'auto'
189
+ ? {}
190
+ : resolveReasoningParams(effort, {
191
+ provider: 'anthropic',
192
+ model: runtime?.getModel() ?? getActiveModel(),
193
+ maxTokens: runtimeConfig.maxTokens,
194
+ });
186
195
  return {
187
196
  model: runtime?.getModel() ?? getActiveModel(),
188
197
  max_tokens: runtimeConfig.maxTokens ?? 8192,
@@ -190,6 +199,7 @@ export function buildAnthropicRequest(messages, tools, runtime) {
190
199
  system: encoded.system,
191
200
  messages: encoded.messages,
192
201
  ...(anthropicTools.length > 0 ? { tools: anthropicTools } : {}),
202
+ ...reasoningParams,
193
203
  };
194
204
  }
195
205
  class AnthropicHttpError extends Error {
@@ -287,7 +297,7 @@ function addLiveCount(text, state) {
287
297
  }
288
298
  }
289
299
  /** Anthropic Messages API 单次请求;外层 chat() 继续统一负责重试。 */
290
- export async function anthropicChatOnce(messages, handlers, signal, tools, runtime) {
300
+ export async function anthropicChatOnce(messages, handlers, signal, tools, runtime, overrides) {
291
301
  const runtimeConfig = runtime?.config ?? config;
292
302
  const fetchImpl = runtime?.clientState.anthropicFetchImpl ?? defaultAnthropicFetch;
293
303
  const response = await fetchImpl(endpoint(runtimeConfig.baseURL), {
@@ -298,7 +308,7 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
298
308
  'x-api-key': runtimeConfig.apiKey,
299
309
  'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
300
310
  },
301
- body: JSON.stringify(buildAnthropicRequest(messages, tools, runtime)),
311
+ body: JSON.stringify(buildAnthropicRequest(messages, tools, runtime, overrides)),
302
312
  signal,
303
313
  });
304
314
  if (!response.ok)