@xqli02/mneme 0.1.11 → 0.1.12
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/package.json +1 -1
- package/src/commands/auto.mjs +1140 -207
package/src/commands/auto.mjs
CHANGED
|
@@ -1,36 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mneme auto — Dual-agent autonomous supervisor loop.
|
|
3
3
|
*
|
|
4
|
+
* Architecture (default: daemon + TUI):
|
|
5
|
+
* 1. Start opencode serve (if not already running)
|
|
6
|
+
* 2. Fork self as background daemon (prompt driver)
|
|
7
|
+
* 3. exec opencode attach (foreground TUI)
|
|
8
|
+
*
|
|
9
|
+
* The daemon drives prompts in the background while the user views
|
|
10
|
+
* everything through opencode's TUI. User can type directly in TUI
|
|
11
|
+
* to intervene — the daemon detects this via SSE and pauses.
|
|
12
|
+
*
|
|
13
|
+
* Use --headless for the original CLI mode (no TUI, streaming to stdout).
|
|
14
|
+
*
|
|
4
15
|
* Uses two agents in the same opencode session:
|
|
5
16
|
* - Planner (default: gpt-4.1): analyzes goal, breaks down tasks, reviews results
|
|
6
17
|
* - Executor (default: claude-opus-4.6): writes code, runs commands, implements changes
|
|
7
18
|
*
|
|
8
|
-
* The planner and executor alternate turns via per-message model switching.
|
|
9
|
-
* Both see the full conversation history within the same session.
|
|
10
|
-
*
|
|
11
|
-
* Flow per cycle:
|
|
12
|
-
* 1. Planner: receives goal/context → outputs structured instructions
|
|
13
|
-
* 2. Executor: receives planner's instructions → implements changes
|
|
14
|
-
* 3. Planner: reviews executor's output → more instructions or "DONE"
|
|
15
|
-
* 4. Repeat until planner says done or user intervenes
|
|
16
|
-
*
|
|
17
19
|
* Usage:
|
|
18
|
-
* mneme auto #
|
|
20
|
+
* mneme auto # Daemon + TUI mode (default)
|
|
19
21
|
* mneme auto "Build auth module" # Start with a specific goal
|
|
22
|
+
* mneme auto --headless # CLI mode (no TUI)
|
|
20
23
|
* mneme auto --attach http://localhost:4096
|
|
21
24
|
* mneme auto --port 4096
|
|
22
|
-
* mneme auto --planner github-copilot/gpt-
|
|
25
|
+
* mneme auto --planner github-copilot/gpt-4.1 --executor github-copilot/claude-opus-4.6
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
|
-
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
28
|
+
import { readFileSync, existsSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
26
29
|
import { join } from "node:path";
|
|
27
30
|
import { createInterface } from "node:readline";
|
|
31
|
+
import { fork, execSync } from "node:child_process";
|
|
28
32
|
import {
|
|
29
33
|
startOpencodeServer,
|
|
30
34
|
attachOpencodeServer,
|
|
31
35
|
parseModelSpec,
|
|
32
|
-
|
|
36
|
+
findOpencodeProcess,
|
|
33
37
|
} from "../opencode-server.mjs";
|
|
38
|
+
import { createClient } from "../opencode-client.mjs";
|
|
34
39
|
import { color, log, run, has } from "../utils.mjs";
|
|
35
40
|
|
|
36
41
|
// ── Default models ──────────────────────────────────────────────────────────
|
|
@@ -38,6 +43,10 @@ import { color, log, run, has } from "../utils.mjs";
|
|
|
38
43
|
const DEFAULT_PLANNER = "github-copilot/gpt-4.1";
|
|
39
44
|
const DEFAULT_EXECUTOR = "github-copilot/claude-opus-4.6";
|
|
40
45
|
|
|
46
|
+
// ── Log file path ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const LOG_FILE = ".mneme-auto.log";
|
|
49
|
+
|
|
41
50
|
// ── Argument parsing ────────────────────────────────────────────────────────
|
|
42
51
|
|
|
43
52
|
function parseArgs(argv) {
|
|
@@ -48,6 +57,10 @@ function parseArgs(argv) {
|
|
|
48
57
|
maxCycles: 50, // planner-executor cycles
|
|
49
58
|
planner: DEFAULT_PLANNER,
|
|
50
59
|
executor: DEFAULT_EXECUTOR,
|
|
60
|
+
headless: false, // --headless: use original CLI mode
|
|
61
|
+
_daemon: false, // --_daemon: internal flag for forked daemon process
|
|
62
|
+
_daemonUrl: null, // --_daemon-url: server URL passed to daemon
|
|
63
|
+
_daemonSessionId: null, // --_daemon-session: session ID passed to daemon
|
|
51
64
|
};
|
|
52
65
|
const positional = [];
|
|
53
66
|
|
|
@@ -71,6 +84,18 @@ function parseArgs(argv) {
|
|
|
71
84
|
opts.executor = argv[++i];
|
|
72
85
|
} else if (arg.startsWith("--executor=")) {
|
|
73
86
|
opts.executor = arg.split("=").slice(1).join("=");
|
|
87
|
+
} else if (arg === "--headless") {
|
|
88
|
+
opts.headless = true;
|
|
89
|
+
} else if (arg === "--_daemon") {
|
|
90
|
+
opts._daemon = true;
|
|
91
|
+
} else if (arg === "--_daemon-url" && argv[i + 1]) {
|
|
92
|
+
opts._daemonUrl = argv[++i];
|
|
93
|
+
} else if (arg.startsWith("--_daemon-url=")) {
|
|
94
|
+
opts._daemonUrl = arg.split("=").slice(1).join("=");
|
|
95
|
+
} else if (arg === "--_daemon-session" && argv[i + 1]) {
|
|
96
|
+
opts._daemonSessionId = argv[++i];
|
|
97
|
+
} else if (arg.startsWith("--_daemon-session=")) {
|
|
98
|
+
opts._daemonSessionId = arg.split("=").slice(1).join("=");
|
|
74
99
|
} else if (arg === "--help" || arg === "-h") {
|
|
75
100
|
showHelp();
|
|
76
101
|
process.exit(0);
|
|
@@ -91,8 +116,9 @@ function showHelp() {
|
|
|
91
116
|
${color.bold("mneme auto")} — Dual-agent autonomous supervisor
|
|
92
117
|
|
|
93
118
|
Usage:
|
|
94
|
-
mneme auto
|
|
119
|
+
mneme auto Daemon + TUI mode (default)
|
|
95
120
|
mneme auto "Build auth module" Start with a specific goal
|
|
121
|
+
mneme auto --headless CLI mode (no TUI, streams to stdout)
|
|
96
122
|
mneme auto --attach URL Attach to existing server
|
|
97
123
|
mneme auto --port PORT Use specific port (default: 4097)
|
|
98
124
|
|
|
@@ -100,11 +126,24 @@ Options:
|
|
|
100
126
|
--planner MODEL Planner model (default: ${DEFAULT_PLANNER})
|
|
101
127
|
--executor MODEL Executor model (default: ${DEFAULT_EXECUTOR})
|
|
102
128
|
--max-cycles N Max planner-executor cycles (default: 50)
|
|
129
|
+
--headless Use CLI mode instead of TUI
|
|
103
130
|
--attach URL Attach to running opencode server
|
|
104
131
|
--port PORT Port for auto-started server
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
Behavior when no goal is provided:
|
|
134
|
+
Asks whether to pick from existing beads or discuss a plan with the
|
|
135
|
+
Planner first. In TUI mode, the Planner presents current tasks and
|
|
136
|
+
suggestions — discuss interactively, then type /go to start execution.
|
|
137
|
+
|
|
138
|
+
Default mode (daemon + TUI):
|
|
139
|
+
Opens the opencode TUI. The auto-driver runs in the background,
|
|
140
|
+
alternating planner/executor prompts. Type directly in the TUI
|
|
141
|
+
to intervene — the daemon pauses while you interact.
|
|
142
|
+
|
|
143
|
+
Headless mode (--headless):
|
|
144
|
+
Streams agent output to stdout. Commands while running:
|
|
107
145
|
Type any message Inject feedback (sent to planner next turn)
|
|
146
|
+
/go Finish goal discussion and start execution
|
|
108
147
|
/status Show bead status
|
|
109
148
|
/skip Skip current bead
|
|
110
149
|
/abort Abort current turn
|
|
@@ -112,6 +151,29 @@ Commands while running:
|
|
|
112
151
|
`);
|
|
113
152
|
}
|
|
114
153
|
|
|
154
|
+
// ── File logger (for daemon mode) ───────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a file-based logger for daemon mode.
|
|
158
|
+
* Replaces all console output. Truncates the log file on start.
|
|
159
|
+
*/
|
|
160
|
+
function createFileLogger(logPath) {
|
|
161
|
+
// Truncate on start
|
|
162
|
+
writeFileSync(logPath, `[mneme auto daemon] Started at ${new Date().toISOString()}\n`);
|
|
163
|
+
|
|
164
|
+
function write(level, msg) {
|
|
165
|
+
const ts = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
|
|
166
|
+
appendFileSync(logPath, `[${ts}] [${level}] ${msg}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
info: (msg) => write("INFO", msg),
|
|
171
|
+
ok: (msg) => write("OK", msg),
|
|
172
|
+
warn: (msg) => write("WARN", msg),
|
|
173
|
+
fail: (msg) => write("FAIL", msg),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
// ── Bead management ─────────────────────────────────────────────────────────
|
|
116
178
|
|
|
117
179
|
function getReadyBeads() {
|
|
@@ -280,6 +342,66 @@ Be specific and actionable.`;
|
|
|
280
342
|
return prompt;
|
|
281
343
|
}
|
|
282
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Build the planner's discovery prompt — for interactive goal discussion
|
|
347
|
+
* when no goal was provided on the command line.
|
|
348
|
+
*/
|
|
349
|
+
function buildPlannerDiscoveryPrompt() {
|
|
350
|
+
// Gather current project state for context
|
|
351
|
+
const readyBeads = run("bd ready") || "";
|
|
352
|
+
const openBeads = run("bd list --status=open") || "";
|
|
353
|
+
const inProgressBeads = run("bd list --status=in_progress") || "";
|
|
354
|
+
|
|
355
|
+
let beadContext = "";
|
|
356
|
+
if (inProgressBeads && !inProgressBeads.includes("No ")) {
|
|
357
|
+
beadContext += `### In-progress tasks:\n\`\`\`\n${inProgressBeads}\n\`\`\`\n\n`;
|
|
358
|
+
}
|
|
359
|
+
if (readyBeads && !readyBeads.includes("No ready")) {
|
|
360
|
+
beadContext += `### Ready tasks (unblocked):\n\`\`\`\n${readyBeads}\n\`\`\`\n\n`;
|
|
361
|
+
}
|
|
362
|
+
if (openBeads && !openBeads.includes("No ")) {
|
|
363
|
+
beadContext += `### Open tasks:\n\`\`\`\n${openBeads}\n\`\`\`\n\n`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return `## Role: Planner (Goal Discovery)
|
|
367
|
+
|
|
368
|
+
You are the PLANNER in discovery mode. No goal was provided, so your job is to help the user decide what to work on.
|
|
369
|
+
|
|
370
|
+
${beadContext ? `## Current Task State\n\n${beadContext}` : "## No existing tasks found.\n\n"}## Instructions
|
|
371
|
+
|
|
372
|
+
1. Review the project state above (existing tasks, facts, codebase)
|
|
373
|
+
2. Suggest 2-3 concrete goals the user could work on, prioritized by impact
|
|
374
|
+
3. For each suggestion, explain WHY it's a good next step
|
|
375
|
+
4. Ask the user which direction they'd like to go, or if they have something else in mind
|
|
376
|
+
|
|
377
|
+
Keep your suggestions specific and actionable. The user will discuss with you and then type **\`/go\`** when they're ready to start execution.
|
|
378
|
+
|
|
379
|
+
**Important**: This is a conversation. Respond to the user's input naturally. When they type \`/go\`, the system will finalize the goal and begin the planner-executor loop.`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Build a prompt to finalize the goal after /go is received.
|
|
384
|
+
* The planner should summarize the agreed goal and produce the first
|
|
385
|
+
* executor instruction.
|
|
386
|
+
*/
|
|
387
|
+
function buildPlannerFinalizeGoalPrompt() {
|
|
388
|
+
return `## Role: Planner (Finalize Goal)
|
|
389
|
+
|
|
390
|
+
The user has typed \`/go\`, signaling they're ready to start execution.
|
|
391
|
+
|
|
392
|
+
Based on our discussion above, do the following:
|
|
393
|
+
|
|
394
|
+
1. Summarize the agreed goal in 1-2 sentences
|
|
395
|
+
2. Check existing beads with \`mneme ready\` and \`mneme list --status=open\`
|
|
396
|
+
3. If this maps to an existing bead, claim it: \`mneme update <id> --status=in_progress\`
|
|
397
|
+
4. If not, create a new bead: \`mneme create --title="..." --description="..." --type=task -p 2\`
|
|
398
|
+
5. Break the goal into specific, actionable steps
|
|
399
|
+
6. Give the Executor clear instructions for what to implement FIRST
|
|
400
|
+
7. When all work is complete (in future turns), include "TASK_DONE" in your response
|
|
401
|
+
|
|
402
|
+
Output your plan and the first instruction for the Executor.`;
|
|
403
|
+
}
|
|
404
|
+
|
|
283
405
|
/**
|
|
284
406
|
* Build the executor's prompt (wrapping the planner's output).
|
|
285
407
|
*/
|
|
@@ -297,10 +419,11 @@ Rules:
|
|
|
297
419
|
- Report what you did when finished so the Planner can review`;
|
|
298
420
|
}
|
|
299
421
|
|
|
300
|
-
// ── User input handling
|
|
422
|
+
// ── User input handling (headless mode only) ────────────────────────────────
|
|
301
423
|
|
|
302
424
|
/**
|
|
303
425
|
* Non-blocking stdin reader with message queue.
|
|
426
|
+
* Only used in --headless mode.
|
|
304
427
|
*/
|
|
305
428
|
function createInputQueue() {
|
|
306
429
|
const queue = [];
|
|
@@ -320,22 +443,22 @@ function createInputQueue() {
|
|
|
320
443
|
if (!trimmed) return;
|
|
321
444
|
|
|
322
445
|
if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/stop") {
|
|
323
|
-
console.log(color.dim("
|
|
446
|
+
console.log(color.dim(" -> quitting after current turn..."));
|
|
324
447
|
queue.push({ type: "quit" });
|
|
325
448
|
} else if (trimmed === "/status") {
|
|
326
449
|
queue.push({ type: "status" });
|
|
327
450
|
} else if (trimmed === "/skip") {
|
|
328
451
|
console.log(
|
|
329
|
-
color.dim("
|
|
452
|
+
color.dim(" -> will skip current bead after this cycle"),
|
|
330
453
|
);
|
|
331
454
|
queue.push({ type: "skip" });
|
|
332
455
|
} else if (trimmed === "/abort") {
|
|
333
|
-
console.log(color.dim("
|
|
456
|
+
console.log(color.dim(" -> aborting current turn..."));
|
|
334
457
|
queue.push({ type: "abort" });
|
|
335
458
|
} else {
|
|
336
459
|
queue.push({ type: "message", text: trimmed });
|
|
337
460
|
console.log(
|
|
338
|
-
color.dim("
|
|
461
|
+
color.dim(" -> queued, will send to planner next cycle"),
|
|
339
462
|
);
|
|
340
463
|
}
|
|
341
464
|
});
|
|
@@ -377,26 +500,26 @@ function createInputQueue() {
|
|
|
377
500
|
};
|
|
378
501
|
}
|
|
379
502
|
|
|
380
|
-
// ── Event display (streaming)
|
|
503
|
+
// ── Event display (streaming, headless mode) ────────────────────────────────
|
|
381
504
|
|
|
382
505
|
/**
|
|
383
506
|
* Subscribe to SSE events and display agent output in real-time.
|
|
384
507
|
* Returns an object with methods to control display and detect turn completion.
|
|
385
508
|
*
|
|
386
509
|
* Also tracks `lastOutputTime` so callers can detect stalls.
|
|
510
|
+
* Only used in --headless mode.
|
|
387
511
|
*/
|
|
388
512
|
function createEventDisplay(client) {
|
|
389
513
|
let running = false;
|
|
390
514
|
let connected = false;
|
|
391
515
|
let turnResolve = null;
|
|
392
|
-
let currentRole = null;
|
|
393
|
-
let lastOutputTime = 0;
|
|
394
|
-
let hasReceivedAny = false;
|
|
516
|
+
let currentRole = null;
|
|
517
|
+
let lastOutputTime = 0;
|
|
518
|
+
let hasReceivedAny = false;
|
|
395
519
|
|
|
396
|
-
// Track incremental text and tool display state
|
|
397
520
|
const printedTextLengths = new Map();
|
|
398
521
|
const displayedToolStates = new Map();
|
|
399
|
-
const deltaParts = new Set();
|
|
522
|
+
const deltaParts = new Set();
|
|
400
523
|
|
|
401
524
|
async function start() {
|
|
402
525
|
running = true;
|
|
@@ -416,7 +539,6 @@ function createEventDisplay(client) {
|
|
|
416
539
|
console.error(
|
|
417
540
|
color.dim(`\n [events] Stream error: ${err.message}`),
|
|
418
541
|
);
|
|
419
|
-
// Try to reconnect after a brief delay
|
|
420
542
|
await sleep(2000);
|
|
421
543
|
if (running) {
|
|
422
544
|
log.info("Reconnecting SSE...");
|
|
@@ -431,7 +553,6 @@ function createEventDisplay(client) {
|
|
|
431
553
|
const props = event.properties || {};
|
|
432
554
|
|
|
433
555
|
switch (type) {
|
|
434
|
-
// ── Incremental text deltas (streaming) ──
|
|
435
556
|
case "message.part.delta": {
|
|
436
557
|
const partId = props.partID || props.partId;
|
|
437
558
|
if (partId) deltaParts.add(partId);
|
|
@@ -442,7 +563,6 @@ function createEventDisplay(client) {
|
|
|
442
563
|
break;
|
|
443
564
|
}
|
|
444
565
|
|
|
445
|
-
// ── Part snapshots (full text at end, tool states) ──
|
|
446
566
|
case "message.part.updated": {
|
|
447
567
|
if (!props.part) break;
|
|
448
568
|
const part = props.part;
|
|
@@ -455,12 +575,9 @@ function createEventDisplay(client) {
|
|
|
455
575
|
displayToolPart(part, partId);
|
|
456
576
|
lastOutputTime = Date.now();
|
|
457
577
|
}
|
|
458
|
-
// For text parts: we rely on message.part.delta for streaming,
|
|
459
|
-
// so only use updated as a fallback if we missed the deltas
|
|
460
578
|
if (part.type === "text" && part.text) {
|
|
461
579
|
const prev = printedTextLengths.get(partId) || 0;
|
|
462
580
|
if (prev === 0 && !deltaParts.has(partId)) {
|
|
463
|
-
// We never saw deltas for this part — print the full text
|
|
464
581
|
process.stdout.write(part.text);
|
|
465
582
|
lastOutputTime = Date.now();
|
|
466
583
|
}
|
|
@@ -469,7 +586,6 @@ function createEventDisplay(client) {
|
|
|
469
586
|
break;
|
|
470
587
|
}
|
|
471
588
|
|
|
472
|
-
// ── Session status (busy → idle/completed) ──
|
|
473
589
|
case "session.status": {
|
|
474
590
|
const status = props.status?.type || props.status;
|
|
475
591
|
if (status && status !== "busy" && status !== "pending") {
|
|
@@ -481,7 +597,6 @@ function createEventDisplay(client) {
|
|
|
481
597
|
break;
|
|
482
598
|
}
|
|
483
599
|
|
|
484
|
-
// ── Session updated (metadata; also check for status) ──
|
|
485
600
|
case "session.updated": {
|
|
486
601
|
const info = props.info || props.session || {};
|
|
487
602
|
const status = info.status?.type || info.status;
|
|
@@ -494,12 +609,9 @@ function createEventDisplay(client) {
|
|
|
494
609
|
break;
|
|
495
610
|
}
|
|
496
611
|
|
|
497
|
-
// ── Message-level finish detection ──
|
|
498
612
|
case "message.updated": {
|
|
499
613
|
const info = props.info || {};
|
|
500
614
|
if (info.finish && info.finish !== "pending") {
|
|
501
|
-
// Model finished generating — mark as turn end candidate
|
|
502
|
-
// (session.status should follow, but use this as backup)
|
|
503
615
|
lastOutputTime = Date.now();
|
|
504
616
|
}
|
|
505
617
|
break;
|
|
@@ -519,7 +631,7 @@ function createEventDisplay(client) {
|
|
|
519
631
|
if (state === "call" && lastState !== "call") {
|
|
520
632
|
const argsStr = summarizeArgs(inv.args);
|
|
521
633
|
console.log(
|
|
522
|
-
`\n${color.bold(`
|
|
634
|
+
`\n${color.bold(` * ${toolName}`)}${argsStr ? color.dim(` ${argsStr}`) : ""}`,
|
|
523
635
|
);
|
|
524
636
|
displayedToolStates.set(partId, "call");
|
|
525
637
|
} else if (state === "result" && lastState !== "result") {
|
|
@@ -560,10 +672,6 @@ function createEventDisplay(client) {
|
|
|
560
672
|
return str.length > 120 ? str.slice(0, 120) + "..." : str;
|
|
561
673
|
}
|
|
562
674
|
|
|
563
|
-
/**
|
|
564
|
-
* Wait for the current turn to complete via SSE.
|
|
565
|
-
* Returns a promise that resolves with the session status.
|
|
566
|
-
*/
|
|
567
675
|
function waitForTurnEnd() {
|
|
568
676
|
return new Promise((resolve) => {
|
|
569
677
|
turnResolve = resolve;
|
|
@@ -596,17 +704,167 @@ function createEventDisplay(client) {
|
|
|
596
704
|
};
|
|
597
705
|
}
|
|
598
706
|
|
|
599
|
-
// ──
|
|
707
|
+
// ── Daemon event monitor (silent, for daemon mode) ──────────────────────────
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* SSE listener for daemon mode — tracks turn completion and detects
|
|
711
|
+
* user-initiated messages, but produces NO stdout output.
|
|
712
|
+
* All logging goes to file.
|
|
713
|
+
*/
|
|
714
|
+
function createDaemonEventMonitor(client, dlog) {
|
|
715
|
+
let running = false;
|
|
716
|
+
let connected = false;
|
|
717
|
+
let turnResolve = null;
|
|
718
|
+
let lastOutputTime = 0;
|
|
719
|
+
let hasReceivedAny = false;
|
|
720
|
+
|
|
721
|
+
// Track known prompt texts we sent — to distinguish user messages
|
|
722
|
+
const knownPromptTexts = new Set();
|
|
723
|
+
|
|
724
|
+
// Callback for user message detection
|
|
725
|
+
let onUserMessage = null;
|
|
726
|
+
|
|
727
|
+
async function start() {
|
|
728
|
+
running = true;
|
|
729
|
+
try {
|
|
730
|
+
const iterator = await client.events.subscribe();
|
|
731
|
+
connected = true;
|
|
732
|
+
hasReceivedAny = false;
|
|
733
|
+
dlog.ok("SSE event stream connected (daemon)");
|
|
734
|
+
for await (const event of iterator) {
|
|
735
|
+
if (!running) break;
|
|
736
|
+
if (!hasReceivedAny) hasReceivedAny = true;
|
|
737
|
+
handleEvent(event);
|
|
738
|
+
}
|
|
739
|
+
} catch (err) {
|
|
740
|
+
connected = false;
|
|
741
|
+
if (running) {
|
|
742
|
+
dlog.warn(`SSE stream error: ${err.message}`);
|
|
743
|
+
await sleep(2000);
|
|
744
|
+
if (running) {
|
|
745
|
+
dlog.info("Reconnecting SSE...");
|
|
746
|
+
start().catch(() => {});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function handleEvent(event) {
|
|
753
|
+
const type = event.type || "";
|
|
754
|
+
const props = event.properties || {};
|
|
755
|
+
|
|
756
|
+
switch (type) {
|
|
757
|
+
case "message.part.delta": {
|
|
758
|
+
// Just track that output is happening
|
|
759
|
+
if (props.field === "text" && props.delta) {
|
|
760
|
+
lastOutputTime = Date.now();
|
|
761
|
+
}
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
case "message.part.updated": {
|
|
766
|
+
if (props.part) {
|
|
767
|
+
lastOutputTime = Date.now();
|
|
768
|
+
}
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
case "session.status": {
|
|
773
|
+
const status = props.status?.type || props.status;
|
|
774
|
+
if (status && status !== "busy" && status !== "pending") {
|
|
775
|
+
if (turnResolve) {
|
|
776
|
+
turnResolve(status);
|
|
777
|
+
turnResolve = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
case "session.updated": {
|
|
784
|
+
const info = props.info || props.session || {};
|
|
785
|
+
const status = info.status?.type || info.status;
|
|
786
|
+
if (status && status !== "busy" && status !== "running" && status !== "pending") {
|
|
787
|
+
if (turnResolve) {
|
|
788
|
+
turnResolve(status);
|
|
789
|
+
turnResolve = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
case "message.updated": {
|
|
796
|
+
const info = props.info || {};
|
|
797
|
+
// Detect user-initiated messages: role === "user" with text we didn't send
|
|
798
|
+
const role = info.role;
|
|
799
|
+
if (role === "user" && info.parts) {
|
|
800
|
+
const text = info.parts
|
|
801
|
+
.filter((p) => p.type === "text")
|
|
802
|
+
.map((p) => p.text || "")
|
|
803
|
+
.join("\n")
|
|
804
|
+
.trim();
|
|
805
|
+
if (text && !knownPromptTexts.has(text)) {
|
|
806
|
+
dlog.info(`User message detected: "${text.slice(0, 80)}..."`);
|
|
807
|
+
if (onUserMessage) onUserMessage(text);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (info.finish && info.finish !== "pending") {
|
|
811
|
+
lastOutputTime = Date.now();
|
|
812
|
+
}
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
default:
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function waitForTurnEnd() {
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
turnResolve = resolve;
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function resetTurn() {
|
|
828
|
+
// Nothing visual to reset in daemon mode
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function registerPrompt(text) {
|
|
832
|
+
// Register a prompt text so we can distinguish our prompts from user's
|
|
833
|
+
knownPromptTexts.add(text.trim());
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function stop() {
|
|
837
|
+
running = false;
|
|
838
|
+
if (turnResolve) {
|
|
839
|
+
turnResolve("stopped");
|
|
840
|
+
turnResolve = null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
start,
|
|
846
|
+
stop,
|
|
847
|
+
waitForTurnEnd,
|
|
848
|
+
resetTurn,
|
|
849
|
+
registerPrompt,
|
|
850
|
+
set onUserMessage(fn) { onUserMessage = fn; },
|
|
851
|
+
get onUserMessage() { return onUserMessage; },
|
|
852
|
+
get lastOutputTime() { return lastOutputTime; },
|
|
853
|
+
get connected() { return connected; },
|
|
854
|
+
get hasReceivedAny() { return hasReceivedAny; },
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── Turn execution (headless mode) ──────────────────────────────────────────
|
|
600
859
|
|
|
601
860
|
/**
|
|
602
|
-
* Send a message and wait for the turn to complete.
|
|
861
|
+
* Send a message and wait for the turn to complete (headless mode).
|
|
603
862
|
* Handles /abort and /quit from input queue during execution.
|
|
604
863
|
* Prints heartbeat every 15s when no output is flowing.
|
|
605
|
-
* Warns at 30s of silence, auto-aborts at 120s of silence.
|
|
606
864
|
*
|
|
607
865
|
* @returns {{ status: string, aborted: boolean, quit: boolean }}
|
|
608
866
|
*/
|
|
609
|
-
async function
|
|
867
|
+
async function executeTurnHeadless(
|
|
610
868
|
client,
|
|
611
869
|
sessionId,
|
|
612
870
|
prompt,
|
|
@@ -623,18 +881,14 @@ async function executeTurn(
|
|
|
623
881
|
body.model = modelSpec;
|
|
624
882
|
}
|
|
625
883
|
|
|
626
|
-
// Send async — returns immediately
|
|
627
884
|
await client.session.promptAsync(sessionId, body);
|
|
628
885
|
|
|
629
|
-
// Quick check
|
|
630
|
-
// instantly but promptAsync still returns 204. Poll once after a short
|
|
631
|
-
// delay to catch this before entering the long wait loop.
|
|
886
|
+
// Quick check for immediate model errors
|
|
632
887
|
await sleep(2000);
|
|
633
888
|
try {
|
|
634
889
|
const sessions = await client.session.list();
|
|
635
890
|
const s = sessions?.find?.((ss) => ss.id === sessionId);
|
|
636
891
|
if (s && s.status && s.status !== "running" && s.status !== "pending") {
|
|
637
|
-
// Session already finished — likely an immediate error
|
|
638
892
|
const msgs = await client.session.messages(sessionId);
|
|
639
893
|
const lastMsg = msgs?.[msgs.length - 1];
|
|
640
894
|
const errInfo = lastMsg?.info?.error;
|
|
@@ -645,15 +899,14 @@ async function executeTurn(
|
|
|
645
899
|
}
|
|
646
900
|
}
|
|
647
901
|
} catch {
|
|
648
|
-
// Ignore probe failures
|
|
902
|
+
// Ignore probe failures
|
|
649
903
|
}
|
|
650
904
|
|
|
651
|
-
const HEARTBEAT_INTERVAL = 15_000;
|
|
652
|
-
const SILENCE_WARN = 30_000;
|
|
653
|
-
const SILENCE_ABORT = 120_000;
|
|
905
|
+
const HEARTBEAT_INTERVAL = 15_000;
|
|
906
|
+
const SILENCE_WARN = 30_000;
|
|
907
|
+
const SILENCE_ABORT = 120_000;
|
|
654
908
|
const turnStartTime = Date.now();
|
|
655
909
|
|
|
656
|
-
// Race: SSE turn completion vs user commands vs silence timeout
|
|
657
910
|
return new Promise((resolve) => {
|
|
658
911
|
let resolved = false;
|
|
659
912
|
let warnedSilence = false;
|
|
@@ -666,12 +919,10 @@ async function executeTurn(
|
|
|
666
919
|
resolve(result);
|
|
667
920
|
};
|
|
668
921
|
|
|
669
|
-
// SSE completion
|
|
670
922
|
eventDisplay.waitForTurnEnd().then((status) => {
|
|
671
923
|
done({ status, aborted: false, quit: false });
|
|
672
924
|
});
|
|
673
925
|
|
|
674
|
-
// Heartbeat: show elapsed time when no output is flowing
|
|
675
926
|
const heartbeatId = setInterval(() => {
|
|
676
927
|
if (resolved) return;
|
|
677
928
|
const now = Date.now();
|
|
@@ -679,26 +930,23 @@ async function executeTurn(
|
|
|
679
930
|
const lastOut = eventDisplay.lastOutputTime;
|
|
680
931
|
const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
|
|
681
932
|
|
|
682
|
-
// Silence auto-abort
|
|
683
933
|
if (silenceMs >= SILENCE_ABORT) {
|
|
684
934
|
console.log(
|
|
685
|
-
color.dim(`\n
|
|
935
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s, auto-aborting`),
|
|
686
936
|
);
|
|
687
937
|
client.session.abort(sessionId).catch(() => {});
|
|
688
938
|
done({ status: "aborted", aborted: true, quit: false });
|
|
689
939
|
return;
|
|
690
940
|
}
|
|
691
941
|
|
|
692
|
-
// Silence warning
|
|
693
942
|
if (silenceMs >= SILENCE_WARN && !warnedSilence) {
|
|
694
943
|
warnedSilence = true;
|
|
695
944
|
console.log(
|
|
696
|
-
color.dim(`\n
|
|
945
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s (will abort at ${SILENCE_ABORT / 1000}s)`),
|
|
697
946
|
);
|
|
698
947
|
return;
|
|
699
948
|
}
|
|
700
949
|
|
|
701
|
-
// Regular heartbeat (only when silent for >HEARTBEAT_INTERVAL)
|
|
702
950
|
if (silenceMs >= HEARTBEAT_INTERVAL) {
|
|
703
951
|
process.stdout.write(
|
|
704
952
|
color.dim(` [${elapsed}s] `),
|
|
@@ -706,7 +954,6 @@ async function executeTurn(
|
|
|
706
954
|
}
|
|
707
955
|
}, HEARTBEAT_INTERVAL);
|
|
708
956
|
|
|
709
|
-
// Poll input queue for /abort, /quit
|
|
710
957
|
const pollId = setInterval(() => {
|
|
711
958
|
if (!inputQueue.hasMessages()) return;
|
|
712
959
|
const items = inputQueue.drain();
|
|
@@ -725,7 +972,6 @@ async function executeTurn(
|
|
|
725
972
|
showBeadStatus();
|
|
726
973
|
}
|
|
727
974
|
if (item.type === "message" || item.type === "skip") {
|
|
728
|
-
// Re-queue for processing between cycles
|
|
729
975
|
inputQueue.pushBack(item);
|
|
730
976
|
}
|
|
731
977
|
}
|
|
@@ -733,8 +979,91 @@ async function executeTurn(
|
|
|
733
979
|
});
|
|
734
980
|
}
|
|
735
981
|
|
|
982
|
+
// ── Turn execution (daemon mode) ────────────────────────────────────────────
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Send a message and wait for the turn to complete (daemon mode).
|
|
986
|
+
* No stdout output — all logging to file. No input queue.
|
|
987
|
+
*
|
|
988
|
+
* @returns {{ status: string, aborted: boolean }}
|
|
989
|
+
*/
|
|
990
|
+
async function executeTurnDaemon(
|
|
991
|
+
client,
|
|
992
|
+
sessionId,
|
|
993
|
+
prompt,
|
|
994
|
+
modelSpec,
|
|
995
|
+
monitor,
|
|
996
|
+
dlog,
|
|
997
|
+
) {
|
|
998
|
+
monitor.resetTurn();
|
|
999
|
+
monitor.registerPrompt(prompt);
|
|
1000
|
+
|
|
1001
|
+
const body = {
|
|
1002
|
+
parts: [{ type: "text", text: prompt }],
|
|
1003
|
+
};
|
|
1004
|
+
if (modelSpec) {
|
|
1005
|
+
body.model = modelSpec;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
await client.session.promptAsync(sessionId, body);
|
|
1009
|
+
|
|
1010
|
+
// Quick check for immediate model errors
|
|
1011
|
+
await sleep(2000);
|
|
1012
|
+
try {
|
|
1013
|
+
const sessions = await client.session.list();
|
|
1014
|
+
const s = sessions?.find?.((ss) => ss.id === sessionId);
|
|
1015
|
+
if (s && s.status && s.status !== "running" && s.status !== "pending") {
|
|
1016
|
+
const msgs = await client.session.messages(sessionId);
|
|
1017
|
+
const lastMsg = msgs?.[msgs.length - 1];
|
|
1018
|
+
const errInfo = lastMsg?.info?.error;
|
|
1019
|
+
if (errInfo) {
|
|
1020
|
+
const errMsg = errInfo.data?.message || errInfo.name || "unknown";
|
|
1021
|
+
dlog.fail(`Model error: ${errMsg}`);
|
|
1022
|
+
return { status: "error", aborted: true };
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
} catch {
|
|
1026
|
+
// Ignore probe failures
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const SILENCE_ABORT = 120_000;
|
|
1030
|
+
const turnStartTime = Date.now();
|
|
1031
|
+
|
|
1032
|
+
return new Promise((resolve) => {
|
|
1033
|
+
let resolved = false;
|
|
1034
|
+
|
|
1035
|
+
const done = (result) => {
|
|
1036
|
+
if (resolved) return;
|
|
1037
|
+
resolved = true;
|
|
1038
|
+
clearInterval(silenceCheckId);
|
|
1039
|
+
resolve(result);
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
monitor.waitForTurnEnd().then((status) => {
|
|
1043
|
+
done({ status, aborted: false });
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// Check for silence timeouts
|
|
1047
|
+
const silenceCheckId = setInterval(() => {
|
|
1048
|
+
if (resolved) return;
|
|
1049
|
+
const now = Date.now();
|
|
1050
|
+
const elapsed = Math.round((now - turnStartTime) / 1000);
|
|
1051
|
+
const lastOut = monitor.lastOutputTime;
|
|
1052
|
+
const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
|
|
1053
|
+
|
|
1054
|
+
if (silenceMs >= SILENCE_ABORT) {
|
|
1055
|
+
dlog.warn(`${elapsed}s elapsed, no output for ${Math.round(silenceMs / 1000)}s — auto-aborting`);
|
|
1056
|
+
client.session.abort(sessionId).catch(() => {});
|
|
1057
|
+
done({ status: "aborted", aborted: true });
|
|
1058
|
+
}
|
|
1059
|
+
}, 15_000);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ── Status display (headless only) ─────────────────────────────────────────
|
|
1064
|
+
|
|
736
1065
|
function showBeadStatus() {
|
|
737
|
-
console.log(`\n${color.bold("
|
|
1066
|
+
console.log(`\n${color.bold("-- Status --")}`);
|
|
738
1067
|
const ready = run("bd ready") || " (none)";
|
|
739
1068
|
const inProgress = run("bd list --status=in_progress") || " (none)";
|
|
740
1069
|
console.log(` ${color.bold("Ready:")} ${ready}`);
|
|
@@ -742,60 +1071,26 @@ function showBeadStatus() {
|
|
|
742
1071
|
console.log("");
|
|
743
1072
|
}
|
|
744
1073
|
|
|
745
|
-
// ── Supervisor loop
|
|
1074
|
+
// ── Supervisor loop (headless mode — original CLI) ──────────────────────────
|
|
746
1075
|
|
|
747
|
-
async function
|
|
1076
|
+
async function supervisorLoopHeadless(client, opts, inputQueue) {
|
|
748
1077
|
const plannerModel = parseModelSpec(opts.planner);
|
|
749
1078
|
const executorModel = parseModelSpec(opts.executor);
|
|
750
1079
|
|
|
751
|
-
// Create session first (needed for model probing)
|
|
752
1080
|
log.info("Creating session...");
|
|
753
1081
|
const session = await client.session.create({ title: "mneme auto" });
|
|
754
1082
|
const sessionId = session.id;
|
|
755
1083
|
log.ok(`Session: ${sessionId}`);
|
|
756
1084
|
|
|
757
|
-
//
|
|
758
|
-
// opencode models lists theoretical models, but the provider may reject them
|
|
759
|
-
// at runtime (e.g. Copilot plan doesn't include gpt-5.x). Only a real API
|
|
760
|
-
// call reveals this — sync prompt returns the error, async silently fails.
|
|
1085
|
+
// Validate models
|
|
761
1086
|
log.info("Validating models (API probe)...");
|
|
762
|
-
|
|
763
|
-
{ label: "Planner", spec: opts.planner, parsed: plannerModel },
|
|
764
|
-
{ label: "Executor", spec: opts.executor, parsed: executorModel },
|
|
765
|
-
];
|
|
766
|
-
// Deduplicate if both use the same model
|
|
767
|
-
const seen = new Set();
|
|
768
|
-
for (const m of probeModels) {
|
|
769
|
-
if (seen.has(m.spec)) continue;
|
|
770
|
-
seen.add(m.spec);
|
|
771
|
-
try {
|
|
772
|
-
const result = await client.session.prompt(sessionId, {
|
|
773
|
-
parts: [{ type: "text", text: "Say OK" }],
|
|
774
|
-
model: m.parsed,
|
|
775
|
-
});
|
|
776
|
-
// Check if the response contains an error
|
|
777
|
-
const err = result?.info?.error;
|
|
778
|
-
if (err) {
|
|
779
|
-
const msg = err.data?.message || err.name || "unknown error";
|
|
780
|
-
log.fail(`${m.label} model "${m.spec}" rejected by provider: ${msg}`);
|
|
781
|
-
console.log(color.dim(" Tip: run 'opencode models' to see listed models, but not all may be available on your plan."));
|
|
782
|
-
throw new Error(`${m.label} model unavailable: ${msg}`);
|
|
783
|
-
}
|
|
784
|
-
log.ok(`${m.label} model verified: ${m.spec}`);
|
|
785
|
-
} catch (probeErr) {
|
|
786
|
-
if (probeErr.message.includes("unavailable") || probeErr.message.includes("rejected")) {
|
|
787
|
-
throw probeErr; // re-throw our own errors
|
|
788
|
-
}
|
|
789
|
-
// API call itself failed — might be a transient issue
|
|
790
|
-
log.warn(`${m.label} model probe inconclusive: ${probeErr.message}`);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
1087
|
+
await validateModels(client, sessionId, opts, log);
|
|
793
1088
|
|
|
794
1089
|
// Start SSE event display
|
|
795
1090
|
const eventDisplay = createEventDisplay(client);
|
|
796
1091
|
eventDisplay.start().catch(() => {});
|
|
797
1092
|
|
|
798
|
-
// Inject system context
|
|
1093
|
+
// Inject system context
|
|
799
1094
|
const systemContext = buildSystemContext(opts);
|
|
800
1095
|
try {
|
|
801
1096
|
await client.session.prompt(sessionId, {
|
|
@@ -808,10 +1103,50 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
808
1103
|
}
|
|
809
1104
|
|
|
810
1105
|
let cycle = 0;
|
|
1106
|
+
let startMode = "beads"; // default: pick from beads
|
|
1107
|
+
|
|
1108
|
+
// If no explicit goal, ask user whether to pick from beads or discuss a plan.
|
|
1109
|
+
if (!opts.goal) {
|
|
1110
|
+
const choice = await askStartModeHeadless(inputQueue);
|
|
1111
|
+
if (choice === "quit") {
|
|
1112
|
+
eventDisplay.stop();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
startMode = choice; // "beads" or "discuss"
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// If user chose to discuss, enter goal discussion before main loop.
|
|
1119
|
+
if (startMode === "discuss") {
|
|
1120
|
+
const goResult = await goalDiscussionHeadless(
|
|
1121
|
+
client, sessionId, plannerModel, eventDisplay, inputQueue,
|
|
1122
|
+
);
|
|
1123
|
+
if (!goResult) {
|
|
1124
|
+
log.info("User quit during goal discussion.");
|
|
1125
|
+
eventDisplay.stop();
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
// Goal discussion complete — planner finalize prompt has produced
|
|
1129
|
+
// the first executor instruction. Jump straight to executor turn.
|
|
1130
|
+
cycle++;
|
|
1131
|
+
console.log(
|
|
1132
|
+
`\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
|
|
1133
|
+
);
|
|
1134
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1135
|
+
log.info(`Sending prompt to Executor (${opts.executor})...`);
|
|
1136
|
+
eventDisplay.resetTurn("executor");
|
|
1137
|
+
const executorResult = await executeTurnHeadless(
|
|
1138
|
+
client, sessionId, executorPrompt, executorModel,
|
|
1139
|
+
eventDisplay, inputQueue,
|
|
1140
|
+
);
|
|
1141
|
+
console.log("");
|
|
1142
|
+
if (executorResult.quit) { log.info("User requested quit."); eventDisplay.stop(); return; }
|
|
1143
|
+
if (executorResult.aborted) { log.info("Executor turn aborted."); }
|
|
1144
|
+
await sleep(1000);
|
|
1145
|
+
// Fall through to the main loop (cycle is now 1, will get planner review)
|
|
1146
|
+
}
|
|
811
1147
|
|
|
812
1148
|
try {
|
|
813
1149
|
while (cycle < opts.maxCycles) {
|
|
814
|
-
// ── Process queued user commands between cycles ──
|
|
815
1150
|
let userFeedback = null;
|
|
816
1151
|
let shouldSkip = false;
|
|
817
1152
|
|
|
@@ -822,40 +1157,30 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
822
1157
|
log.info("User requested quit.");
|
|
823
1158
|
return;
|
|
824
1159
|
}
|
|
825
|
-
if (item.type === "skip")
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
if (item.type === "status") {
|
|
829
|
-
showBeadStatus();
|
|
830
|
-
}
|
|
831
|
-
if (item.type === "message") {
|
|
832
|
-
userFeedback = item.text;
|
|
833
|
-
}
|
|
1160
|
+
if (item.type === "skip") shouldSkip = true;
|
|
1161
|
+
if (item.type === "status") showBeadStatus();
|
|
1162
|
+
if (item.type === "message") userFeedback = item.text;
|
|
834
1163
|
}
|
|
835
1164
|
}
|
|
836
1165
|
|
|
837
1166
|
if (shouldSkip) {
|
|
838
1167
|
log.info("Skipping current bead...");
|
|
839
|
-
// Fall through to pick next bead
|
|
840
1168
|
}
|
|
841
1169
|
|
|
842
|
-
// ── Pick a task (first cycle or after skip) ──
|
|
843
1170
|
let plannerPrompt = null;
|
|
844
1171
|
|
|
845
1172
|
if (cycle === 0) {
|
|
846
|
-
// First cycle: use goal or pick a bead
|
|
847
1173
|
if (opts.goal) {
|
|
848
1174
|
plannerPrompt = buildPlannerGoalPrompt(opts.goal);
|
|
849
1175
|
} else {
|
|
850
|
-
|
|
1176
|
+
// No explicit goal, but beads exist — pick from beads
|
|
1177
|
+
plannerPrompt = pickBeadForPlanner(log);
|
|
851
1178
|
}
|
|
852
1179
|
} else {
|
|
853
|
-
// Subsequent cycles: planner reviews executor's work
|
|
854
1180
|
plannerPrompt = buildPlannerReviewPrompt(userFeedback);
|
|
855
1181
|
}
|
|
856
1182
|
|
|
857
1183
|
if (!plannerPrompt) {
|
|
858
|
-
// No work available
|
|
859
1184
|
const open = getOpenBeads();
|
|
860
1185
|
if (open.length === 0) {
|
|
861
1186
|
log.ok("All beads completed! Nothing left to do.");
|
|
@@ -869,97 +1194,262 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
869
1194
|
|
|
870
1195
|
cycle++;
|
|
871
1196
|
|
|
872
|
-
//
|
|
1197
|
+
// Planner turn
|
|
873
1198
|
console.log(
|
|
874
|
-
`\n${color.bold(
|
|
1199
|
+
`\n${color.bold(`-- Cycle ${cycle} / Planner`)} ${color.dim(`(${opts.planner})`)} ${color.bold("--------------------")}`,
|
|
875
1200
|
);
|
|
876
1201
|
|
|
877
1202
|
log.info(`Sending prompt to Planner (${opts.planner})...`);
|
|
878
1203
|
eventDisplay.resetTurn("planner");
|
|
879
|
-
const plannerResult = await
|
|
880
|
-
client,
|
|
881
|
-
|
|
882
|
-
plannerPrompt,
|
|
883
|
-
plannerModel,
|
|
884
|
-
eventDisplay,
|
|
885
|
-
inputQueue,
|
|
1204
|
+
const plannerResult = await executeTurnHeadless(
|
|
1205
|
+
client, sessionId, plannerPrompt, plannerModel,
|
|
1206
|
+
eventDisplay, inputQueue,
|
|
886
1207
|
);
|
|
887
|
-
console.log("");
|
|
1208
|
+
console.log("");
|
|
888
1209
|
|
|
889
|
-
if (plannerResult.quit) {
|
|
890
|
-
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
if (plannerResult.aborted) {
|
|
894
|
-
log.info("Planner turn aborted.");
|
|
895
|
-
continue;
|
|
896
|
-
}
|
|
1210
|
+
if (plannerResult.quit) { log.info("User requested quit."); return; }
|
|
1211
|
+
if (plannerResult.aborted) { log.info("Planner turn aborted."); continue; }
|
|
897
1212
|
|
|
898
|
-
// Check
|
|
899
|
-
// from the session to see the planner's output
|
|
1213
|
+
// Check TASK_DONE
|
|
900
1214
|
let plannerSaidDone = false;
|
|
901
1215
|
try {
|
|
902
1216
|
const messages = await client.session.messages(sessionId);
|
|
903
1217
|
if (messages && messages.length > 0) {
|
|
904
1218
|
const lastMsg = messages[messages.length - 1];
|
|
905
1219
|
const text = extractMessageText(lastMsg);
|
|
906
|
-
if (text.includes("TASK_DONE"))
|
|
907
|
-
plannerSaidDone = true;
|
|
908
|
-
}
|
|
1220
|
+
if (text.includes("TASK_DONE")) plannerSaidDone = true;
|
|
909
1221
|
}
|
|
910
|
-
} catch {
|
|
911
|
-
// Can't check, proceed with executor turn
|
|
912
|
-
}
|
|
1222
|
+
} catch { /* proceed */ }
|
|
913
1223
|
|
|
914
1224
|
if (plannerSaidDone) {
|
|
915
1225
|
log.ok("Planner declared task complete.");
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
if (!nextBead) {
|
|
920
|
-
log.ok("No more tasks. Finished.");
|
|
921
|
-
break;
|
|
922
|
-
}
|
|
923
|
-
// plannerPrompt for next iteration will be set at top of loop
|
|
1226
|
+
cycle = 0;
|
|
1227
|
+
const nextBead = pickBeadForPlanner(log);
|
|
1228
|
+
if (!nextBead) { log.ok("No more tasks. Finished."); break; }
|
|
924
1229
|
continue;
|
|
925
1230
|
}
|
|
926
1231
|
|
|
927
|
-
//
|
|
1232
|
+
// Executor turn
|
|
928
1233
|
console.log(
|
|
929
|
-
`\n${color.bold(
|
|
1234
|
+
`\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
|
|
930
1235
|
);
|
|
931
1236
|
|
|
932
1237
|
const executorPrompt = buildExecutorPrompt();
|
|
933
1238
|
log.info(`Sending prompt to Executor (${opts.executor})...`);
|
|
934
1239
|
eventDisplay.resetTurn("executor");
|
|
935
|
-
const executorResult = await
|
|
936
|
-
client,
|
|
937
|
-
|
|
938
|
-
executorPrompt,
|
|
939
|
-
executorModel,
|
|
940
|
-
eventDisplay,
|
|
941
|
-
inputQueue,
|
|
1240
|
+
const executorResult = await executeTurnHeadless(
|
|
1241
|
+
client, sessionId, executorPrompt, executorModel,
|
|
1242
|
+
eventDisplay, inputQueue,
|
|
942
1243
|
);
|
|
943
|
-
console.log("");
|
|
1244
|
+
console.log("");
|
|
944
1245
|
|
|
945
|
-
if (executorResult.quit) {
|
|
946
|
-
|
|
947
|
-
|
|
1246
|
+
if (executorResult.quit) { log.info("User requested quit."); return; }
|
|
1247
|
+
if (executorResult.aborted) { log.info("Executor turn aborted."); }
|
|
1248
|
+
|
|
1249
|
+
await sleep(1000);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (cycle >= opts.maxCycles) {
|
|
1253
|
+
log.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
|
|
1254
|
+
}
|
|
1255
|
+
} finally {
|
|
1256
|
+
eventDisplay.stop();
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ── Supervisor loop (daemon mode) ───────────────────────────────────────────
|
|
1261
|
+
|
|
1262
|
+
async function supervisorLoopDaemon(client, sessionId, opts, dlog) {
|
|
1263
|
+
const plannerModel = parseModelSpec(opts.planner);
|
|
1264
|
+
const executorModel = parseModelSpec(opts.executor);
|
|
1265
|
+
|
|
1266
|
+
// Start SSE event monitor (silent)
|
|
1267
|
+
const monitor = createDaemonEventMonitor(client, dlog);
|
|
1268
|
+
|
|
1269
|
+
// Track whether user is interacting via TUI
|
|
1270
|
+
let userInteracting = false;
|
|
1271
|
+
let userTurnResolve = null;
|
|
1272
|
+
|
|
1273
|
+
monitor.onUserMessage = (text) => {
|
|
1274
|
+
dlog.info(`User typed in TUI, pausing auto loop...`);
|
|
1275
|
+
userInteracting = true;
|
|
1276
|
+
// The user message triggers a model response. We need to wait for
|
|
1277
|
+
// that response to complete before resuming our auto loop.
|
|
1278
|
+
// The next session.status=idle will signal completion.
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
monitor.start().catch(() => {});
|
|
1282
|
+
|
|
1283
|
+
// Inject system context
|
|
1284
|
+
const systemContext = buildSystemContext(opts);
|
|
1285
|
+
monitor.registerPrompt(systemContext);
|
|
1286
|
+
try {
|
|
1287
|
+
await client.session.prompt(sessionId, {
|
|
1288
|
+
noReply: true,
|
|
1289
|
+
parts: [{ type: "text", text: systemContext }],
|
|
1290
|
+
});
|
|
1291
|
+
dlog.ok("Context injected");
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
dlog.warn(`Context injection: ${err.message}`);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
let cycle = 0;
|
|
1297
|
+
|
|
1298
|
+
// If no explicit goal, enter goal discussion with planner.
|
|
1299
|
+
// The discovery prompt lists existing beads (if any) and suggestions.
|
|
1300
|
+
// User discusses in TUI, types /go when ready. If they want to pick
|
|
1301
|
+
// from beads, the planner will incorporate that into its plan.
|
|
1302
|
+
if (!opts.goal) {
|
|
1303
|
+
dlog.info("No goal specified — entering goal discussion with Planner.");
|
|
1304
|
+
const goResult = await goalDiscussionDaemon(
|
|
1305
|
+
client, sessionId, plannerModel, monitor, dlog,
|
|
1306
|
+
);
|
|
1307
|
+
if (!goResult) {
|
|
1308
|
+
dlog.info("Goal discussion ended without /go. Exiting.");
|
|
1309
|
+
monitor.stop();
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
// Goal discussion complete — planner finalize prompt has produced
|
|
1313
|
+
// the first executor instruction. Jump straight to executor turn.
|
|
1314
|
+
cycle++;
|
|
1315
|
+
dlog.info(`Cycle ${cycle} / Executor (${opts.executor}) [post-goal-discussion]`);
|
|
1316
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1317
|
+
const executorResult = await executeTurnDaemon(
|
|
1318
|
+
client, sessionId, executorPrompt, executorModel, monitor, dlog,
|
|
1319
|
+
);
|
|
1320
|
+
if (executorResult.aborted) {
|
|
1321
|
+
dlog.warn("Executor turn aborted.");
|
|
1322
|
+
}
|
|
1323
|
+
await sleep(1000);
|
|
1324
|
+
// Fall through to the main loop (cycle is now 1, will get planner review)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
while (cycle < opts.maxCycles) {
|
|
1329
|
+
// If user is interacting, wait for the model to finish responding
|
|
1330
|
+
// to their message before we send the next auto prompt
|
|
1331
|
+
if (userInteracting) {
|
|
1332
|
+
dlog.info("Waiting for user's turn to complete...");
|
|
1333
|
+
await monitor.waitForTurnEnd();
|
|
1334
|
+
userInteracting = false;
|
|
1335
|
+
dlog.info("User turn complete, resuming auto loop.");
|
|
1336
|
+
// After user intervention, planner should review
|
|
1337
|
+
// (fall through to planner review prompt)
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
let plannerPrompt = null;
|
|
1341
|
+
|
|
1342
|
+
if (cycle === 0) {
|
|
1343
|
+
if (opts.goal) {
|
|
1344
|
+
plannerPrompt = buildPlannerGoalPrompt(opts.goal);
|
|
1345
|
+
} else {
|
|
1346
|
+
// No explicit goal, but beads exist — pick from beads
|
|
1347
|
+
plannerPrompt = pickBeadForPlanner(dlog);
|
|
1348
|
+
}
|
|
1349
|
+
} else {
|
|
1350
|
+
plannerPrompt = buildPlannerReviewPrompt(null);
|
|
948
1351
|
}
|
|
1352
|
+
|
|
1353
|
+
if (!plannerPrompt) {
|
|
1354
|
+
const open = getOpenBeads();
|
|
1355
|
+
if (open.length === 0) {
|
|
1356
|
+
dlog.ok("All beads completed! Nothing left to do.");
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
dlog.warn("All beads blocked. Waiting 30s before retry...");
|
|
1360
|
+
await sleep(30_000);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
cycle++;
|
|
1365
|
+
|
|
1366
|
+
// Planner turn
|
|
1367
|
+
dlog.info(`Cycle ${cycle} / Planner (${opts.planner})`);
|
|
1368
|
+
const plannerResult = await executeTurnDaemon(
|
|
1369
|
+
client, sessionId, plannerPrompt, plannerModel, monitor, dlog,
|
|
1370
|
+
);
|
|
1371
|
+
|
|
1372
|
+
if (plannerResult.aborted) {
|
|
1373
|
+
dlog.warn("Planner turn aborted.");
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Check TASK_DONE
|
|
1378
|
+
let plannerSaidDone = false;
|
|
1379
|
+
try {
|
|
1380
|
+
const messages = await client.session.messages(sessionId);
|
|
1381
|
+
if (messages && messages.length > 0) {
|
|
1382
|
+
const lastMsg = messages[messages.length - 1];
|
|
1383
|
+
const text = extractMessageText(lastMsg);
|
|
1384
|
+
if (text.includes("TASK_DONE")) plannerSaidDone = true;
|
|
1385
|
+
}
|
|
1386
|
+
} catch { /* proceed */ }
|
|
1387
|
+
|
|
1388
|
+
if (plannerSaidDone) {
|
|
1389
|
+
dlog.ok("Planner declared task complete.");
|
|
1390
|
+
cycle = 0;
|
|
1391
|
+
const nextBead = pickBeadForPlanner(dlog);
|
|
1392
|
+
if (!nextBead) { dlog.ok("No more tasks. Finished."); break; }
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Executor turn
|
|
1397
|
+
dlog.info(`Cycle ${cycle} / Executor (${opts.executor})`);
|
|
1398
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1399
|
+
const executorResult = await executeTurnDaemon(
|
|
1400
|
+
client, sessionId, executorPrompt, executorModel, monitor, dlog,
|
|
1401
|
+
);
|
|
1402
|
+
|
|
949
1403
|
if (executorResult.aborted) {
|
|
950
|
-
|
|
951
|
-
// Planner will review on next cycle
|
|
1404
|
+
dlog.warn("Executor turn aborted.");
|
|
952
1405
|
}
|
|
953
1406
|
|
|
954
|
-
// Small pause between cycles
|
|
955
1407
|
await sleep(1000);
|
|
956
1408
|
}
|
|
957
1409
|
|
|
958
1410
|
if (cycle >= opts.maxCycles) {
|
|
959
|
-
|
|
1411
|
+
dlog.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
|
|
960
1412
|
}
|
|
961
1413
|
} finally {
|
|
962
|
-
|
|
1414
|
+
monitor.stop();
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Validate models by sending a real test prompt.
|
|
1422
|
+
* Works with both console logger (log) and file logger (dlog).
|
|
1423
|
+
*/
|
|
1424
|
+
async function validateModels(client, sessionId, opts, logger) {
|
|
1425
|
+
const plannerModel = parseModelSpec(opts.planner);
|
|
1426
|
+
const executorModel = parseModelSpec(opts.executor);
|
|
1427
|
+
const probeModels = [
|
|
1428
|
+
{ label: "Planner", spec: opts.planner, parsed: plannerModel },
|
|
1429
|
+
{ label: "Executor", spec: opts.executor, parsed: executorModel },
|
|
1430
|
+
];
|
|
1431
|
+
const seen = new Set();
|
|
1432
|
+
for (const m of probeModels) {
|
|
1433
|
+
if (seen.has(m.spec)) continue;
|
|
1434
|
+
seen.add(m.spec);
|
|
1435
|
+
try {
|
|
1436
|
+
const result = await client.session.prompt(sessionId, {
|
|
1437
|
+
parts: [{ type: "text", text: "Say OK" }],
|
|
1438
|
+
model: m.parsed,
|
|
1439
|
+
});
|
|
1440
|
+
const err = result?.info?.error;
|
|
1441
|
+
if (err) {
|
|
1442
|
+
const msg = err.data?.message || err.name || "unknown error";
|
|
1443
|
+
logger.fail(`${m.label} model "${m.spec}" rejected by provider: ${msg}`);
|
|
1444
|
+
throw new Error(`${m.label} model unavailable: ${msg}`);
|
|
1445
|
+
}
|
|
1446
|
+
logger.ok(`${m.label} model verified: ${m.spec}`);
|
|
1447
|
+
} catch (probeErr) {
|
|
1448
|
+
if (probeErr.message.includes("unavailable") || probeErr.message.includes("rejected")) {
|
|
1449
|
+
throw probeErr;
|
|
1450
|
+
}
|
|
1451
|
+
logger.warn(`${m.label} model probe inconclusive: ${probeErr.message}`);
|
|
1452
|
+
}
|
|
963
1453
|
}
|
|
964
1454
|
}
|
|
965
1455
|
|
|
@@ -967,27 +1457,24 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
967
1457
|
* Try to pick a bead and return a planner prompt for it.
|
|
968
1458
|
* Returns null if no beads available.
|
|
969
1459
|
*/
|
|
970
|
-
function pickBeadForPlanner() {
|
|
971
|
-
// Check in-progress first
|
|
1460
|
+
function pickBeadForPlanner(logger) {
|
|
972
1461
|
const inProgress = getInProgressBeads();
|
|
973
1462
|
if (inProgress.length > 0) {
|
|
974
1463
|
const beadId = extractBeadId(inProgress[0]);
|
|
975
1464
|
if (beadId) {
|
|
976
|
-
|
|
1465
|
+
logger.info(`Resuming: ${beadId}`);
|
|
977
1466
|
return buildPlannerBeadPrompt(beadId);
|
|
978
1467
|
}
|
|
979
1468
|
}
|
|
980
1469
|
|
|
981
|
-
// Check ready beads
|
|
982
1470
|
const ready = getReadyBeads();
|
|
983
1471
|
if (ready.length === 0) return null;
|
|
984
1472
|
|
|
985
1473
|
const beadId = extractBeadId(ready[0]);
|
|
986
1474
|
if (!beadId) return null;
|
|
987
1475
|
|
|
988
|
-
// Claim it
|
|
989
1476
|
run(`bd update ${beadId} --status=in_progress`);
|
|
990
|
-
|
|
1477
|
+
logger.info(`Picked: ${beadId}`);
|
|
991
1478
|
return buildPlannerBeadPrompt(beadId);
|
|
992
1479
|
}
|
|
993
1480
|
|
|
@@ -1022,6 +1509,231 @@ async function waitForInput(inputQueue) {
|
|
|
1022
1509
|
}
|
|
1023
1510
|
}
|
|
1024
1511
|
|
|
1512
|
+
/**
|
|
1513
|
+
* Ask the user whether to pick from beads or discuss a plan (headless mode).
|
|
1514
|
+
* Shows current beads state and waits for the user to choose.
|
|
1515
|
+
*
|
|
1516
|
+
* @returns {"beads"|"discuss"|"quit"}
|
|
1517
|
+
*/
|
|
1518
|
+
async function askStartModeHeadless(inputQueue) {
|
|
1519
|
+
const inProgress = getInProgressBeads();
|
|
1520
|
+
const ready = getReadyBeads();
|
|
1521
|
+
const hasBeads = inProgress.length > 0 || ready.length > 0;
|
|
1522
|
+
|
|
1523
|
+
console.log(`\n${color.bold("-- What would you like to do?")} ${color.bold("--")}`);
|
|
1524
|
+
if (hasBeads) {
|
|
1525
|
+
if (inProgress.length > 0) {
|
|
1526
|
+
console.log(color.dim(` In-progress beads: ${inProgress.length}`));
|
|
1527
|
+
}
|
|
1528
|
+
if (ready.length > 0) {
|
|
1529
|
+
console.log(color.dim(` Ready beads: ${ready.length}`));
|
|
1530
|
+
}
|
|
1531
|
+
} else {
|
|
1532
|
+
console.log(color.dim(" No beads available."));
|
|
1533
|
+
}
|
|
1534
|
+
console.log("");
|
|
1535
|
+
if (hasBeads) {
|
|
1536
|
+
console.log(` ${color.bold("1")} Pick from beads (auto-select a task)`);
|
|
1537
|
+
}
|
|
1538
|
+
console.log(` ${color.bold("2")} Discuss with Planner (plan before execution)`);
|
|
1539
|
+
console.log(color.dim("\n Type 1 or 2, or /quit to exit.\n"));
|
|
1540
|
+
|
|
1541
|
+
while (true) {
|
|
1542
|
+
await waitForInput(inputQueue);
|
|
1543
|
+
const items = inputQueue.drain();
|
|
1544
|
+
for (const item of items) {
|
|
1545
|
+
if (item.type === "quit") return "quit";
|
|
1546
|
+
if (item.type === "message") {
|
|
1547
|
+
const t = item.text.trim();
|
|
1548
|
+
if (t === "1" && hasBeads) return "beads";
|
|
1549
|
+
if (t === "2") return "discuss";
|
|
1550
|
+
console.log(color.dim(` Please type ${hasBeads ? "1 or 2" : "2"}, or /quit to exit.`));
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// ── Goal discussion (headless mode) ─────────────────────────────────────────
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Interactive goal discussion in headless mode.
|
|
1560
|
+
* Planner suggests goals, user discusses, user types /go to proceed.
|
|
1561
|
+
*
|
|
1562
|
+
* @returns {boolean} true if /go was received and discussion completed,
|
|
1563
|
+
* false if user quit.
|
|
1564
|
+
*/
|
|
1565
|
+
async function goalDiscussionHeadless(client, sessionId, plannerModel, eventDisplay, inputQueue) {
|
|
1566
|
+
console.log(
|
|
1567
|
+
`\n${color.bold("-- Goal Discussion")} ${color.dim("(type /go when ready to start execution)")} ${color.bold("--")}`,
|
|
1568
|
+
);
|
|
1569
|
+
console.log(color.dim(" Discuss with the Planner what to work on. Type /go to begin.\n"));
|
|
1570
|
+
|
|
1571
|
+
// Send discovery prompt to planner
|
|
1572
|
+
const discoveryPrompt = buildPlannerDiscoveryPrompt();
|
|
1573
|
+
log.info("Sending discovery prompt to Planner...");
|
|
1574
|
+
eventDisplay.resetTurn("planner");
|
|
1575
|
+
const discoveryResult = await executeTurnHeadless(
|
|
1576
|
+
client, sessionId, discoveryPrompt, plannerModel,
|
|
1577
|
+
eventDisplay, inputQueue,
|
|
1578
|
+
);
|
|
1579
|
+
console.log("");
|
|
1580
|
+
|
|
1581
|
+
if (discoveryResult.quit) return false;
|
|
1582
|
+
if (discoveryResult.aborted) {
|
|
1583
|
+
log.warn("Discovery prompt aborted. Retrying...");
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Discussion loop: user talks to planner until /go
|
|
1587
|
+
while (true) {
|
|
1588
|
+
// Wait for user input
|
|
1589
|
+
console.log(color.dim("\n [waiting for your input... type /go to start, /quit to exit]\n"));
|
|
1590
|
+
await waitForInput(inputQueue);
|
|
1591
|
+
|
|
1592
|
+
const items = inputQueue.drain();
|
|
1593
|
+
let userText = null;
|
|
1594
|
+
let goReceived = false;
|
|
1595
|
+
let quitReceived = false;
|
|
1596
|
+
|
|
1597
|
+
for (const item of items) {
|
|
1598
|
+
if (item.type === "quit") {
|
|
1599
|
+
quitReceived = true;
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
if (item.type === "message") {
|
|
1603
|
+
// Check if the user typed /go
|
|
1604
|
+
if (item.text.toLowerCase().trim() === "/go") {
|
|
1605
|
+
goReceived = true;
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
userText = item.text;
|
|
1609
|
+
}
|
|
1610
|
+
if (item.type === "abort") {
|
|
1611
|
+
// Ignore /abort during discussion
|
|
1612
|
+
}
|
|
1613
|
+
if (item.type === "status") {
|
|
1614
|
+
showBeadStatus();
|
|
1615
|
+
}
|
|
1616
|
+
if (item.type === "skip") {
|
|
1617
|
+
// Ignore /skip during discussion
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (quitReceived) return false;
|
|
1622
|
+
|
|
1623
|
+
if (goReceived) {
|
|
1624
|
+
// Send finalize prompt to planner
|
|
1625
|
+
console.log(
|
|
1626
|
+
`\n${color.bold("-- Finalizing Goal")} ${color.bold("--------------------")}`,
|
|
1627
|
+
);
|
|
1628
|
+
log.info("Sending finalize prompt to Planner...");
|
|
1629
|
+
eventDisplay.resetTurn("planner");
|
|
1630
|
+
const finalizeResult = await executeTurnHeadless(
|
|
1631
|
+
client, sessionId, buildPlannerFinalizeGoalPrompt(), plannerModel,
|
|
1632
|
+
eventDisplay, inputQueue,
|
|
1633
|
+
);
|
|
1634
|
+
console.log("");
|
|
1635
|
+
|
|
1636
|
+
if (finalizeResult.quit) return false;
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (userText) {
|
|
1641
|
+
// Send user's message to planner for continued discussion
|
|
1642
|
+
const discussPrompt = `## Role: Planner (Goal Discussion)
|
|
1643
|
+
|
|
1644
|
+
The user says:
|
|
1645
|
+
|
|
1646
|
+
> ${userText}
|
|
1647
|
+
|
|
1648
|
+
Continue the goal discussion. Help the user refine what to work on.
|
|
1649
|
+
When they're ready, remind them to type \`/go\` to begin execution.`;
|
|
1650
|
+
|
|
1651
|
+
eventDisplay.resetTurn("planner");
|
|
1652
|
+
const discussResult = await executeTurnHeadless(
|
|
1653
|
+
client, sessionId, discussPrompt, plannerModel,
|
|
1654
|
+
eventDisplay, inputQueue,
|
|
1655
|
+
);
|
|
1656
|
+
console.log("");
|
|
1657
|
+
|
|
1658
|
+
if (discussResult.quit) return false;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ── Goal discussion (daemon mode) ───────────────────────────────────────────
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Goal discussion in daemon+TUI mode.
|
|
1667
|
+
* Sends a discovery prompt to planner, then waits for the user to
|
|
1668
|
+
* type /go in the TUI. While waiting, the user can freely discuss
|
|
1669
|
+
* with the planner via the TUI — the daemon stays paused.
|
|
1670
|
+
*
|
|
1671
|
+
* @returns {boolean} true if /go was received, false if daemon should exit.
|
|
1672
|
+
*/
|
|
1673
|
+
async function goalDiscussionDaemon(client, sessionId, plannerModel, monitor, dlog) {
|
|
1674
|
+
dlog.info("Starting goal discussion (no goal provided)");
|
|
1675
|
+
|
|
1676
|
+
// Send discovery prompt
|
|
1677
|
+
const discoveryPrompt = buildPlannerDiscoveryPrompt();
|
|
1678
|
+
dlog.info("Sending discovery prompt to Planner...");
|
|
1679
|
+
const discoveryResult = await executeTurnDaemon(
|
|
1680
|
+
client, sessionId, discoveryPrompt, plannerModel, monitor, dlog,
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
if (discoveryResult.aborted) {
|
|
1684
|
+
dlog.warn("Discovery prompt aborted");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Now wait for user to type /go in the TUI.
|
|
1688
|
+
// The monitor detects user messages via SSE. We listen for /go specifically.
|
|
1689
|
+
dlog.info("Waiting for user to type /go in TUI...");
|
|
1690
|
+
|
|
1691
|
+
return new Promise((resolve) => {
|
|
1692
|
+
let resolved = false;
|
|
1693
|
+
|
|
1694
|
+
// Save previous onUserMessage handler
|
|
1695
|
+
const prevHandler = monitor.onUserMessage;
|
|
1696
|
+
|
|
1697
|
+
monitor.onUserMessage = (text) => {
|
|
1698
|
+
if (resolved) return;
|
|
1699
|
+
const trimmed = text.trim().toLowerCase();
|
|
1700
|
+
|
|
1701
|
+
if (trimmed === "/go") {
|
|
1702
|
+
dlog.info("User typed /go — finalizing goal");
|
|
1703
|
+
resolved = true;
|
|
1704
|
+
|
|
1705
|
+
// Restore previous handler
|
|
1706
|
+
monitor.onUserMessage = prevHandler || null;
|
|
1707
|
+
|
|
1708
|
+
// Send finalize prompt
|
|
1709
|
+
(async () => {
|
|
1710
|
+
const finalizePrompt = buildPlannerFinalizeGoalPrompt();
|
|
1711
|
+
monitor.registerPrompt(finalizePrompt);
|
|
1712
|
+
dlog.info("Sending finalize prompt to Planner...");
|
|
1713
|
+
const finalizeResult = await executeTurnDaemon(
|
|
1714
|
+
client, sessionId, finalizePrompt, plannerModel, monitor, dlog,
|
|
1715
|
+
);
|
|
1716
|
+
if (finalizeResult.aborted) {
|
|
1717
|
+
dlog.warn("Finalize prompt aborted");
|
|
1718
|
+
}
|
|
1719
|
+
resolve(true);
|
|
1720
|
+
})();
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Any other user message: the user is discussing with the planner via
|
|
1725
|
+
// TUI. The TUI + opencode handle this automatically (user types →
|
|
1726
|
+
// model responds). The daemon just needs to wait for those turns to
|
|
1727
|
+
// complete before checking for /go again.
|
|
1728
|
+
dlog.info(`User discussing in TUI: "${text.slice(0, 60)}..."`);
|
|
1729
|
+
// Wait for the model's response to finish
|
|
1730
|
+
monitor.waitForTurnEnd().then(() => {
|
|
1731
|
+
dlog.info("Planner response to user complete, still waiting for /go");
|
|
1732
|
+
});
|
|
1733
|
+
};
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1025
1737
|
// ── Main entry point ────────────────────────────────────────────────────────
|
|
1026
1738
|
|
|
1027
1739
|
export async function auto(argv) {
|
|
@@ -1034,42 +1746,131 @@ export async function auto(argv) {
|
|
|
1034
1746
|
process.exit(1);
|
|
1035
1747
|
}
|
|
1036
1748
|
|
|
1749
|
+
// ── Path 1: Internal daemon process (forked by main process) ──
|
|
1750
|
+
if (opts._daemon) {
|
|
1751
|
+
return runDaemonProcess(opts);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// ── Path 2: Headless mode (original CLI) ──
|
|
1755
|
+
if (opts.headless) {
|
|
1756
|
+
return runHeadlessMode(opts);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// ── Path 3: Default — daemon + TUI ──
|
|
1760
|
+
return runDaemonTuiMode(opts);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// ── Path 1: Daemon process ──────────────────────────────────────────────────
|
|
1764
|
+
|
|
1765
|
+
async function runDaemonProcess(opts) {
|
|
1766
|
+
const dlog = createFileLogger(LOG_FILE);
|
|
1767
|
+
dlog.info(`Daemon starting — goal: ${opts.goal || "(auto-pick)"}`);
|
|
1768
|
+
dlog.info(`Planner: ${opts.planner}, Executor: ${opts.executor}`);
|
|
1769
|
+
|
|
1770
|
+
const url = opts._daemonUrl;
|
|
1771
|
+
if (!url) {
|
|
1772
|
+
dlog.fail("No server URL provided (--_daemon-url)");
|
|
1773
|
+
process.exit(1);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Connect to the opencode serve instance
|
|
1777
|
+
const client = createClient(url);
|
|
1778
|
+
try {
|
|
1779
|
+
const health = await client.health();
|
|
1780
|
+
if (!health?.healthy) throw new Error("not healthy");
|
|
1781
|
+
dlog.ok(`Connected to server at ${url}`);
|
|
1782
|
+
} catch (err) {
|
|
1783
|
+
dlog.fail(`Cannot connect to server at ${url}: ${err.message}`);
|
|
1784
|
+
process.exit(1);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Create or reuse session
|
|
1788
|
+
let sessionId = opts._daemonSessionId;
|
|
1789
|
+
if (!sessionId) {
|
|
1790
|
+
dlog.info("Creating session...");
|
|
1791
|
+
const session = await client.session.create({ title: "mneme auto" });
|
|
1792
|
+
sessionId = session.id;
|
|
1793
|
+
dlog.ok(`Session: ${sessionId}`);
|
|
1794
|
+
|
|
1795
|
+
// Validate models in new session
|
|
1796
|
+
dlog.info("Validating models (API probe)...");
|
|
1797
|
+
try {
|
|
1798
|
+
await validateModels(client, sessionId, opts, dlog);
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
dlog.fail(`Model validation failed: ${err.message}`);
|
|
1801
|
+
process.exit(1);
|
|
1802
|
+
}
|
|
1803
|
+
} else {
|
|
1804
|
+
dlog.ok(`Reusing session: ${sessionId}`);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Handle signals for clean shutdown
|
|
1808
|
+
let shuttingDown = false;
|
|
1809
|
+
const shutdown = (signal) => {
|
|
1810
|
+
if (shuttingDown) return;
|
|
1811
|
+
shuttingDown = true;
|
|
1812
|
+
dlog.info(`Received ${signal}, shutting down daemon...`);
|
|
1813
|
+
process.exit(0);
|
|
1814
|
+
};
|
|
1815
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1816
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1817
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
1818
|
+
|
|
1819
|
+
// Monitor parent process (the TUI). When it exits, we should too.
|
|
1820
|
+
// In Node.js, when the parent exits, the daemon gets SIGHUP if not
|
|
1821
|
+
// fully detached. We also poll as a fallback.
|
|
1822
|
+
if (process.ppid) {
|
|
1823
|
+
const parentPid = process.ppid;
|
|
1824
|
+
const parentCheckId = setInterval(() => {
|
|
1825
|
+
try {
|
|
1826
|
+
// Signal 0 checks if process exists
|
|
1827
|
+
process.kill(parentPid, 0);
|
|
1828
|
+
} catch {
|
|
1829
|
+
dlog.info("Parent process exited, daemon shutting down.");
|
|
1830
|
+
clearInterval(parentCheckId);
|
|
1831
|
+
process.exit(0);
|
|
1832
|
+
}
|
|
1833
|
+
}, 5000);
|
|
1834
|
+
parentCheckId.unref?.();
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Run the daemon supervisor loop
|
|
1838
|
+
try {
|
|
1839
|
+
await supervisorLoopDaemon(client, sessionId, opts, dlog);
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
dlog.fail(`Daemon supervisor error: ${err.message}`);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
dlog.ok("Daemon finished.");
|
|
1845
|
+
process.exit(0);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// ── Path 2: Headless mode (original CLI) ────────────────────────────────────
|
|
1849
|
+
|
|
1850
|
+
async function runHeadlessMode(opts) {
|
|
1037
1851
|
console.log(
|
|
1038
|
-
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor\n`,
|
|
1039
|
-
);
|
|
1040
|
-
console.log(
|
|
1041
|
-
` ${color.bold("Planner:")} ${opts.planner}`,
|
|
1042
|
-
);
|
|
1043
|
-
console.log(
|
|
1044
|
-
` ${color.bold("Executor:")} ${opts.executor}\n`,
|
|
1852
|
+
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor (headless)\n`,
|
|
1045
1853
|
);
|
|
1854
|
+
console.log(` ${color.bold("Planner:")} ${opts.planner}`);
|
|
1855
|
+
console.log(` ${color.bold("Executor:")} ${opts.executor}\n`);
|
|
1046
1856
|
console.log(color.dim("Commands while running:"));
|
|
1047
|
-
console.log(
|
|
1048
|
-
|
|
1049
|
-
);
|
|
1050
|
-
console.log(color.dim(" /
|
|
1051
|
-
console.log(color.dim(" /
|
|
1052
|
-
console.log(color.dim(" /abort → abort current turn"));
|
|
1053
|
-
console.log(color.dim(" /quit → stop and exit\n"));
|
|
1857
|
+
console.log(color.dim(" Type any message -> inject feedback to planner"));
|
|
1858
|
+
console.log(color.dim(" /status -> show bead status"));
|
|
1859
|
+
console.log(color.dim(" /skip -> skip current bead"));
|
|
1860
|
+
console.log(color.dim(" /abort -> abort current turn"));
|
|
1861
|
+
console.log(color.dim(" /quit -> stop and exit\n"));
|
|
1054
1862
|
|
|
1055
|
-
// Start or attach to server
|
|
1056
1863
|
let serverCtx;
|
|
1057
1864
|
try {
|
|
1058
1865
|
if (opts.attach) {
|
|
1059
1866
|
serverCtx = await attachOpencodeServer(opts.attach);
|
|
1060
|
-
log.ok(
|
|
1061
|
-
`Attached to ${serverCtx.url} (v${serverCtx.version})`,
|
|
1062
|
-
);
|
|
1867
|
+
log.ok(`Attached to ${serverCtx.url} (v${serverCtx.version})`);
|
|
1063
1868
|
} else {
|
|
1064
1869
|
serverCtx = await startOpencodeServer({ port: opts.port });
|
|
1065
1870
|
if (serverCtx.alreadyRunning) {
|
|
1066
|
-
log.ok(
|
|
1067
|
-
`Server already running at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1068
|
-
);
|
|
1871
|
+
log.ok(`Server already running at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1069
1872
|
} else {
|
|
1070
|
-
log.ok(
|
|
1071
|
-
`Server started at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1072
|
-
);
|
|
1873
|
+
log.ok(`Server started at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1073
1874
|
}
|
|
1074
1875
|
}
|
|
1075
1876
|
} catch (err) {
|
|
@@ -1077,18 +1878,15 @@ export async function auto(argv) {
|
|
|
1077
1878
|
process.exit(1);
|
|
1078
1879
|
}
|
|
1079
1880
|
|
|
1080
|
-
// Start input queue
|
|
1081
1881
|
const inputQueue = createInputQueue();
|
|
1082
1882
|
inputQueue.start();
|
|
1083
1883
|
|
|
1084
|
-
// Run supervisor
|
|
1085
1884
|
try {
|
|
1086
|
-
await
|
|
1885
|
+
await supervisorLoopHeadless(serverCtx.client, opts, inputQueue);
|
|
1087
1886
|
} catch (err) {
|
|
1088
1887
|
log.fail(`Supervisor error: ${err.message}`);
|
|
1089
1888
|
} finally {
|
|
1090
1889
|
inputQueue.stop();
|
|
1091
|
-
// Only kill server if WE started it
|
|
1092
1890
|
if (serverCtx.serverProcess) {
|
|
1093
1891
|
log.info("Shutting down server...");
|
|
1094
1892
|
serverCtx.serverProcess.kill("SIGTERM");
|
|
@@ -1097,6 +1895,141 @@ export async function auto(argv) {
|
|
|
1097
1895
|
}
|
|
1098
1896
|
}
|
|
1099
1897
|
|
|
1898
|
+
// ── Path 3: Daemon + TUI mode (default) ────────────────────────────────────
|
|
1899
|
+
|
|
1900
|
+
async function runDaemonTuiMode(opts) {
|
|
1901
|
+
console.log(
|
|
1902
|
+
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor\n`,
|
|
1903
|
+
);
|
|
1904
|
+
console.log(` ${color.bold("Planner:")} ${opts.planner}`);
|
|
1905
|
+
console.log(` ${color.bold("Executor:")} ${opts.executor}`);
|
|
1906
|
+
console.log(` ${color.bold("Mode:")} daemon + TUI\n`);
|
|
1907
|
+
|
|
1908
|
+
// Step 1: Start opencode serve (if not running)
|
|
1909
|
+
let serverUrl;
|
|
1910
|
+
let weStartedServer = false;
|
|
1911
|
+
|
|
1912
|
+
if (opts.attach) {
|
|
1913
|
+
// User provided a URL
|
|
1914
|
+
serverUrl = opts.attach;
|
|
1915
|
+
log.info(`Using provided server: ${serverUrl}`);
|
|
1916
|
+
try {
|
|
1917
|
+
const client = createClient(serverUrl);
|
|
1918
|
+
const health = await client.health();
|
|
1919
|
+
if (!health?.healthy) throw new Error("not healthy");
|
|
1920
|
+
log.ok(`Server healthy (v${health.version})`);
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
log.fail(`Cannot connect to ${serverUrl}: ${err.message}`);
|
|
1923
|
+
process.exit(1);
|
|
1924
|
+
}
|
|
1925
|
+
} else {
|
|
1926
|
+
serverUrl = `http://127.0.0.1:${opts.port}`;
|
|
1927
|
+
// Check if already running
|
|
1928
|
+
try {
|
|
1929
|
+
const client = createClient(serverUrl);
|
|
1930
|
+
const health = await client.health();
|
|
1931
|
+
if (health?.healthy) {
|
|
1932
|
+
log.ok(`Server already running at ${serverUrl} (v${health.version})`);
|
|
1933
|
+
} else {
|
|
1934
|
+
throw new Error("not healthy");
|
|
1935
|
+
}
|
|
1936
|
+
} catch {
|
|
1937
|
+
// Need to start it
|
|
1938
|
+
log.info(`Starting opencode serve on port ${opts.port}...`);
|
|
1939
|
+
try {
|
|
1940
|
+
const ctx = await startOpencodeServer({ port: opts.port, detached: true });
|
|
1941
|
+
weStartedServer = !ctx.alreadyRunning;
|
|
1942
|
+
log.ok(`Server started at ${ctx.url} (v${ctx.version})`);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
log.fail(`Failed to start server: ${err.message}`);
|
|
1945
|
+
process.exit(1);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// Step 2: Create session and validate models before forking daemon
|
|
1951
|
+
log.info("Creating session and validating models...");
|
|
1952
|
+
const client = createClient(serverUrl);
|
|
1953
|
+
let sessionId;
|
|
1954
|
+
try {
|
|
1955
|
+
const session = await client.session.create({ title: "mneme auto" });
|
|
1956
|
+
sessionId = session.id;
|
|
1957
|
+
log.ok(`Session: ${sessionId}`);
|
|
1958
|
+
await validateModels(client, sessionId, opts, log);
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
log.fail(`Setup failed: ${err.message}`);
|
|
1961
|
+
if (weStartedServer) {
|
|
1962
|
+
log.info("Stopping server we started...");
|
|
1963
|
+
run(`kill $(ps aux | grep 'opencode.*serve.*--port.*${opts.port}' | grep -v grep | awk '{print $2}') 2>/dev/null`);
|
|
1964
|
+
}
|
|
1965
|
+
process.exit(1);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// Step 3: Fork daemon process
|
|
1969
|
+
log.info("Forking daemon process...");
|
|
1970
|
+
|
|
1971
|
+
// Build daemon argv
|
|
1972
|
+
const daemonArgs = [
|
|
1973
|
+
"auto",
|
|
1974
|
+
"--_daemon",
|
|
1975
|
+
"--_daemon-url", serverUrl,
|
|
1976
|
+
"--_daemon-session", sessionId,
|
|
1977
|
+
"--planner", opts.planner,
|
|
1978
|
+
"--executor", opts.executor,
|
|
1979
|
+
"--max-cycles", String(opts.maxCycles),
|
|
1980
|
+
];
|
|
1981
|
+
if (opts.goal) {
|
|
1982
|
+
daemonArgs.push(opts.goal);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Fork using the mneme CLI entry point
|
|
1986
|
+
const mnemeEntry = join(
|
|
1987
|
+
new URL(".", import.meta.url).pathname,
|
|
1988
|
+
"..", "..", "bin", "mneme.mjs",
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
const daemon = fork(mnemeEntry, daemonArgs, {
|
|
1992
|
+
detached: true,
|
|
1993
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
daemon.unref();
|
|
1997
|
+
// Disconnect IPC so parent can exit cleanly
|
|
1998
|
+
daemon.disconnect();
|
|
1999
|
+
|
|
2000
|
+
log.ok(`Daemon forked (PID: ${daemon.pid})`);
|
|
2001
|
+
log.info(`Log file: ${LOG_FILE}`);
|
|
2002
|
+
|
|
2003
|
+
// Small delay to let daemon connect before we launch TUI
|
|
2004
|
+
await sleep(1000);
|
|
2005
|
+
|
|
2006
|
+
// Step 4: exec opencode attach (replaces this process)
|
|
2007
|
+
log.info(`Launching TUI: opencode attach ${serverUrl}`);
|
|
2008
|
+
console.log(color.dim(" Type directly in the TUI to intervene. The daemon pauses automatically.\n"));
|
|
2009
|
+
|
|
2010
|
+
try {
|
|
2011
|
+
execSync(`opencode attach ${serverUrl}`, {
|
|
2012
|
+
stdio: "inherit",
|
|
2013
|
+
// This blocks until the TUI exits
|
|
2014
|
+
});
|
|
2015
|
+
} catch {
|
|
2016
|
+
// TUI exited (normal or error)
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// TUI exited — kill daemon
|
|
2020
|
+
log.info("TUI exited.");
|
|
2021
|
+
try {
|
|
2022
|
+
process.kill(daemon.pid, "SIGTERM");
|
|
2023
|
+
log.info(`Sent SIGTERM to daemon (PID: ${daemon.pid})`);
|
|
2024
|
+
} catch {
|
|
2025
|
+
// Daemon may have already exited
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// If we started the server and --attach wasn't used, optionally stop it
|
|
2029
|
+
// (leave it running — user can `mneme down` manually)
|
|
2030
|
+
log.ok("mneme auto finished.");
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1100
2033
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1101
2034
|
|
|
1102
2035
|
function sleep(ms) {
|