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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台任务命令组:/jobs · /jobs log [id前缀] · /jobs kill [id前缀]
|
|
3
|
+
*
|
|
4
|
+
* 不依赖 REPL 闭包状态(job 真相在 .mocode/jobs/),所以是这批命令里最独立的一组。
|
|
5
|
+
* id 可只给时间戳前缀(如 /jobs kill 20260926-1730),唯一定位即可。
|
|
6
|
+
*/
|
|
7
|
+
import * as layout from '../../ui/layout.js';
|
|
8
|
+
import { ui } from '../../ui/theme.js';
|
|
9
|
+
import { listJobs, readJobLog } from '../../jobs/store.js';
|
|
10
|
+
import { killBackgroundJob } from '../../jobs/launch.js';
|
|
11
|
+
import { unhandled, next } from './types.js';
|
|
12
|
+
const STATUS_COLOR = {
|
|
13
|
+
paused: ui.cyan,
|
|
14
|
+
running: ui.yellow,
|
|
15
|
+
succeeded: ui.green,
|
|
16
|
+
failed: ui.red,
|
|
17
|
+
killed: ui.dim,
|
|
18
|
+
};
|
|
19
|
+
function findJob(idPrefix) {
|
|
20
|
+
const jobs = listJobs();
|
|
21
|
+
if (!idPrefix)
|
|
22
|
+
return jobs[0];
|
|
23
|
+
const matches = jobs.filter((j) => j.id.startsWith(idPrefix));
|
|
24
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
25
|
+
}
|
|
26
|
+
function oneLinePrompt(prompt) {
|
|
27
|
+
const line = prompt.split('\n')[0] ?? '';
|
|
28
|
+
return line.length > 50 ? `${line.slice(0, 50)}…` : line;
|
|
29
|
+
}
|
|
30
|
+
function renderList() {
|
|
31
|
+
const jobs = listJobs().slice(0, 10);
|
|
32
|
+
if (jobs.length === 0) {
|
|
33
|
+
layout.contentWrite(`${ui.dim}(没有后台任务。用 mocode run --bg "任务" 启动)${ui.reset}\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const job of jobs) {
|
|
37
|
+
const color = STATUS_COLOR[job.status];
|
|
38
|
+
layout.contentWrite(` ${color}●${ui.reset} ${ui.bold}${job.id}${ui.reset} ${color}${job.status}${ui.reset}\n` +
|
|
39
|
+
` ${ui.dim}${oneLinePrompt(job.prompt)}${ui.reset}\n`);
|
|
40
|
+
}
|
|
41
|
+
layout.contentWrite(`${ui.dim} /jobs log [id前缀] 看尾日志 · /jobs kill [id前缀] 杀任务${ui.reset}\n`);
|
|
42
|
+
}
|
|
43
|
+
export const jobsCommands = [
|
|
44
|
+
// /jobs approve|deny [id前缀]
|
|
45
|
+
async (ctx) => {
|
|
46
|
+
if (ctx.cmd !== '/jobs')
|
|
47
|
+
return unhandled();
|
|
48
|
+
const parts = ctx.line.split(/\s+/);
|
|
49
|
+
if (parts[1] !== 'approve' && parts[1] !== 'deny')
|
|
50
|
+
return unhandled();
|
|
51
|
+
const job = findJob(parts[2]);
|
|
52
|
+
if (!job) {
|
|
53
|
+
layout.contentWrite(`${ui.yellow}(没找到唯一匹配的任务)${ui.reset}\n`);
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
if (job.status !== 'paused') {
|
|
57
|
+
layout.contentWrite(`${ui.dim}任务 ${job.id} 状态 ${job.status},无需审批${ui.reset}\n`);
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
const { runApproveCli } = await import('../../jobs/approve-cli.js');
|
|
61
|
+
runApproveCli([job.id], parts[1] === 'approve' ? 'approved' : 'denied');
|
|
62
|
+
return next();
|
|
63
|
+
},
|
|
64
|
+
// /jobs kill [id前缀]
|
|
65
|
+
(ctx) => {
|
|
66
|
+
if (ctx.cmd !== '/jobs')
|
|
67
|
+
return unhandled();
|
|
68
|
+
const parts = ctx.line.split(/\s+/);
|
|
69
|
+
if (parts[1] !== 'kill')
|
|
70
|
+
return unhandled();
|
|
71
|
+
const job = findJob(parts[2]);
|
|
72
|
+
if (!job) {
|
|
73
|
+
layout.contentWrite(`${ui.yellow}(没找到唯一匹配的任务)${ui.reset}\n`);
|
|
74
|
+
return next();
|
|
75
|
+
}
|
|
76
|
+
if (job.status !== 'running' && job.status !== 'paused') {
|
|
77
|
+
layout.contentWrite(`${ui.dim}任务 ${job.id} 已是 ${job.status},无需 kill${ui.reset}\n`);
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
const ok = killBackgroundJob(job);
|
|
81
|
+
layout.contentWrite(ok
|
|
82
|
+
? `${ui.yellow}已 kill 任务 ${job.id}${ui.reset}\n`
|
|
83
|
+
: `${ui.red}kill 失败(进程可能已退出;PID=${job.pid ?? '?'})${ui.reset}\n`);
|
|
84
|
+
return next();
|
|
85
|
+
},
|
|
86
|
+
// /jobs log [id前缀]
|
|
87
|
+
(ctx) => {
|
|
88
|
+
if (ctx.cmd !== '/jobs')
|
|
89
|
+
return unhandled();
|
|
90
|
+
const parts = ctx.line.split(/\s+/);
|
|
91
|
+
if (parts[1] !== 'log')
|
|
92
|
+
return unhandled();
|
|
93
|
+
const job = findJob(parts[2]);
|
|
94
|
+
if (!job) {
|
|
95
|
+
layout.contentWrite(`${ui.yellow}(没找到唯一匹配的任务)${ui.reset}\n`);
|
|
96
|
+
return next();
|
|
97
|
+
}
|
|
98
|
+
layout.contentWrite(`${ui.bold}── log ${job.id} ──${ui.reset}\n`);
|
|
99
|
+
layout.contentWrite(`${readJobLog(job.id)}\n`);
|
|
100
|
+
return next();
|
|
101
|
+
},
|
|
102
|
+
// /jobs(列表)
|
|
103
|
+
(ctx) => {
|
|
104
|
+
if (ctx.line !== '/jobs')
|
|
105
|
+
return unhandled();
|
|
106
|
+
renderList();
|
|
107
|
+
return next();
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -246,26 +246,11 @@ export const modelCommands = [
|
|
|
246
246
|
}
|
|
247
247
|
// /model 无参 → 直接进入「配置新模型」4 步向导(不弹动作菜单)。
|
|
248
248
|
// 切换 / 查看 / 删除已配置预设改用显式子命令:/model switch · /model list · /model delete <name>。
|
|
249
|
-
// 1)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
title: '选择后端预设(预填 baseURL,后续可改)',
|
|
255
|
-
detail: '选一个会预填 baseURL/model/窗口,之后逐项确认。选「自定义」全部手填。',
|
|
256
|
-
options: MODEL_PRESETS.map((p) => p.label),
|
|
257
|
-
});
|
|
258
|
-
if (res.action === 'cancelled') {
|
|
259
|
-
return next();
|
|
260
|
-
}
|
|
261
|
-
const idx = MODEL_PRESETS.findIndex((p) => p.label === res.value);
|
|
262
|
-
if (idx === -1) {
|
|
263
|
-
return next();
|
|
264
|
-
}
|
|
265
|
-
preset = MODEL_PRESETS[idx];
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
return next(); // Ctrl+C
|
|
249
|
+
// 1) 后端预设:本地/远程都可由一个 OpenAI 兼容端点表达,故只保留「自定义 base_url」
|
|
250
|
+
// 一个入口;既然只有一个预设,不再弹选择菜单,直接进入字段填写。
|
|
251
|
+
const preset = MODEL_PRESETS[0];
|
|
252
|
+
if (!preset) {
|
|
253
|
+
return next();
|
|
269
254
|
}
|
|
270
255
|
// 1.5) 一键应用确认:非「自定义」预设(带预填值)给直接应用入口,免连按 4 次回车。
|
|
271
256
|
// 直接应用 = 用预设 model/baseURL/window + 保留当前 apiKey(等价于下方逐项链连按回车)。
|
|
@@ -16,6 +16,9 @@ import { modelCatalogCommands } from './model-catalog.js';
|
|
|
16
16
|
import { effortCommands } from './effort.js';
|
|
17
17
|
import { routerCommands } from './router.js';
|
|
18
18
|
import { toolGroupCommands } from './tool-group.js';
|
|
19
|
+
import { jobsCommands } from './jobs.js';
|
|
20
|
+
import { scheduleCommands } from './schedule.js';
|
|
21
|
+
import { botsCommands } from './bots.js';
|
|
19
22
|
/**
|
|
20
23
|
* 命令注册表。顺序即匹配优先级 —— 更具体的分支放前面。
|
|
21
24
|
* 组内顺序由各文件自己保证(见文件头注释);组间顺序互不影响(条件互斥)。
|
|
@@ -40,6 +43,9 @@ export const commandHandlers = [
|
|
|
40
43
|
...modelCommands,
|
|
41
44
|
...routerCommands,
|
|
42
45
|
...toolGroupCommands,
|
|
46
|
+
...jobsCommands,
|
|
47
|
+
...scheduleCommands,
|
|
48
|
+
...botsCommands,
|
|
43
49
|
];
|
|
44
50
|
/**
|
|
45
51
|
* 依次询问 handler,返回第一个非 unhandled 的结果。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调度计划命令:/schedules
|
|
3
|
+
* 只读列出 schedules + 守护进程状态;增删改/启停走 CLI `mocode schedule …`。
|
|
4
|
+
*/
|
|
5
|
+
import * as layout from '../../ui/layout.js';
|
|
6
|
+
import { ui } from '../../ui/theme.js';
|
|
7
|
+
import { listSchedules } from '../../schedule/store.js';
|
|
8
|
+
import { isDaemonRunning, getDaemonState } from '../../schedule/daemon.js';
|
|
9
|
+
import { unhandled, next } from './types.js';
|
|
10
|
+
function oneLine(prompt) {
|
|
11
|
+
const line = prompt.split('\n')[0] ?? '';
|
|
12
|
+
return line.length > 50 ? `${line.slice(0, 50)}…` : line;
|
|
13
|
+
}
|
|
14
|
+
export const scheduleCommands = [
|
|
15
|
+
(ctx) => {
|
|
16
|
+
if (ctx.line !== '/schedules')
|
|
17
|
+
return unhandled();
|
|
18
|
+
const daemonState = getDaemonState();
|
|
19
|
+
const daemon = isDaemonRunning() && daemonState;
|
|
20
|
+
layout.contentWrite(daemon
|
|
21
|
+
? `${ui.dim}scheduler:${ui.reset} ${ui.green}running${ui.reset} ` +
|
|
22
|
+
`${ui.dim}(pid ${daemonState?.pid}, port ${daemonState?.port})${ui.reset}\n`
|
|
23
|
+
: `${ui.dim}scheduler:${ui.reset} ${ui.yellow}stopped${ui.reset} ` +
|
|
24
|
+
`${ui.dim}(mocode schedule start)${ui.reset}\n`);
|
|
25
|
+
const all = listSchedules();
|
|
26
|
+
if (all.length === 0) {
|
|
27
|
+
layout.contentWrite(`${ui.dim}(no schedules; mocode schedule add …)${ui.reset}\n`);
|
|
28
|
+
return next();
|
|
29
|
+
}
|
|
30
|
+
for (const s of all) {
|
|
31
|
+
const trigger = s.cron ? s.cron : s.webhookToken ? `webhook:${s.webhookToken}` : '?';
|
|
32
|
+
layout.contentWrite(` ${s.enabled ? ui.green + '●' : ui.dim + '○'}${ui.reset} ${ui.bold}${s.name}${ui.reset} ` +
|
|
33
|
+
`${ui.dim}${trigger}${ui.reset}\n ${ui.dim}${oneLine(s.prompt)}${ui.reset}\n`);
|
|
34
|
+
}
|
|
35
|
+
return next();
|
|
36
|
+
},
|
|
37
|
+
];
|
package/dist/repl/commands.js
CHANGED
|
@@ -30,6 +30,9 @@ export function buildSlashCommands() {
|
|
|
30
30
|
{ name: '/sessions', desc: d('commands.sessionBrowse') },
|
|
31
31
|
{ name: '/rollback', desc: d('commands.sessionRollback') },
|
|
32
32
|
{ name: '/ssearch <关键词>', value: '/ssearch ', submit: false, desc: '跨会话搜索归档会话' },
|
|
33
|
+
{ name: '/jobs', desc: '后台任务列表(/jobs log/kill <id前缀>)' },
|
|
34
|
+
{ name: '/schedules', desc: '查看定时计划与调度守护状态' },
|
|
35
|
+
{ name: '/bots', desc: '查看具名 Bot 列表' },
|
|
33
36
|
{
|
|
34
37
|
name: '/memory',
|
|
35
38
|
desc: d('commands.memory'),
|
|
@@ -271,64 +274,6 @@ export function themeDescription(name) {
|
|
|
271
274
|
}
|
|
272
275
|
/** /model 预设后端:同时声明原生协议;旧服务继续走 OpenAI-compatible。 */
|
|
273
276
|
export const MODEL_PRESETS = [
|
|
274
|
-
{
|
|
275
|
-
label: 'Anthropic Claude',
|
|
276
|
-
provider: 'anthropic',
|
|
277
|
-
baseURL: 'https://api.anthropic.com',
|
|
278
|
-
model: 'claude-sonnet-4-5',
|
|
279
|
-
window: 200000,
|
|
280
|
-
anthropicPromptCache: true,
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
label: 'GLM(智谱)',
|
|
284
|
-
provider: 'openai',
|
|
285
|
-
baseURL: 'https://open.bigmodel.cn/api/v3',
|
|
286
|
-
model: 'glm-4.6',
|
|
287
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
288
|
-
anthropicPromptCache: false,
|
|
289
|
-
},
|
|
290
|
-
{
|
|
291
|
-
label: 'DeepSeek',
|
|
292
|
-
provider: 'openai',
|
|
293
|
-
baseURL: 'https://api.deepseek.com',
|
|
294
|
-
model: 'deepseek-chat',
|
|
295
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
296
|
-
anthropicPromptCache: false,
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
label: 'Qwen(阿里)',
|
|
300
|
-
provider: 'openai',
|
|
301
|
-
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
302
|
-
model: 'qwen-plus',
|
|
303
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
304
|
-
anthropicPromptCache: false,
|
|
305
|
-
},
|
|
306
|
-
// MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
|
|
307
|
-
// MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
|
|
308
|
-
{
|
|
309
|
-
label: 'MiniMax',
|
|
310
|
-
provider: 'openai',
|
|
311
|
-
baseURL: 'https://api.minimax.io/v1',
|
|
312
|
-
model: 'MiniMax-M3',
|
|
313
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
314
|
-
anthropicPromptCache: false,
|
|
315
|
-
},
|
|
316
|
-
{
|
|
317
|
-
label: '本地 Ollama',
|
|
318
|
-
provider: 'openai',
|
|
319
|
-
baseURL: 'http://localhost:11434/v1',
|
|
320
|
-
model: 'qwen2.5:7b',
|
|
321
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
322
|
-
anthropicPromptCache: false,
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
label: '本地 vLLM',
|
|
326
|
-
provider: 'openai',
|
|
327
|
-
baseURL: 'http://localhost:8000/v1',
|
|
328
|
-
model: 'default',
|
|
329
|
-
window: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
330
|
-
anthropicPromptCache: false,
|
|
331
|
-
},
|
|
332
277
|
{
|
|
333
278
|
label: '自定义 base_url',
|
|
334
279
|
provider: 'openai',
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `mocode schedule …` 命令行管理:
|
|
2
|
+
// add --name <n> (--cron "<5段>" | --webhook | 两者) --prompt "<任务>" [--worktree]
|
|
3
|
+
// list | rm <id前缀> | enable <id前缀> | disable <id前缀>
|
|
4
|
+
// start | stop | status | tick
|
|
5
|
+
// add 成功后打印触发方式(cron / webhook URL);cron 类需 start 拉起守护进程才生效。
|
|
6
|
+
import { createSchedule, listSchedules, deleteSchedule, updateSchedule } from './store.js';
|
|
7
|
+
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonState, runTick } from './daemon.js';
|
|
8
|
+
function flagValue(args, flag) {
|
|
9
|
+
const i = args.indexOf(flag);
|
|
10
|
+
if (i === -1)
|
|
11
|
+
return undefined;
|
|
12
|
+
const v = args[i + 1];
|
|
13
|
+
return v && !v.startsWith('-') ? v : undefined;
|
|
14
|
+
}
|
|
15
|
+
function findByPrefix(prefix) {
|
|
16
|
+
const all = listSchedules();
|
|
17
|
+
if (!prefix)
|
|
18
|
+
return all.length === 1 ? all[0] : undefined;
|
|
19
|
+
const m = all.filter((s) => (prefix ? s.id.startsWith(prefix) || s.name === prefix : false));
|
|
20
|
+
return m.length === 1 ? m[0] : undefined;
|
|
21
|
+
}
|
|
22
|
+
export async function runScheduleCli(rawArgs) {
|
|
23
|
+
const sub = rawArgs[0] ?? 'list';
|
|
24
|
+
const args = rawArgs.slice(1);
|
|
25
|
+
switch (sub) {
|
|
26
|
+
case 'add': {
|
|
27
|
+
const name = flagValue(args, '--name');
|
|
28
|
+
const prompt = flagValue(args, '--prompt');
|
|
29
|
+
const cron = flagValue(args, '--cron');
|
|
30
|
+
const webhook = args.includes('--webhook');
|
|
31
|
+
const worktree = args.includes('--worktree');
|
|
32
|
+
const botForSchedule = flagValue(args, '--bot');
|
|
33
|
+
if (!name || !prompt) {
|
|
34
|
+
process.stderr.write('usage: mocode schedule add --name <n> --prompt "<task>" (--cron "<5 fields>" and/or --webhook) [--worktree]\n');
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const rec = createSchedule({ name, prompt, cron, webhook, worktree, botName: botForSchedule });
|
|
39
|
+
process.stdout.write(`Schedule created: ${rec.id}\n name: ${rec.name}\n`);
|
|
40
|
+
if (rec.cron)
|
|
41
|
+
process.stdout.write(` cron: ${rec.cron}(需 mocode schedule start)\n`);
|
|
42
|
+
if (rec.webhookToken) {
|
|
43
|
+
const port = getDaemonState()?.port ?? 8788;
|
|
44
|
+
process.stdout.write(` webhook: POST http://127.0.0.1:${port}/trigger/${rec.webhookToken}(需 schedule start)\n`);
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
process.stderr.write(`mocode: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
case 'rm':
|
|
54
|
+
case 'remove': {
|
|
55
|
+
const rec = findByPrefix(args[0]);
|
|
56
|
+
if (!rec) {
|
|
57
|
+
process.stderr.write('mocode: no unique matching schedule\n');
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
deleteSchedule(rec.id);
|
|
61
|
+
process.stdout.write(`Removed ${rec.id} (${rec.name})\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
case 'enable':
|
|
65
|
+
case 'disable': {
|
|
66
|
+
const rec = findByPrefix(args[0]);
|
|
67
|
+
if (!rec) {
|
|
68
|
+
process.stderr.write('mocode: no unique matching schedule\n');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
updateSchedule(rec.id, { enabled: sub === 'enable' });
|
|
72
|
+
process.stdout.write(`${rec.name} ${sub}d\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
case 'start': {
|
|
76
|
+
const r = startDaemon();
|
|
77
|
+
if (r.started && r.state) {
|
|
78
|
+
process.stdout.write(`Scheduler started (pid ${r.state.pid}, port ${r.state.port})\n`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
process.stdout.write(`Scheduler not started: ${r.reason ?? 'unknown'}\n`);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
case 'stop':
|
|
86
|
+
process.stdout.write(stopDaemon() ? 'Scheduler stopped\n' : 'Scheduler was not running\n');
|
|
87
|
+
return 0;
|
|
88
|
+
case 'status': {
|
|
89
|
+
const state = getDaemonState();
|
|
90
|
+
process.stdout.write(isDaemonRunning() && state
|
|
91
|
+
? `running (pid ${state.pid}, port ${state.port}, since ${state.startedAt})\n`
|
|
92
|
+
: 'stopped\n');
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
case 'tick': {
|
|
96
|
+
const fired = await runTick();
|
|
97
|
+
process.stdout.write(`tick done: ${fired} fired\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
case 'list':
|
|
101
|
+
default: {
|
|
102
|
+
const all = listSchedules();
|
|
103
|
+
if (all.length === 0) {
|
|
104
|
+
process.stdout.write('(no schedules; add one with `mocode schedule add …`)\n');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
for (const s of all) {
|
|
108
|
+
const trigger = s.cron ? s.cron : s.webhookToken ? `webhook:${s.webhookToken}` : '?';
|
|
109
|
+
process.stdout.write(`${s.enabled ? '●' : '○'} ${s.id} ${s.name}\n ${trigger}\n ${s.prompt.split('\n')[0]}\n`);
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 标准 5 段 cron 匹配(无第三方依赖):分 时 日(月) 月 周
|
|
2
|
+
// 字段支持:*、数字、逗号列表 1,2,3、范围 1-5、步长 */15 或 1-10/2。
|
|
3
|
+
// 周:0-6,0=周日。
|
|
4
|
+
// 经典语义:当日(月)与周都不是 * 时,满足其一即触发;其一为 * 时只看另一个。
|
|
5
|
+
const RANGES = {
|
|
6
|
+
minute: { min: 0, max: 59 },
|
|
7
|
+
hour: { min: 0, max: 23 },
|
|
8
|
+
dom: { min: 1, max: 31 },
|
|
9
|
+
month: { min: 1, max: 12 },
|
|
10
|
+
dow: { min: 0, max: 6 },
|
|
11
|
+
};
|
|
12
|
+
/** 解析单个字段为允许值集合。非法表达式抛错。 */
|
|
13
|
+
export function parseField(raw, range) {
|
|
14
|
+
const values = new Set();
|
|
15
|
+
for (const part of raw.split(',')) {
|
|
16
|
+
if (part === '')
|
|
17
|
+
throw new Error(`empty field part in "${raw}"`);
|
|
18
|
+
const [body, stepRaw] = part.split('/');
|
|
19
|
+
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
20
|
+
if (!Number.isInteger(step) || step < 1)
|
|
21
|
+
throw new Error(`bad step in "${part}"`);
|
|
22
|
+
let lo;
|
|
23
|
+
let hi;
|
|
24
|
+
if (body === '*') {
|
|
25
|
+
lo = range.min;
|
|
26
|
+
hi = range.max;
|
|
27
|
+
}
|
|
28
|
+
else if (body.includes('-')) {
|
|
29
|
+
const [a, b] = body.split('-');
|
|
30
|
+
lo = Number(a);
|
|
31
|
+
hi = Number(b);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
lo = Number(body);
|
|
35
|
+
hi = stepRaw === undefined ? lo : range.max; // "5/10" = 5,15,25…
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < range.min || hi > range.max || lo > hi) {
|
|
38
|
+
throw new Error(`out-of-range or invalid "${part}" (allowed ${range.min}-${range.max})`);
|
|
39
|
+
}
|
|
40
|
+
for (let v = lo; v <= hi; v += step)
|
|
41
|
+
values.add(v);
|
|
42
|
+
}
|
|
43
|
+
return values;
|
|
44
|
+
}
|
|
45
|
+
export function parseCron(expr) {
|
|
46
|
+
const fields = expr.trim().split(/\s+/);
|
|
47
|
+
if (fields.length !== 5)
|
|
48
|
+
throw new Error(`cron must have exactly 5 fields, got ${fields.length}`);
|
|
49
|
+
const [m, h, dom, mon, dow] = fields;
|
|
50
|
+
return {
|
|
51
|
+
minute: parseField(m, RANGES.minute),
|
|
52
|
+
hour: parseField(h, RANGES.hour),
|
|
53
|
+
dom: parseField(dom, RANGES.dom),
|
|
54
|
+
month: parseField(mon, RANGES.month),
|
|
55
|
+
dow: parseField(dow, RANGES.dow),
|
|
56
|
+
domRestricted: dom !== '*',
|
|
57
|
+
dowRestricted: dow !== '*',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** 给定时间是否命中 cron。 */
|
|
61
|
+
export function cronMatches(expr, date = new Date()) {
|
|
62
|
+
const c = parseCron(expr);
|
|
63
|
+
if (!c.month.has(date.getMonth() + 1))
|
|
64
|
+
return false;
|
|
65
|
+
if (!c.hour.has(date.getHours()))
|
|
66
|
+
return false;
|
|
67
|
+
if (!c.minute.has(date.getMinutes()))
|
|
68
|
+
return false;
|
|
69
|
+
const domMatch = c.dom.has(date.getDate());
|
|
70
|
+
const dowMatch = c.dow.has(date.getDay());
|
|
71
|
+
// 两者都受限 → OR;否则 AND 到 *(即只看受限方)。
|
|
72
|
+
if (c.domRestricted && c.dowRestricted)
|
|
73
|
+
return domMatch || dowMatch;
|
|
74
|
+
if (c.domRestricted)
|
|
75
|
+
return domMatch;
|
|
76
|
+
if (c.dowRestricted)
|
|
77
|
+
return dowMatch;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// 调度守护进程:detached 常驻,两件事——
|
|
2
|
+
// 1. 每 15s 扫描 schedules,cron 命中当前分钟即 launch 一个后台 job(分钟键去重,
|
|
3
|
+
// lastRunAt 落盘,重启 / tick 模式也不会同分钟重复触发)。
|
|
4
|
+
// 2. 在 127.0.0.1 起 HTTP 端点:POST /trigger/<token> 立即触发,GET /health 探活。
|
|
5
|
+
// 仅监听回环;用 node:http 直连(fetch 会被 HTTP_PROXY 干扰,见 notify 模块的坑)。
|
|
6
|
+
//
|
|
7
|
+
// 另有 runTick():单次扫描模式,供系统任务计划(Windows 任务计划/launchd + cron)
|
|
8
|
+
// 每 1-5 分钟调用一次,不想养常驻进程时用它。
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { spawnDetached, killTree } from '../jobs/launch.js';
|
|
13
|
+
import { getSandboxRoot } from '../sandbox/index.js';
|
|
14
|
+
import { cronMatches } from './cron.js';
|
|
15
|
+
import { listSchedules, updateSchedule } from './store.js';
|
|
16
|
+
const DEFAULT_PORT = 8788;
|
|
17
|
+
function stateDir() {
|
|
18
|
+
return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'schedules');
|
|
19
|
+
}
|
|
20
|
+
function daemonStatePath() {
|
|
21
|
+
return path.join(stateDir(), 'daemon.json');
|
|
22
|
+
}
|
|
23
|
+
function daemonLogPath() {
|
|
24
|
+
return path.join(stateDir(), 'daemon.log');
|
|
25
|
+
}
|
|
26
|
+
export function getDaemonState() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(daemonStatePath(), 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** 进程是否存活(pid 复用概率低,足够判活)。 */
|
|
35
|
+
function isPidAlive(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function isDaemonRunning() {
|
|
45
|
+
const state = getDaemonState();
|
|
46
|
+
return !!state && isPidAlive(state.pid);
|
|
47
|
+
}
|
|
48
|
+
/** 本地分钟键(本地时区,与 cron 的 getHours 等对齐;ISO 字符串是 UTC 不能用)。 */
|
|
49
|
+
function minuteKey(d) {
|
|
50
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
51
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
52
|
+
}
|
|
53
|
+
/** 触发一个 schedule:launch 后台 job + 记 lastRunAt。 */
|
|
54
|
+
async function fireSchedule(s, now) {
|
|
55
|
+
const { launchBackgroundJob } = await import('../jobs/launch.js');
|
|
56
|
+
const { record } = launchBackgroundJob(s.prompt, {
|
|
57
|
+
...(s.worktree ? { worktree: true } : {}),
|
|
58
|
+
...(s.sessionDir ? { sessionDir: s.sessionDir } : {}),
|
|
59
|
+
...(s.botName ? { botName: s.botName } : {}),
|
|
60
|
+
});
|
|
61
|
+
updateSchedule(s.id, { lastRunAt: now.toISOString() });
|
|
62
|
+
process.stdout.write(`[${now.toISOString()}] fired ${s.name} (${s.id}) → job ${record.id}\n`);
|
|
63
|
+
}
|
|
64
|
+
/** 单次扫描:所有 enabled 且有 cron 的 schedule,命中当前分钟且本分钟未跑过则触发。 */
|
|
65
|
+
export async function runTick(now = new Date()) {
|
|
66
|
+
const key = minuteKey(now);
|
|
67
|
+
let fired = 0;
|
|
68
|
+
for (const s of listSchedules()) {
|
|
69
|
+
if (!s.enabled || !s.cron)
|
|
70
|
+
continue;
|
|
71
|
+
if (s.lastRunAt && minuteKey(new Date(s.lastRunAt)) === key)
|
|
72
|
+
continue;
|
|
73
|
+
let hit = false;
|
|
74
|
+
try {
|
|
75
|
+
hit = cronMatches(s.cron, now);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 损坏的 cron 跳过
|
|
79
|
+
}
|
|
80
|
+
if (hit) {
|
|
81
|
+
await fireSchedule(s, now);
|
|
82
|
+
fired += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fired;
|
|
86
|
+
}
|
|
87
|
+
/** 读取并丢弃请求体。 */
|
|
88
|
+
function drainBody(req) {
|
|
89
|
+
return new Promise((resolvePromise) => {
|
|
90
|
+
req.on('data', () => { });
|
|
91
|
+
req.on('end', () => resolvePromise());
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** 常驻守护:tick 循环 + HTTP 端点。 */
|
|
95
|
+
export function runDaemon(port = DEFAULT_PORT) {
|
|
96
|
+
const log = (msg) => {
|
|
97
|
+
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
98
|
+
};
|
|
99
|
+
void runTick();
|
|
100
|
+
const timer = setInterval(() => {
|
|
101
|
+
void runTick().catch((e) => log(`tick error: ${String(e)}`));
|
|
102
|
+
}, 15000);
|
|
103
|
+
timer.unref?.();
|
|
104
|
+
const server = http.createServer((req, res) => {
|
|
105
|
+
const url = req.url ?? '';
|
|
106
|
+
if (req.method === 'GET' && url === '/health') {
|
|
107
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ ok: true, schedules: listSchedules().length }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const trigger = url.match(/^\/trigger\/([A-Za-z0-9]+)\/?$/);
|
|
112
|
+
if ((req.method === 'POST' || req.method === 'GET') && trigger) {
|
|
113
|
+
void drainBody(req).then(async () => {
|
|
114
|
+
const token = trigger[1] ?? '';
|
|
115
|
+
const sched = listSchedules().find((s) => s.webhookToken === token && s.enabled);
|
|
116
|
+
if (!sched) {
|
|
117
|
+
res.writeHead(404);
|
|
118
|
+
res.end('unknown or disabled token');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const { launchBackgroundJob } = await import('../jobs/launch.js');
|
|
122
|
+
const { record } = launchBackgroundJob(sched.prompt, {
|
|
123
|
+
...(sched.worktree ? { worktree: true } : {}),
|
|
124
|
+
...(sched.sessionDir ? { sessionDir: sched.sessionDir } : {}),
|
|
125
|
+
...(sched.botName ? { botName: sched.botName } : {}),
|
|
126
|
+
});
|
|
127
|
+
updateSchedule(sched.id, { lastRunAt: new Date().toISOString() });
|
|
128
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
129
|
+
res.end(JSON.stringify({ ok: true, jobId: record.id }));
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
res.writeHead(404);
|
|
134
|
+
res.end('not found');
|
|
135
|
+
});
|
|
136
|
+
server.listen(port, '127.0.0.1', () => log(`scheduler daemon listening on 127.0.0.1:${port}`));
|
|
137
|
+
}
|
|
138
|
+
/** 启动 detached 守护进程;已在跑则直接返回当前状态。 */
|
|
139
|
+
export function startDaemon(port = DEFAULT_PORT) {
|
|
140
|
+
const existing = getDaemonState();
|
|
141
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
142
|
+
return { started: false, state: existing, reason: 'already running' };
|
|
143
|
+
}
|
|
144
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
145
|
+
const logPath = daemonLogPath();
|
|
146
|
+
const child = spawnDetached(['--schedule-daemon', '--port', String(port)], logPath);
|
|
147
|
+
if (!child.pid)
|
|
148
|
+
return { started: false, state: null, reason: 'spawn failed' };
|
|
149
|
+
const state = { pid: child.pid, port, startedAt: new Date().toISOString() };
|
|
150
|
+
fs.writeFileSync(daemonStatePath(), `${JSON.stringify(state, null, 2)}\n`);
|
|
151
|
+
return { started: true, state };
|
|
152
|
+
}
|
|
153
|
+
/** 停止守护进程并清状态。 */
|
|
154
|
+
export function stopDaemon() {
|
|
155
|
+
const state = getDaemonState();
|
|
156
|
+
if (!state)
|
|
157
|
+
return false;
|
|
158
|
+
const ok = killTree(state.pid);
|
|
159
|
+
try {
|
|
160
|
+
fs.rmSync(daemonStatePath());
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
return ok;
|
|
166
|
+
}
|