mocode-ai 1.6.5 → 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.
- package/README.md +7 -1
- package/README.zh-CN.md +7 -1
- package/dist/agent/run-coordinator.js +2 -1
- package/dist/agent/tool-turn.js +2 -0
- package/dist/bots/bus.js +117 -0
- package/dist/bots/cli.js +102 -0
- package/dist/bots/store.js +100 -0
- package/dist/config/index.js +1 -0
- package/dist/headless.js +302 -0
- package/dist/index.js +171 -3
- package/dist/jobs/approval.js +153 -0
- package/dist/jobs/approve-cli.js +30 -0
- package/dist/jobs/attach.js +61 -0
- package/dist/jobs/checkpoint.js +33 -0
- package/dist/jobs/launch.js +107 -0
- package/dist/jobs/runner.js +59 -0
- package/dist/jobs/store.js +101 -0
- package/dist/jobs/worktree.js +63 -0
- package/dist/notify/index.js +144 -0
- package/dist/repl/commands/bots.js +32 -0
- package/dist/repl/commands/jobs.js +109 -0
- package/dist/repl/commands/model.js +5 -20
- package/dist/repl/commands/registry.js +6 -0
- package/dist/repl/commands/schedule.js +37 -0
- package/dist/repl/commands.js +3 -58
- package/dist/schedule/cli.js +114 -0
- package/dist/schedule/cron.js +79 -0
- package/dist/schedule/daemon.js +166 -0
- package/dist/schedule/store.js +98 -0
- package/dist/tools/builtins/arena.js +156 -0
- package/dist/tools/builtins/index.js +4 -0
- package/dist/tools/builtins/message-bus.js +98 -0
- package/dist/tools/builtins/run-command.js +2 -1
- package/dist/ui/batch.js +31 -1
- package/dist/ui/composer.js +4 -18
- package/dist/ui/intervention.js +6 -0
- package/dist/ui/prompt-internal/editor.js +10 -27
- 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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// 任务结束通知:POST 到用户自配的 webhook,按 URL 自动识别格式:
|
|
2
|
+
// - ntfy:https://ntfy.sh/<topic>(POST 正文,Title header)
|
|
3
|
+
// - Bark:https://api.day.app/<key>(POST JSON {title, body})
|
|
4
|
+
// - Telegram:https://api.telegram.org/bot<token>/sendMessage[?chat_id=...]
|
|
5
|
+
// - 其他:generic JSON webhook
|
|
6
|
+
//
|
|
7
|
+
// 刻意 best-effort:通知失败永不影响任务结果、不重试(webhook 是副作用通道,
|
|
8
|
+
// 不该因为通知服务挂了让 job 变 failed)。
|
|
9
|
+
//
|
|
10
|
+
// 回环地址(localhost/127.0.0.1)走 node:http 直连:Node 24 的 fetch 默认使用
|
|
11
|
+
// HTTP_PROXY 环境代理,而 NO_PROXY 对 localhost 的绕过不可靠,带代理的机器上
|
|
12
|
+
// 本地 webhook 会被转发失败。公网地址仍走 fetch(保留代理穿透)。
|
|
13
|
+
function firstLine(prompt) {
|
|
14
|
+
const line = prompt.split('\n')[0] ?? '';
|
|
15
|
+
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
16
|
+
}
|
|
17
|
+
function buildTitle(ev) {
|
|
18
|
+
if (ev.status === 'succeeded')
|
|
19
|
+
return 'mocode OK';
|
|
20
|
+
if (ev.status === 'failed')
|
|
21
|
+
return 'mocode FAILED';
|
|
22
|
+
return 'mocode APPROVAL NEEDED';
|
|
23
|
+
}
|
|
24
|
+
function buildBody(ev) {
|
|
25
|
+
if (ev.status === 'waiting') {
|
|
26
|
+
return (`Tool: ${ev.tool ?? '?'}\n\n${firstLine(ev.prompt)}\n\nsession: ${ev.sessionId}\nmodel: ${ev.model}` +
|
|
27
|
+
`\n\nApprove: mocode approve ${ev.sessionId}\nDeny: mocode deny ${ev.sessionId}`);
|
|
28
|
+
}
|
|
29
|
+
const secs = (ev.elapsedMs / 1000).toFixed(1);
|
|
30
|
+
return `${firstLine(ev.prompt)}\n\nsession: ${ev.sessionId}\nmodel: ${ev.model}\ntime: ${secs}s`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 按 URL 构造请求(纯函数)。
|
|
34
|
+
* 返回 null 表示 URL 无法识别(调用方静默跳过)。
|
|
35
|
+
*/
|
|
36
|
+
export function buildNotifyRequest(webhookUrl, ev) {
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(webhookUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const title = buildTitle(ev);
|
|
45
|
+
const body = buildBody(ev);
|
|
46
|
+
// Telegram:query 中的 chat_id 合并进 JSON body。
|
|
47
|
+
if (url.hostname === 'api.telegram.org' && url.pathname.includes('/sendMessage')) {
|
|
48
|
+
const chatId = url.searchParams.get('chat_id') ?? process.env.MOCODE_NOTIFY_CHAT_ID;
|
|
49
|
+
if (!chatId)
|
|
50
|
+
return null;
|
|
51
|
+
url.search = '';
|
|
52
|
+
return {
|
|
53
|
+
init: {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ chat_id: chatId, text: `${title}\n${body}` }),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Bark:{title, body}。
|
|
61
|
+
if (url.hostname === 'api.day.app' || url.hostname.endsWith('.day.app')) {
|
|
62
|
+
return {
|
|
63
|
+
init: {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ title, body }),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ntfy 风格(含自建实例):裸文本正文 + Title/Priority header。
|
|
71
|
+
if (url.hostname === 'ntfy.sh' || url.pathname.split('/').filter(Boolean).length === 1) {
|
|
72
|
+
return {
|
|
73
|
+
init: {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { Title: title, Priority: ev.status === 'failed' ? 'high' : 'default' },
|
|
76
|
+
body,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Generic JSON webhook。
|
|
81
|
+
return {
|
|
82
|
+
init: {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ ...ev, title, body }),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** 是否回环地址(localhost/127.0.0.1/[::1])。 */
|
|
90
|
+
function isLoopback(hostname) {
|
|
91
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 回环地址直连 POST(node:http)。
|
|
95
|
+
*/
|
|
96
|
+
function postLoopback(url, init) {
|
|
97
|
+
return new Promise((resolvePromise) => {
|
|
98
|
+
void (async () => {
|
|
99
|
+
const mod = url.protocol === 'https:' ? await import('node:https') : await import('node:http');
|
|
100
|
+
const headers = {};
|
|
101
|
+
const headersInit = init.headers;
|
|
102
|
+
if (headersInit) {
|
|
103
|
+
for (const [k, v] of Object.entries(headersInit))
|
|
104
|
+
headers[k] = v;
|
|
105
|
+
}
|
|
106
|
+
const body = typeof init.body === 'string' ? init.body : '';
|
|
107
|
+
headers['Content-Length'] = String(Buffer.byteLength(body));
|
|
108
|
+
const req = mod.request(url, { method: init.method, headers, timeout: 10000 }, (res) => {
|
|
109
|
+
res.resume();
|
|
110
|
+
resolvePromise((res.statusCode ?? 500) < 400);
|
|
111
|
+
});
|
|
112
|
+
req.on('error', () => resolvePromise(false));
|
|
113
|
+
req.on('timeout', () => {
|
|
114
|
+
req.destroy();
|
|
115
|
+
resolvePromise(false);
|
|
116
|
+
});
|
|
117
|
+
req.end(body);
|
|
118
|
+
})();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** 发送通知。best-effort:成功返回 true,任何失败静默 false。 */
|
|
122
|
+
export async function sendNotification(webhookUrl, ev) {
|
|
123
|
+
if (!webhookUrl)
|
|
124
|
+
return false;
|
|
125
|
+
const spec = buildNotifyRequest(webhookUrl, ev);
|
|
126
|
+
if (!spec)
|
|
127
|
+
return false;
|
|
128
|
+
let url;
|
|
129
|
+
try {
|
|
130
|
+
url = new URL(webhookUrl);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (isLoopback(url.hostname))
|
|
137
|
+
return await postLoopback(url, spec.init);
|
|
138
|
+
const res = await fetch(url, { ...spec.init, signal: AbortSignal.timeout(10000) });
|
|
139
|
+
return res.ok;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 具名 Bot 命令:/bots
|
|
3
|
+
* 只读列出 bots;增删改/查看详情走 CLI `mocode bots …`,按名运行用 --bot <name>。
|
|
4
|
+
*/
|
|
5
|
+
import * as layout from '../../ui/layout.js';
|
|
6
|
+
import { ui } from '../../ui/theme.js';
|
|
7
|
+
import { listBots } from '../../bots/store.js';
|
|
8
|
+
import { unhandled, next } from './types.js';
|
|
9
|
+
function oneLine(text) {
|
|
10
|
+
const line = text.split('\n')[0] ?? '';
|
|
11
|
+
return line.length > 50 ? `${line.slice(0, 50)}…` : line;
|
|
12
|
+
}
|
|
13
|
+
export const botsCommands = [
|
|
14
|
+
(ctx) => {
|
|
15
|
+
if (ctx.line !== '/bots')
|
|
16
|
+
return unhandled();
|
|
17
|
+
const all = listBots();
|
|
18
|
+
if (all.length === 0) {
|
|
19
|
+
layout.contentWrite(`${ui.dim}(no bots; mocode bots add --name <n> --prompt "…")${ui.reset}\n`);
|
|
20
|
+
return next();
|
|
21
|
+
}
|
|
22
|
+
for (const b of all) {
|
|
23
|
+
const scopeTag = b.scope === 'global' ? `${ui.dim}g${ui.reset}` : `${ui.cyan}p${ui.reset}`;
|
|
24
|
+
layout.contentWrite(` ${scopeTag} ${ui.bold}${b.name}${ui.reset}` +
|
|
25
|
+
`${b.tools && b.tools.length ? ` ${ui.dim}tools:${b.tools.length}${ui.reset}` : ''}` +
|
|
26
|
+
` ${ui.dim}${b.description}${ui.reset}\n` +
|
|
27
|
+
` ${ui.dim}${oneLine(b.systemPrompt)}${ui.reset}\n`);
|
|
28
|
+
}
|
|
29
|
+
layout.contentWrite(`${ui.dim} run: mocode run --bot ${all[0]?.name ?? '<name>'} "task" · details: mocode bots show <name>${ui.reset}\n`);
|
|
30
|
+
return next();
|
|
31
|
+
},
|
|
32
|
+
];
|