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
|
@@ -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
|
+
];
|
|
@@ -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',
|