Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5292,7 +5292,7 @@ async function cmdSuspend(): Promise<void> {
async function postSessionCliIpc(
ipcPort: number,
sessionId: string,
route: 'slash' | 'cd' | 'close' | 'preview' | 'chat-rename' | 'project',
route: 'slash' | 'cd' | 'close' | 'preview' | 'chat-rename' | 'project' | 'continuation',
payload: Record<string, unknown>,
): Promise<Response> {
const requestBody: Record<string, unknown> = { ...payload };
Expand Down Expand Up @@ -5324,6 +5324,68 @@ async function postSessionCliIpc(
: loopbackFetch(`http://127.0.0.1:${ipcPort}${path}`, init);
}

async function cmdContinuation(argv: string[]): Promise<void> {
const action = argv[0] ?? '';
if (!['start', 'await-user', 'cancel'].includes(action)) {
console.error('用法: botmux continuation start --readonly [--ttl-minutes N] [--max-continuations N] | await-user | cancel');
process.exitCode = 2;
return;
}
const ctx = findAncestorSessionContext();
if (!ctx?.sessionId || !ctx.turnId) {
console.error('✗ continuation 只能由当前 BotMux 会话的活动轮次调用');
process.exitCode = 1;
return;
}
if (action === 'start' && !argv.includes('--readonly')) {
console.error('✗ 第一阶段只支持显式 --readonly 的只读长程任务');
process.exitCode = 2;
return;
}
const ttlRaw = argValue(argv, '--ttl-minutes');
const maxRaw = argValue(argv, '--max-continuations');
const ttlMinutes = ttlRaw === undefined ? undefined : Number(ttlRaw);
const maxContinuations = maxRaw === undefined ? undefined : Number(maxRaw);
if (ttlMinutes !== undefined && (!Number.isFinite(ttlMinutes) || ttlMinutes <= 0)) {
console.error('✗ --ttl-minutes 必须是正数');
process.exitCode = 2;
return;
}
if (maxContinuations !== undefined
&& (!Number.isSafeInteger(maxContinuations) || maxContinuations <= 0)) {
console.error('✗ --max-continuations 必须是正整数');
process.exitCode = 2;
return;
}
let discoveredPort: number | undefined;
try { discoveredPort = findDaemon(process.env.BOTMUX_LARK_APP_ID)?.ipcPort; } catch { /* isolated */ }
const ipcPort = resolveDaemonIpcPort(discoveredPort, process.env.BOTMUX_DAEMON_IPC_PORT);
if (!ipcPort) {
console.error('✗ 无法定位当前会话的 daemon');
process.exitCode = 1;
return;
}
const response = await postSessionCliIpc(ipcPort, ctx.sessionId, 'continuation', {
action,
originTurnId: ctx.turnId,
...(ctx.dispatchAttempt !== undefined ? { originDispatchAttempt: ctx.dispatchAttempt } : {}),
...(action === 'start' ? { readonly: true } : {}),
...(ttlMinutes !== undefined ? { ttlMs: Math.round(ttlMinutes * 60_000) } : {}),
...(maxContinuations !== undefined ? { maxContinuations } : {}),
});
const body = await response.json().catch(() => ({})) as {
ok?: boolean;
error?: string;
state?: { leaseId?: string; status?: string; expiresAt?: number; maxContinuations?: number };
};
if (!response.ok || !body.ok) {
console.error(`✗ continuation 被拒绝: ${body.error ?? `HTTP ${response.status}`}`);
process.exitCode = 1;
return;
}
console.log(JSON.stringify({ ok: true, ...body.state }));
}

/** `botmux preview <port>` registers a reachable loopback Web service for the
* exact current session. There is intentionally no `--session` escape hatch:
* ancestry/env resolution plus the rotating session capability prove which
Expand Down Expand Up @@ -6306,6 +6368,9 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
同源 /preview/<sessionId>/ 访问,不暴露本机地址或任何 token。
端口必须由本会话的进程持有(在会话内直接启动,别 setsid/nohup
脱离进程树);换代/关闭后需重新注册,远端 sandbox 后端不支持
continuation start --readonly
(实验性)为当前 TraeX 普通会话显式开启一次只读长程任务续跑;
可加 --ttl-minutes N / --max-continuations N,另有 await-user / cancel
autostart enable 注册开机自启(macOS launchd / Linux user systemd / Windows Task Scheduler,无需 sudo)
autostart disable 注销开机自启
autostart status 查看自启状态
Expand Down Expand Up @@ -9219,6 +9284,7 @@ async function cmdSend(rest: string[]): Promise<void> {
const marker: Record<string, unknown> = {
sentAtMs,
messageId,
responseKind: effectiveResponseKind,
...(originTurnId ? { turnId: originTurnId } : {}),
...(originDispatchAttempt !== undefined ? { dispatchAttempt: originDispatchAttempt } : {}),
};
Expand Down Expand Up @@ -9301,6 +9367,7 @@ async function cmdSend(rest: string[]): Promise<void> {
const marker: Record<string, unknown> = {
sentAtMs: Date.now(),
messageId: `doc:${exactDocTarget.commentId}`,
responseKind: effectiveResponseKind,
...(originTurnId ? { turnId: originTurnId } : {}),
...(originDispatchAttempt !== undefined ? { dispatchAttempt: originDispatchAttempt } : {}),
contentLength: content.length,
Expand Down Expand Up @@ -9770,6 +9837,7 @@ async function cmdSend(rest: string[]): Promise<void> {
const marker: Record<string, unknown> = {
sentAtMs,
messageId,
responseKind: effectiveResponseKind,
...(originTurnId ? { turnId: originTurnId } : {}),
...(originDispatchAttempt !== undefined ? { dispatchAttempt: originDispatchAttempt } : {}),
};
Expand Down Expand Up @@ -13023,8 +13091,21 @@ async function cmdNativeSubagentRuntimeHook(): Promise<void> {
});
return;
}
const data = JSON.parse(raw) as { ok?: unknown; invalidPolicy?: unknown; policy?: unknown };
const data = JSON.parse(raw) as { ok?: unknown; invalidPolicy?: unknown; deny?: unknown; reason?: unknown; policy?: unknown };
if (data.ok !== true) return;
if (data.deny === true) {
nativeSubagentDiagnostic('daemon denied spawn for read-only continuation');
await writeNativeSubagentHookDirective({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: typeof data.reason === 'string'
? data.reason
: 'Read-only continuation forbids subagents',
},
});
return;
}
if (data.invalidPolicy === true) {
nativeSubagentDiagnostic('daemon rejected invalid stored policy; allowing spawn');
return;
Expand Down Expand Up @@ -14827,6 +14908,7 @@ switch (command) {
}
case 'term-link': await cmdTermLink(process.argv.slice(3)); break;
case 'preview': await cmdPreview(process.argv.slice(3)); break;
case 'continuation': await cmdContinuation(process.argv.slice(3)); break;
case 'schedule': await cmdSchedule(process.argv[3] ?? '', process.argv.slice(4)); break;
case 'ask': {
// `botmux ask buttons --options ...` → sub='buttons', rest=['--options', ...]
Expand Down
4 changes: 4 additions & 0 deletions src/cli/daemon-lifecycle-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export const DAEMON_ENV_KEYS = [
// keeps them on the deterministic resolveDaemonEnv snapshot semantics.
'BOTMUX_DASHBOARD_CONTROL_AUDIT_PATH',
'BOTMUX_DASHBOARD_TERMINAL_CONTROL_TTL_MS',
// Machine-wide emergency brake for the opt-in read-only task continuation
// lease. Missing/empty means OFF; the daemon re-checks it before every
// continuation, so a restart with false cancels restored backoff leases.
'BOTMUX_READONLY_CONTINUATION_ENABLED',
// Merlin Devbox auto-export switch (platform/devbox-dashboard-export.ts).
// The dashboard resolves it (dashboard-url / control-csrf run there), so it
// has to survive the allowlist copy — same reason BOTMUX_PUBLIC_URL is here.
Expand Down
Loading