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.
- package/README.md +46 -31
- package/README.zh-CN.md +57 -30
- 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 +2 -1
- 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/session/scheduler.js +0 -3
- package/dist/session/store.js +1 -1
- package/dist/tools/builtins/arena.js +156 -0
- package/dist/tools/builtins/glob.js +64 -16
- package/dist/tools/builtins/grep.js +176 -122
- package/dist/tools/builtins/index.js +13 -2
- package/dist/tools/builtins/message-bus.js +98 -0
- package/dist/tools/builtins/run-command.js +2 -1
- package/dist/tools/constants.js +11 -0
- package/dist/tools/ripgrep-client.js +180 -0
- package/dist/ui/batch.js +31 -1
- package/dist/ui/composer.js +4 -18
- package/dist/ui/fuzzy-picker.js +1 -3
- package/dist/ui/intervention.js +6 -0
- package/dist/ui/prompt-internal/editor.js +10 -27
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -38,10 +38,26 @@ process.on('unhandledRejection', (e) => {
|
|
|
38
38
|
exitAltScreen();
|
|
39
39
|
process.exit(1);
|
|
40
40
|
});
|
|
41
|
+
/** 取 args 中任意位置的非 flag 参数;排除带值 flag(--sandbox-root)的下一个值。 */
|
|
42
|
+
function firstPositional(args, options = {}) {
|
|
43
|
+
const valueFlags = options.valueFlags ?? [];
|
|
44
|
+
return args.find((a, idx) => {
|
|
45
|
+
if (a.startsWith('-'))
|
|
46
|
+
return false;
|
|
47
|
+
if (valueFlags.includes(args[idx - 1] ?? ''))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
41
52
|
/**
|
|
42
|
-
*
|
|
53
|
+
* 入口:
|
|
54
|
+
* - 后台任务:`mocode run --bg "任务"`(detached 子进程,状态落 .mocode/jobs/)。
|
|
55
|
+
* - Job runner 内部模式:`mocode --job-runner <id>`(由 run --bg 派生,用户不直接用)。
|
|
56
|
+
* - Headless(一次性 / 非交互):`mocode -p "任务"` 或 `echo "任务" | mocode`,
|
|
57
|
+
* 可选 --json / --verbose / --dangerously-skip-permissions。
|
|
58
|
+
* - 交互 REPL(默认);支持 --resume <id> 续接历史会话(裸 --resume 列出会话)。
|
|
43
59
|
* `mocode config` 走首跑配置向导(动态加载 commands/config,不引入 REPL/config 图,故缺配置也能跑)。
|
|
44
|
-
* REPL / session 用动态 import 按需加载——只在真正启动时才拉入 config 依赖图。
|
|
60
|
+
* REPL / session / headless / jobs 用动态 import 按需加载——只在真正启动时才拉入 config 依赖图。
|
|
45
61
|
* 显式 process.exit(0)——OpenAI 客户端的 keep-alive 会卡住事件循环。
|
|
46
62
|
*/
|
|
47
63
|
async function main() {
|
|
@@ -58,12 +74,160 @@ async function main() {
|
|
|
58
74
|
process.exit(1);
|
|
59
75
|
}
|
|
60
76
|
}
|
|
77
|
+
// --session-dir <path>:会话独立落盘目录(不写主 .mocode/sessions)。
|
|
78
|
+
const sd = args.indexOf('--session-dir');
|
|
79
|
+
let sessionDirOverride;
|
|
80
|
+
if (sd !== -1) {
|
|
81
|
+
sessionDirOverride = args[sd + 1];
|
|
82
|
+
if (!sessionDirOverride || sessionDirOverride.startsWith('--')) {
|
|
83
|
+
console.error('mocode: --session-dir requires a path');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const useWorktree = args.includes('--worktree');
|
|
88
|
+
const botFlagIdx = args.indexOf('--bot');
|
|
89
|
+
const botName = botFlagIdx !== -1 ? args[botFlagIdx + 1] : undefined;
|
|
61
90
|
// 首跑配置向导:写 ~/.mocode/config。独立模块,不触发 config 校验,故零配置也能跑。
|
|
91
|
+
if (args[0] === 'bots') {
|
|
92
|
+
const { runBotsCli } = await import('./bots/cli.js');
|
|
93
|
+
const code = await runBotsCli(args.slice(1));
|
|
94
|
+
process.exit(code);
|
|
95
|
+
}
|
|
96
|
+
if (args[0] === 'schedule') {
|
|
97
|
+
const { runScheduleCli } = await import('./schedule/cli.js');
|
|
98
|
+
const code = await runScheduleCli(args.slice(1));
|
|
99
|
+
process.exit(code);
|
|
100
|
+
}
|
|
101
|
+
if (args[0] === 'resume-job') {
|
|
102
|
+
const { resumeBackgroundJob } = await import('./jobs/launch.js');
|
|
103
|
+
const rec = resumeBackgroundJob(args[1] ?? '');
|
|
104
|
+
if (!rec) {
|
|
105
|
+
process.stderr.write('mocode: job not found\n');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(`Resumed ${rec.id} from last checkpoint.\n`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
if (args[0] === 'attach') {
|
|
112
|
+
const { runAttachCli } = await import('./jobs/attach.js');
|
|
113
|
+
const code = await runAttachCli(args.slice(1));
|
|
114
|
+
await shutdownRuntime();
|
|
115
|
+
process.exit(code);
|
|
116
|
+
}
|
|
117
|
+
if (args[0] === 'approve' || args[0] === 'deny') {
|
|
118
|
+
const { runApproveCli } = await import('./jobs/approve-cli.js');
|
|
119
|
+
const code = runApproveCli(args.slice(1), args[0] === 'approve' ? 'approved' : 'denied');
|
|
120
|
+
process.exit(code);
|
|
121
|
+
}
|
|
62
122
|
if (args[0] === 'config') {
|
|
63
123
|
const { runConfigWizard } = await import('./commands/config.js');
|
|
64
124
|
await runConfigWizard();
|
|
65
125
|
process.exit(0);
|
|
66
126
|
}
|
|
127
|
+
// Job runner 内部模式:detached 子进程入口。
|
|
128
|
+
const runnerIdx = args.indexOf('--job-runner');
|
|
129
|
+
if (runnerIdx !== -1) {
|
|
130
|
+
const jobId = args[runnerIdx + 1];
|
|
131
|
+
if (!jobId) {
|
|
132
|
+
process.stderr.write('mocode: --job-runner requires a job id\n');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const { isModelConfigured } = await import('./config/index.js');
|
|
136
|
+
if (!isModelConfigured()) {
|
|
137
|
+
process.stderr.write('mocode: model not configured; cannot run background job.\n');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const { runJobRunner } = await import('./jobs/runner.js');
|
|
141
|
+
const code = await runJobRunner(jobId, args.includes('--resume'));
|
|
142
|
+
process.exit(code);
|
|
143
|
+
}
|
|
144
|
+
// 调度守护进程内部模式:detached 进程入口。
|
|
145
|
+
const schedDaemonIdx = args.indexOf('--schedule-daemon');
|
|
146
|
+
if (schedDaemonIdx !== -1) {
|
|
147
|
+
const portIdx = args.indexOf('--port');
|
|
148
|
+
const port = portIdx !== -1 ? Number(args[portIdx + 1]) : undefined;
|
|
149
|
+
const { runDaemon } = await import('./schedule/daemon.js');
|
|
150
|
+
runDaemon(port);
|
|
151
|
+
return; // 常驻:不显式 exit,detached 进程靠事件循环存活
|
|
152
|
+
}
|
|
153
|
+
// 后台任务:mocode run [--bg] "任务"。
|
|
154
|
+
if (args[0] === 'run') {
|
|
155
|
+
const rest = args.slice(1);
|
|
156
|
+
const background = rest.includes('--bg');
|
|
157
|
+
const prompt = firstPositional(rest, { valueFlags: ['--sandbox-root'] });
|
|
158
|
+
const { isModelConfigured } = await import('./config/index.js');
|
|
159
|
+
if (!isModelConfigured()) {
|
|
160
|
+
process.stderr.write('mocode: model not configured. Set LLM_BASE_URL / LLM_API_KEY / LLM_MODEL ' +
|
|
161
|
+
'(or run `mocode config`) first.\n');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
if (background) {
|
|
165
|
+
const { launchBackgroundJob } = await import('./jobs/launch.js');
|
|
166
|
+
const { resolvePrompt } = await import('./headless.js');
|
|
167
|
+
const resolved = await resolvePrompt(prompt);
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
process.stderr.write('mocode: empty task (use `mocode run --bg "task"` or pipe via stdin)\n');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
const { record } = launchBackgroundJob(resolved, {
|
|
173
|
+
...(sessionDirOverride ? { sessionDir: sessionDirOverride } : {}),
|
|
174
|
+
...(useWorktree ? { worktree: true } : {}),
|
|
175
|
+
...(botName ? { botName } : {}),
|
|
176
|
+
});
|
|
177
|
+
process.stdout.write(`Job started: ${record.id}\n pid: ${record.pid ?? '?'}\n log: ${record.logPath}\n` +
|
|
178
|
+
` track with: mocode /jobs (in TUI) or read the log file\n`);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
// 前台 run:等价 -p,走 headless。
|
|
182
|
+
const { runHeadless, resolvePrompt } = await import('./headless.js');
|
|
183
|
+
const resolved = await resolvePrompt(prompt);
|
|
184
|
+
if (!resolved) {
|
|
185
|
+
process.stderr.write('mocode: empty task\n');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const code = await runHeadless({
|
|
189
|
+
prompt: resolved,
|
|
190
|
+
json: rest.includes('--json'),
|
|
191
|
+
verbose: rest.includes('--verbose'),
|
|
192
|
+
skipPermissions: rest.includes('--dangerously-skip-permissions'),
|
|
193
|
+
sandboxRootOverride,
|
|
194
|
+
sessionDir: sessionDirOverride,
|
|
195
|
+
worktree: useWorktree,
|
|
196
|
+
...(botName ? { botName } : {}),
|
|
197
|
+
});
|
|
198
|
+
await shutdownRuntime();
|
|
199
|
+
process.exit(code);
|
|
200
|
+
}
|
|
201
|
+
// Headless:-p / --print [prompt]。prompt 缺省时从 stdin(管道)读取。
|
|
202
|
+
const printIdx = args.findIndex((a) => a === '-p' || a === '--print');
|
|
203
|
+
if (printIdx !== -1) {
|
|
204
|
+
// 任意位置的非 flag 参数都作为 prompt(不要求紧跟 -p);排除 --sandbox-root 的值。
|
|
205
|
+
const inlinePrompt = firstPositional(args.filter((_, idx) => idx !== printIdx), { valueFlags: ['--sandbox-root'] });
|
|
206
|
+
const { runHeadless, resolvePrompt } = await import('./headless.js');
|
|
207
|
+
const { isModelConfigured } = await import('./config/index.js');
|
|
208
|
+
const prompt = await resolvePrompt(inlinePrompt);
|
|
209
|
+
if (!prompt) {
|
|
210
|
+
process.stderr.write('mocode: empty prompt (use -p "..." or pipe via stdin)\n');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
if (!isModelConfigured()) {
|
|
214
|
+
process.stderr.write('mocode: model not configured. Set LLM_BASE_URL / LLM_API_KEY / LLM_MODEL ' +
|
|
215
|
+
'(or run `mocode config`) before using -p.\n');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
const exitCode = await runHeadless({
|
|
219
|
+
prompt,
|
|
220
|
+
json: args.includes('--json'),
|
|
221
|
+
verbose: args.includes('--verbose'),
|
|
222
|
+
skipPermissions: args.includes('--dangerously-skip-permissions'),
|
|
223
|
+
sandboxRootOverride,
|
|
224
|
+
sessionDir: sessionDirOverride,
|
|
225
|
+
worktree: useWorktree,
|
|
226
|
+
...(botName ? { botName } : {}),
|
|
227
|
+
});
|
|
228
|
+
await shutdownRuntime();
|
|
229
|
+
process.exit(exitCode);
|
|
230
|
+
}
|
|
67
231
|
const i = args.indexOf('--resume');
|
|
68
232
|
if (i !== -1) {
|
|
69
233
|
const { listSessions, loadSession } = await import('./session/index.js');
|
|
@@ -97,4 +261,8 @@ async function main() {
|
|
|
97
261
|
await shutdownRuntime();
|
|
98
262
|
process.exit(0);
|
|
99
263
|
}
|
|
100
|
-
main()
|
|
264
|
+
main().catch((e) => {
|
|
265
|
+
exitAltScreen();
|
|
266
|
+
process.stderr.write(`${e instanceof Error ? e.stack || e.message : String(e)}\n`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 异步审批(park-and-wait):bg 任务在非 TTY 下需要授权时,不 fail-closed 拒绝,
|
|
2
|
+
// 而是——
|
|
3
|
+
// 1. 写 <jobId>.approval.json 请求(含工具名/参数摘要);
|
|
4
|
+
// 2. job 记录置 paused,发通知(MOCODE_NOTIFY_WEBHOOK);
|
|
5
|
+
// 3. 进程挂起,每 1.5s 轮询 <jobId>.approval.decision;
|
|
6
|
+
// 4. `mocode approve <jobId>` 写决定 → checker 在同进程内返回 allow,
|
|
7
|
+
// agent 带着完整内存历史继续,无需序列化。
|
|
8
|
+
//
|
|
9
|
+
// 已知限制:等待依赖进程存活;机器重启 / 进程被杀则需重新发起(v1 不做断点重放)。
|
|
10
|
+
// MOCODE_APPROVAL_TIMEOUT_MS 可设最大等待(默认 0 = 一直等),超时按 deny。
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { config } from '../config/index.js';
|
|
14
|
+
import { createPermissionChecker } from '../permissions/index.js';
|
|
15
|
+
import { sendNotification } from '../notify/index.js';
|
|
16
|
+
import { jobsRoot, updateJob } from './store.js';
|
|
17
|
+
function requestPath(jobId) {
|
|
18
|
+
return path.join(jobsRoot(), `${jobId}.approval.json`);
|
|
19
|
+
}
|
|
20
|
+
function decisionPath(jobId) {
|
|
21
|
+
return path.join(jobsRoot(), `${jobId}.approval.decision`);
|
|
22
|
+
}
|
|
23
|
+
function argsPreview(tool, args) {
|
|
24
|
+
if (tool === 'run_command' && typeof args.command === 'string')
|
|
25
|
+
return args.command.slice(0, 200);
|
|
26
|
+
if (typeof args.path === 'string')
|
|
27
|
+
return String(args.path);
|
|
28
|
+
if (typeof args.url === 'string')
|
|
29
|
+
return String(args.url);
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(args).slice(0, 200);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return '(unserializable args)';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeApprovalRequest(jobId, tool, args, prompt) {
|
|
38
|
+
fs.mkdirSync(jobsRoot(), { recursive: true });
|
|
39
|
+
const payload = {
|
|
40
|
+
jobId,
|
|
41
|
+
tool,
|
|
42
|
+
argsPreview: argsPreview(tool, args),
|
|
43
|
+
prompt,
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
fs.writeFileSync(requestPath(jobId), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
export function hasPendingApproval(jobId) {
|
|
49
|
+
try {
|
|
50
|
+
fs.accessSync(requestPath(jobId));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
58
|
+
/** 挂起轮询,直到外部写决定文件;消费后清理请求/决定文件。 */
|
|
59
|
+
async function awaitDecision(jobId) {
|
|
60
|
+
const dp = decisionPath(jobId);
|
|
61
|
+
const maxMs = Number(process.env.MOCODE_APPROVAL_TIMEOUT_MS ?? 0);
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
const v = fs.readFileSync(dp, 'utf8').trim();
|
|
66
|
+
if (v === 'approved' || v === 'denied') {
|
|
67
|
+
try {
|
|
68
|
+
fs.rmSync(dp);
|
|
69
|
+
fs.rmSync(requestPath(jobId));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// cleanup best-effort
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 决定文件尚未出现
|
|
79
|
+
}
|
|
80
|
+
if (maxMs > 0 && Date.now() - startedAt > maxMs) {
|
|
81
|
+
try {
|
|
82
|
+
fs.rmSync(requestPath(jobId));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
return 'denied';
|
|
88
|
+
}
|
|
89
|
+
await sleep(1500);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 异步审批 checker:包正常 checker,命中“需授权”时挂起等待。
|
|
94
|
+
* 一旦批准,jobApproved=true → 本次运行后续动作不再逐个询问(语义:批准这个任务)。
|
|
95
|
+
*/
|
|
96
|
+
export function createAsyncApprovalChecker(deps) {
|
|
97
|
+
const inner = createPermissionChecker(config, deps.sandboxRoot);
|
|
98
|
+
let jobApproved = false;
|
|
99
|
+
return async (tool, args, signal, options) => {
|
|
100
|
+
if (jobApproved)
|
|
101
|
+
return 'allow';
|
|
102
|
+
const decision = await inner(tool, args, signal, options);
|
|
103
|
+
if (decision === 'allow')
|
|
104
|
+
return 'allow';
|
|
105
|
+
// 需要授权:置 paused + 写请求 + 通知,然后挂起。
|
|
106
|
+
updateJob(deps.jobId, { status: 'paused' }, { force: true });
|
|
107
|
+
writeApprovalRequest(deps.jobId, tool.name, args, deps.getPrompt());
|
|
108
|
+
await sendNotification(config.notifyWebhook, {
|
|
109
|
+
status: 'waiting',
|
|
110
|
+
tool: tool.name,
|
|
111
|
+
prompt: deps.getPrompt(),
|
|
112
|
+
sessionId: deps.jobId,
|
|
113
|
+
elapsedMs: 0,
|
|
114
|
+
model: config.model,
|
|
115
|
+
});
|
|
116
|
+
const result = await awaitDecision(deps.jobId);
|
|
117
|
+
if (result === 'approved') {
|
|
118
|
+
jobApproved = true;
|
|
119
|
+
updateJob(deps.jobId, { status: 'running' }, { force: true });
|
|
120
|
+
return 'allow';
|
|
121
|
+
}
|
|
122
|
+
return 'deny';
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** CLI/TUI 批准或拒绝:写决定文件。无挂起请求时报错。 */
|
|
126
|
+
export function approveJob(jobId, decision) {
|
|
127
|
+
if (!hasPendingApproval(jobId))
|
|
128
|
+
return { ok: false, reason: 'no pending approval for this job' };
|
|
129
|
+
fs.writeFileSync(decisionPath(jobId), decision, 'utf8');
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
/** 列出所有挂起的审批请求。 */
|
|
133
|
+
export function listPendingApprovals() {
|
|
134
|
+
let files;
|
|
135
|
+
try {
|
|
136
|
+
files = fs.readdirSync(jobsRoot());
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
if (!f.endsWith('.approval.json'))
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
out.push(JSON.parse(fs.readFileSync(path.join(jobsRoot(), f), 'utf8')));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// skip corrupt
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `mocode approve <jobId>` / `mocode deny <jobId>`:写决定文件,唤醒挂起的 bg job。
|
|
2
|
+
import { approveJob, listPendingApprovals } from './approval.js';
|
|
3
|
+
export function runApproveCli(rawArgs, decision) {
|
|
4
|
+
let id = rawArgs[0];
|
|
5
|
+
// 无参数:恰好一个挂起审批时自动选中,否则列出全部。
|
|
6
|
+
if (!id) {
|
|
7
|
+
const pending = listPendingApprovals();
|
|
8
|
+
if (pending.length === 1) {
|
|
9
|
+
id = pending[0]?.jobId;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
if (pending.length === 0)
|
|
13
|
+
process.stdout.write('No pending approvals.\n');
|
|
14
|
+
else {
|
|
15
|
+
for (const p of pending) {
|
|
16
|
+
process.stdout.write(` ${p.jobId} ${p.tool} ${p.argsPreview.split('\n')[0]}\n`);
|
|
17
|
+
}
|
|
18
|
+
process.stdout.write('Usage: mocode approve <jobId>\n');
|
|
19
|
+
}
|
|
20
|
+
return pending.length === 1 ? 0 : 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const r = approveJob(id, decision);
|
|
24
|
+
if (!r.ok) {
|
|
25
|
+
process.stderr.write(`mocode: ${r.reason ?? 'failed'}\n`);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
process.stdout.write(`${decision === 'approved' ? 'Approved' : 'Denied'} ${id}; job continues.\n`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|