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
@@ -1,6 +1,6 @@
1
1
  import { emitKeypressEvents } from 'node:readline';
2
2
  import { stdin } from 'node:process';
3
- import { config, isModelConfigured, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, pinSessionModel, } from '../config/index.js';
3
+ import { config, isModelConfigured, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, pinSessionModel, pinSessionEffort, } from '../config/index.js';
4
4
  import { t } from '../i18n/index.js';
5
5
  import { listPresets, migrateCurrentToPreset } from '../config/presets.js';
6
6
  import { runAgent } from '../agent/index.js';
@@ -166,6 +166,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
166
166
  setAgentMode('auto');
167
167
  // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
168
168
  pinSessionModel();
169
+ pinSessionEffort();
169
170
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
170
171
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
171
172
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
@@ -305,6 +306,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
305
306
  if (!history.some((m) => m.role === 'user')) {
306
307
  layout.writeWelcomeBlock(welcomeLines());
307
308
  }
309
+ // 启动时限频自动 GC(24h 闸门,归档+purge 全做):fire-and-forget,
310
+ // 不阻塞进入主循环;runRetention 内部归档本地秒完、reflection 后台跑。可用 MOCODE_AUTO_GC=false 关闭。
311
+ void runtime.session.autoGc().catch(() => undefined);
308
312
  /**
309
313
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
310
314
  * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { ui } from '../ui/theme.js';
4
4
  import * as layout from '../ui/layout.js';
5
5
  import { t } from '../i18n/index.js';
6
- import { config } from '../config/index.js';
6
+ import { config, getActiveEffort } from '../config/index.js';
7
7
  import { contextState } from '../session/index.js';
8
8
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
9
9
  import { chatTools, correctTokenEstimate, estimateMessagesTokens, estimateToolSchemaTokens, estimateTokens, } from '../llm/index.js';
@@ -47,7 +47,8 @@ export function renderContextBar(history) {
47
47
  const lifecycleLine = lifecycle
48
48
  ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
49
49
  : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
50
- const archiveLine = `\n archived tool results · ${archived.stubbed}`;
50
+ const archiveLine = `
51
+ archived tool results · ${archived.stubbed}${archived.cleared > 0 ? ` · cleared ${archived.cleared} (low-pressure)` : ''}`;
51
52
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${measuredNote}${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
52
53
  }
53
54
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
@@ -167,6 +168,7 @@ export function refreshStatusBase(history, lastTurnUsage) {
167
168
  contextBar: renderContextBarInline(history),
168
169
  cwd: process.cwd(),
169
170
  modeTag: getAgentMode() === 'plan' ? 'Plan' : 'Auto',
171
+ effort: getActiveEffort() === 'auto' ? undefined : getActiveEffort(),
170
172
  planSummary: readPlanFromNotes(),
171
173
  lastTurnUsage,
172
174
  });
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync
3
3
  import * as fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { config } from '../config/index.js';
6
+ import { MAX_SNAPSHOT_TURNS } from '../tools/constants.js';
6
7
  import { toText } from '../context/utils.js';
7
8
  import { truncateDisplay } from '../ui/render.js';
8
9
  const EXCLUDED_WORKSPACE_DIRS = Object.freeze([
@@ -192,8 +193,24 @@ export class RollbackStore {
192
193
  this.turnIdCounter += 1;
193
194
  this.currentTurnId = this.turnIdCounter;
194
195
  this.turns.push({ turnId: this.currentTurnId, firstLine });
196
+ // 压缩前滚动窗口:snapshots.json 此前只在 compact 后剪枝,长会话压缩前持续累积
197
+ // (实测单文件 2MB)。每开新 turn 即丢掉超过 MAX_SNAPSHOT_TURNS 的老 turn 及其快照。
198
+ // env 覆盖仅供测试:MOCODE_MAX_SNAPSHOT_TURNS=2 可立刻触发滚动剪枝。
199
+ const envMax = Number(process.env.MOCODE_MAX_SNAPSHOT_TURNS);
200
+ this.pruneOldTurns(Number.isFinite(envMax) && envMax >= 1 ? envMax : MAX_SNAPSHOT_TURNS);
195
201
  return this.currentTurnId;
196
202
  }
203
+ /** 仅保留最近 maxTurns 个 turn 及其快照;currentTurnId 被剪掉时归零(不应发生,当前 turn 恒在尾部)。 */
204
+ pruneOldTurns(maxTurns) {
205
+ if (this.turns.length <= maxTurns)
206
+ return;
207
+ const kept = this.turns.slice(-maxTurns);
208
+ const alive = new Set(kept.map((turn) => turn.turnId));
209
+ this.turns = kept;
210
+ this.snapshots = this.snapshots.filter((snapshot) => alive.has(snapshot.turnId));
211
+ if (!alive.has(this.currentTurnId))
212
+ this.currentTurnId = 0;
213
+ }
197
214
  getCurrentTurnId() {
198
215
  return this.currentTurnId;
199
216
  }
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { runAgentCore } from '../agent/core.js';
3
3
  import { createAgentRuntimeContext, defaultAgentRuntimeContext, } from '../agent/runtime-context.js';
4
- import { contextState as defaultContextState, createContextState, manualCompact, } from '../session/index.js';
4
+ import { contextState as defaultContextState, createContextState, manualCompact, planRetention, runRetention, searchArchiveIndex, shouldAutoGc, writeLastAutoGc, DEFAULT_RETENTION_POLICY, } from '../session/index.js';
5
5
  const activeRuntimes = new AsyncLocalStorage();
6
6
  /** Runtime facade active in the current agent async tree, used by nested agents to borrow the parent lifecycle. */
7
7
  export function getActiveRuntime() {
@@ -54,6 +54,36 @@ export class Runtime {
54
54
  return this.context.sessionStore.list(limit);
55
55
  },
56
56
  clear: () => this.createSession(true),
57
+ gc: (async (opts = {}) => {
58
+ this.assertIdle('gc sessions');
59
+ const root = this.context.sessionStore.sessionsRoot;
60
+ const currentId = this.context.sessionStore.getCurrentSessionId();
61
+ const plan = planRetention(root, DEFAULT_RETENTION_POLICY, Date.now(), currentId);
62
+ if (opts.dryRun !== false)
63
+ return plan;
64
+ const result = await runRetention(root, plan);
65
+ this.emit('session.gc', { archived: result.archived, purged: result.purged });
66
+ return result;
67
+ }),
68
+ searchArchive: (query, limit) => {
69
+ this.assertOpen();
70
+ return searchArchiveIndex(this.context.sessionStore.sessionsRoot, query, limit ?? 10);
71
+ },
72
+ autoGc: async () => {
73
+ this.assertOpen();
74
+ const root = this.context.sessionStore.sessionsRoot;
75
+ if (!shouldAutoGc(root))
76
+ return null;
77
+ const currentId = this.context.sessionStore.getCurrentSessionId();
78
+ const plan = planRetention(root, DEFAULT_RETENTION_POLICY, Date.now(), currentId);
79
+ // 先写闸门:即使本次没有命中项,也 记录“已检查”,避免每次启动都重新扫描。
80
+ writeLastAutoGc(root);
81
+ if (plan.items.length === 0)
82
+ return null;
83
+ const result = await runRetention(root, plan);
84
+ this.emit('session.gc', { archived: result.archived, purged: result.purged, auto: true });
85
+ return result;
86
+ },
57
87
  };
58
88
  this.rollback = {
59
89
  list: () => {
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ // 调度计划存储:<sandboxRoot>/.mocode/schedules/<id>.json。
2
+ // 一个 schedule 有两种触发方式:cron(周期)或 webhook(仅 HTTP 触发,cron 为空串)。
3
+ // 同一 schedule 也可两者并存(cron 表达式 + webhookToken 都配)。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { getSandboxRoot } from '../sandbox/index.js';
8
+ import { parseCron } from './cron.js';
9
+ function schedulesRoot() {
10
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'schedules');
11
+ }
12
+ function recordPath(id) {
13
+ return path.join(schedulesRoot(), `${id}.json`);
14
+ }
15
+ export function createScheduleId() {
16
+ return `sch-${Date.now()}-${randomBytes(2).toString('hex')}`;
17
+ }
18
+ /** 校验候选字段;非法(cron 语法错 / 无任何触发器)抛错。 */
19
+ function validate(cron, webhookToken) {
20
+ if (cron.trim() !== '')
21
+ parseCron(cron);
22
+ if (!cron.trim() && !webhookToken) {
23
+ throw new Error('schedule needs a cron expression or a webhook token (no trigger configured)');
24
+ }
25
+ }
26
+ export function saveSchedule(record) {
27
+ fs.mkdirSync(schedulesRoot(), { recursive: true });
28
+ const target = recordPath(record.id);
29
+ const tmp = `${target}.tmp`;
30
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
31
+ fs.renameSync(tmp, target);
32
+ }
33
+ export function getSchedule(id) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(recordPath(id), 'utf8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** 创建并落盘一个 schedule。 */
42
+ export function createSchedule(input) {
43
+ const cron = (input.cron ?? '').trim();
44
+ const webhookToken = input.webhook ? randomBytes(8).toString('hex') : undefined;
45
+ validate(cron, webhookToken);
46
+ const record = {
47
+ id: createScheduleId(),
48
+ name: input.name,
49
+ prompt: input.prompt,
50
+ cron,
51
+ enabled: true,
52
+ ...(webhookToken ? { webhookToken } : {}),
53
+ ...(input.worktree ? { worktree: true } : {}),
54
+ ...(input.sessionDir ? { sessionDir: input.sessionDir } : {}),
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+ saveSchedule(record);
58
+ return record;
59
+ }
60
+ export function updateSchedule(id, patch) {
61
+ const current = getSchedule(id);
62
+ if (!current)
63
+ return null;
64
+ const next = { ...current, ...patch };
65
+ validate(next.cron, next.webhookToken);
66
+ saveSchedule(next);
67
+ return next;
68
+ }
69
+ export function deleteSchedule(id) {
70
+ try {
71
+ fs.rmSync(recordPath(id));
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export function listSchedules() {
79
+ let files;
80
+ try {
81
+ files = fs.readdirSync(schedulesRoot());
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ const records = [];
87
+ for (const file of files) {
88
+ if (!file.endsWith('.json'))
89
+ continue;
90
+ try {
91
+ records.push(JSON.parse(fs.readFileSync(path.join(schedulesRoot(), file), 'utf8')));
92
+ }
93
+ catch {
94
+ // skip corrupt
95
+ }
96
+ }
97
+ return records.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
98
+ }