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
@@ -0,0 +1,302 @@
1
+ // Headless(一次性 / 非交互)模式:mocode -p "任务" 或 echo "任务" | mocode。
2
+ // 与 REPL 共用同一套装配(builtins 工具包 + MCP + runAgentCore),区别只在展示:
3
+ // 没有全屏 TUI,正文流式写 stdout,进度写 stderr(保 stdout 可被管道解析)。
4
+ //
5
+ // 工具进度:默认每个调用打一行「[工具名] 关键参数」(summarizeToolCall,与 TUI
6
+ // 同源;browser fill 等敏感值刻意不显示)。--verbose 追加工具结果摘要。
7
+ //
8
+ // 具名 Bot(C2):--bot <name> 时用 Bot 的岗位提示作为角色前缀,并按其工具白名单
9
+ // 过滤可调用工具(toolsOverride + runtimeAllowedToolNames,schema 即上限)。
10
+ //
11
+ // 隔离:--session-dir <path> 会话独立目录;--worktree 在 git detached worktree 中
12
+ // 执行并自动销毁。两者走独立 Runtime(不碰全局单例)。
13
+ //
14
+ // 权限:非交互默认 fail-closed(permissions/index.ts 的 !isTTY 分支)。
15
+ // --dangerously-skip-permissions 显式关闭权限闸。
16
+ import path from 'node:path';
17
+ import { setSandboxRoot } from './sandbox/root.js';
18
+ import { registerToolsExtension } from './tools/registry.js';
19
+ // 装配官方默认工具包:registry 不顶层 import builtins(破模块循环),入口须显式装配。
20
+ import './tools/builtins/index.js';
21
+ import { initializeAllMcp, getMcpTools, closeAllMcp } from './mcp/index.js';
22
+ import { config, buildBasePrompt, isMemoryEnabled } from './config/index.js';
23
+ import { effectiveSystemPrompt } from './skills/index.js';
24
+ import { buildMemoryIndexSection } from './memory/store.js';
25
+ import { defaultRuntime, Runtime } from './runtime/index.js';
26
+ import { runAgentCore } from './agent/core.js';
27
+ import { summarizeToolCall, summarizeToolResult } from './ui/render.js';
28
+ import { getToolChatSchema } from './tools/policy.js';
29
+ import { createWorktree, removeWorktree } from './jobs/worktree.js';
30
+ import { runAsIdentity } from './bots/bus.js';
31
+ import { createAsyncApprovalChecker } from './jobs/approval.js';
32
+ import { writeCheckpoint, readCheckpoint } from './jobs/checkpoint.js';
33
+ import { getBot } from './bots/store.js';
34
+ /** 读取 stdin 全部内容作为 prompt(管道场景)。 */
35
+ async function readStdin() {
36
+ let data = '';
37
+ process.stdin.setEncoding('utf8');
38
+ for await (const chunk of process.stdin)
39
+ data += chunk;
40
+ return data;
41
+ }
42
+ /**
43
+ * 解析 Bot:查记录、解析其沙箱范围、校验白名单。
44
+ * 返回 null 表示未使用 Bot;找不到 Bot 抛错(不静默用默认身份,避免误以为岗位生效)。
45
+ */
46
+ function resolveBot(botName, baseSandbox) {
47
+ if (!botName)
48
+ return null;
49
+ const bot = getBot(botName);
50
+ if (!bot)
51
+ throw new Error(`bot "${botName}" not found (project .mocode/bots or global ~/.mocode/bots)`);
52
+ let sandboxRoot = baseSandbox;
53
+ if (bot.sandboxPath) {
54
+ // project bot 相对其工作区;global bot 相对当前 cwd。
55
+ const anchor = bot.scope === 'project' ? baseSandbox : process.cwd();
56
+ sandboxRoot = path.resolve(anchor, bot.sandboxPath);
57
+ }
58
+ return { bot, sandboxRoot };
59
+ }
60
+ /** 从工具目录按白名单构造 schema 集 + 允许名集合。 */
61
+ function buildBotToolFilter(catalog, allowNames) {
62
+ const wanted = new Set(allowNames);
63
+ const toolsOverride = catalog
64
+ .filter((t) => wanted.has(t.name))
65
+ .map((t) => getToolChatSchema(t.name, catalog))
66
+ .filter((s) => s !== null);
67
+ return { toolsOverride, allowed: new Set(toolsOverride.map((s) => s.function.name)) };
68
+ }
69
+ export async function runHeadless(opts) {
70
+ const prompt = (opts.prompt ?? '').trim();
71
+ if (!prompt) {
72
+ process.stderr.write('mocode: empty prompt (use -p "..." or pipe via stdin)\n');
73
+ return 1;
74
+ }
75
+ if (opts.skipPermissions)
76
+ config.permissionEnabled = false;
77
+ // worktree 先建(失败明确报错,不静默回退主工作区)。
78
+ let wt = null;
79
+ if (opts.worktree) {
80
+ try {
81
+ wt = createWorktree();
82
+ }
83
+ catch (e) {
84
+ process.stderr.write(`mocode: worktree setup failed: ${e instanceof Error ? e.message : String(e)}\n`);
85
+ return 1;
86
+ }
87
+ }
88
+ const initialSandbox = wt?.path ?? opts.sandboxRootOverride ?? config.sandboxRoot ?? process.cwd();
89
+ // Bot 解析(在选沙箱之后:bot.sandboxPath 可进一步收窄)。
90
+ let botInfo;
91
+ try {
92
+ botInfo = resolveBot(opts.botName, initialSandbox);
93
+ }
94
+ catch (e) {
95
+ if (wt) {
96
+ try {
97
+ removeWorktree(wt);
98
+ }
99
+ catch {
100
+ // ignore
101
+ }
102
+ }
103
+ process.stderr.write(`mocode: ${e instanceof Error ? e.message : String(e)}\n`);
104
+ return 1;
105
+ }
106
+ const effectiveSandboxRoot = botInfo?.sandboxRoot ?? initialSandbox;
107
+ const isolated = wt !== null || opts.sessionDir !== undefined || opts.jobId !== undefined;
108
+ // MCP 先初始化注册:独立 Runtime 创建时复制当前 builtinTools(含 MCP),须早于 new Runtime。
109
+ await initializeAllMcp();
110
+ registerToolsExtension('mcp', getMcpTools());
111
+ let runtime;
112
+ if (isolated) {
113
+ runtime = new Runtime({
114
+ sandboxRoot: effectiveSandboxRoot,
115
+ ...(opts.sessionDir ? { configOverrides: { sessionDir: path.resolve(opts.sessionDir) } } : {}),
116
+ // bg job:注入异步审批 checker(命中需授权动作时挂起,等 mocode approve)。
117
+ ...(opts.jobId
118
+ ? {
119
+ services: {
120
+ checkPermission: createAsyncApprovalChecker({
121
+ jobId: opts.jobId,
122
+ sandboxRoot: effectiveSandboxRoot,
123
+ getPrompt: () => prompt,
124
+ }),
125
+ },
126
+ }
127
+ : {}),
128
+ });
129
+ }
130
+ else {
131
+ setSandboxRoot(effectiveSandboxRoot);
132
+ runtime = defaultRuntime;
133
+ }
134
+ await runtime.start();
135
+ const sessionId = runtime.session.create();
136
+ // Bot 工具白名单:从该 runtime 实际工具目录过滤。
137
+ let toolFilter = null;
138
+ if (botInfo?.bot.tools && botInfo.bot.tools.length) {
139
+ toolFilter = buildBotToolFilter(runtime.context.toolRuntime.tools, botInfo.bot.tools);
140
+ if (toolFilter.toolsOverride.length === 0) {
141
+ process.stderr.write('mocode: bot tool whitelist matched no available tools\n');
142
+ await runtime.close();
143
+ if (wt) {
144
+ try {
145
+ removeWorktree(wt);
146
+ }
147
+ catch {
148
+ // ignore
149
+ }
150
+ }
151
+ return 1;
152
+ }
153
+ }
154
+ // 系统提示:Bot 岗位角色在前,基础约定在后;白名单存在时补一句工具边界。
155
+ const baseConventions = buildBasePrompt(sessionId) + buildMemoryIndexSection(isMemoryEnabled());
156
+ const systemContent = botInfo
157
+ ? `# Role\n${botInfo.bot.systemPrompt}\n\n` +
158
+ `${botInfo.bot.description ? `**Position:** ${botInfo.bot.description}\n\n` : ''}` +
159
+ `---\n\n${baseConventions}` +
160
+ (toolFilter ? `\n\n## Tool boundary\nOnly these tools are available: ${[...toolFilter.allowed].join(', ')}.` : '')
161
+ : baseConventions;
162
+ const history = [{ role: 'system', content: effectiveSystemPrompt(systemContent) }];
163
+ let resumedFromCheckpoint = false;
164
+ if (opts.resumeFromCheckpoint && opts.jobId) {
165
+ const snap = readCheckpoint(opts.jobId);
166
+ if (snap && snap.length) {
167
+ history.length = 0;
168
+ history.push(...snap);
169
+ resumedFromCheckpoint = true;
170
+ }
171
+ else {
172
+ process.stderr.write('mocode: no checkpoint to resume from\n');
173
+ await runtime.close();
174
+ return 1;
175
+ }
176
+ }
177
+ let textBuffer = '';
178
+ // D3 runaway guard (bg jobs only): hard wall-clock / token caps.
179
+ let jobSignal;
180
+ let jobGuard;
181
+ if (opts.jobId) {
182
+ const maxMs = Number(process.env.MOCODE_JOB_MAX_MS ?? 0);
183
+ const maxTokens = Number(process.env.MOCODE_JOB_MAX_TOKENS ?? 0);
184
+ if (maxMs > 0 || maxTokens > 0) {
185
+ const controller = new AbortController();
186
+ const state = { tokens: 0, maxTokens, controller, timer: undefined };
187
+ if (maxMs > 0)
188
+ state.timer = setTimeout(() => {
189
+ process.stderr.write(`\n[guard] job exceeded ${maxMs}ms; aborting\n`);
190
+ controller.abort();
191
+ }, maxMs);
192
+ jobGuard = state;
193
+ jobSignal = controller.signal;
194
+ }
195
+ }
196
+ const hooks = {
197
+ onLiveUsage: (u) => {
198
+ if (!jobGuard)
199
+ return;
200
+ jobGuard.tokens = Math.max(jobGuard.tokens, u.totalTokens);
201
+ if (jobGuard.maxTokens && jobGuard.tokens > jobGuard.maxTokens) {
202
+ process.stderr.write(`\n[guard] job exceeded ${jobGuard.maxTokens} tokens; aborting\n`);
203
+ jobGuard.controller?.abort();
204
+ }
205
+ },
206
+ onText: (delta) => {
207
+ textBuffer += delta;
208
+ if (!opts.json)
209
+ process.stdout.write(delta);
210
+ },
211
+ onToolHeader: (tc) => {
212
+ if (!opts.json && textBuffer && !textBuffer.endsWith('\n')) {
213
+ process.stdout.write('\n');
214
+ textBuffer += '\n';
215
+ }
216
+ process.stderr.write(`[${tc.name}] ${summarizeToolCall(tc.name, tc.arguments)}\n`);
217
+ },
218
+ onToolResult: (tc, output) => {
219
+ if (!opts.verbose)
220
+ return;
221
+ process.stderr.write(` → ${summarizeToolResult(tc.name, output)}\n`);
222
+ },
223
+ ...(opts.jobId
224
+ ? {
225
+ onCheckpoint: (h) => writeCheckpoint(opts.jobId, h),
226
+ }
227
+ : {}),
228
+ onModelRetry: (info) => {
229
+ process.stderr.write(`[retry] model error (${info.code}), retry ${info.nextAttempt} in ${info.waitMs}ms\n`);
230
+ },
231
+ };
232
+ const startedAt = Date.now();
233
+ let exitCode = 0;
234
+ let result;
235
+ const invokeCore = () => runAgentCore({
236
+ history,
237
+ userInput: prompt,
238
+ hooks,
239
+ ...(jobSignal ? { signal: jobSignal } : {}),
240
+ runtimeContext: runtime.context,
241
+ ...(resumedFromCheckpoint ? { continueFromHistory: true } : {}),
242
+ ...(toolFilter ? { toolsOverride: toolFilter.toolsOverride, runtimeAllowedToolNames: toolFilter.allowed } : {}),
243
+ });
244
+ try {
245
+ result = opts.botName ? await runAsIdentity(opts.botName, () => invokeCore()) : await invokeCore();
246
+ }
247
+ finally {
248
+ if (jobGuard?.timer)
249
+ clearTimeout(jobGuard.timer);
250
+ try {
251
+ runtime.session.save(history, sessionId, [prompt], []);
252
+ }
253
+ catch {
254
+ // 落盘失败不覆盖主结果
255
+ }
256
+ closeAllMcp();
257
+ await runtime.close();
258
+ if (wt) {
259
+ try {
260
+ removeWorktree(wt);
261
+ }
262
+ catch {
263
+ process.stderr.write(`mocode: failed to remove worktree ${wt.path}(手动清理)\n`);
264
+ }
265
+ }
266
+ }
267
+ const elapsedMs = Date.now() - startedAt;
268
+ const finalText = result.finalText ?? (opts.json ? null : textBuffer);
269
+ if (opts.json) {
270
+ const payload = {
271
+ terminationReason: result.terminationReason,
272
+ text: finalText,
273
+ sessionId,
274
+ changedFiles: result.changedFiles ?? [],
275
+ usage: result.usage,
276
+ elapsedMs,
277
+ model: config.model,
278
+ ...(opts.botName ? { bot: opts.botName } : {}),
279
+ };
280
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
281
+ }
282
+ else if (finalText && !textBuffer) {
283
+ process.stdout.write(finalText);
284
+ }
285
+ if (!opts.json && process.stdout.isTTY && finalText && !finalText.endsWith('\n'))
286
+ process.stdout.write('\n');
287
+ if (result.terminationReason === 'aborted')
288
+ exitCode = 130;
289
+ else if (result.terminationReason === 'max_steps' || !result.completed)
290
+ exitCode = 1;
291
+ return exitCode;
292
+ }
293
+ /** 无 -p 且 stdin 被重定向时,从 stdin 取 prompt;返回 null 表示无可用输入。 */
294
+ export async function resolvePrompt(inlinePrompt) {
295
+ if (inlinePrompt !== undefined)
296
+ return inlinePrompt;
297
+ if (!process.stdin.isTTY) {
298
+ const piped = (await readStdin()).trim();
299
+ return piped || null;
300
+ }
301
+ return null;
302
+ }
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
- * 入口:装配并启动 REPL。支持 --resume <id> 续接历史会话(裸 --resume 列出会话)。
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
+ }