@xqli02/mneme 0.1.10 → 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 +1183 -208
- package/src/opencode-client.mjs +11 -1
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,25 +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();
|
|
522
|
+
const deltaParts = new Set();
|
|
399
523
|
|
|
400
524
|
async function start() {
|
|
401
525
|
running = true;
|
|
@@ -415,7 +539,6 @@ function createEventDisplay(client) {
|
|
|
415
539
|
console.error(
|
|
416
540
|
color.dim(`\n [events] Stream error: ${err.message}`),
|
|
417
541
|
);
|
|
418
|
-
// Try to reconnect after a brief delay
|
|
419
542
|
await sleep(2000);
|
|
420
543
|
if (running) {
|
|
421
544
|
log.info("Reconnecting SSE...");
|
|
@@ -430,32 +553,54 @@ function createEventDisplay(client) {
|
|
|
430
553
|
const props = event.properties || {};
|
|
431
554
|
|
|
432
555
|
switch (type) {
|
|
556
|
+
case "message.part.delta": {
|
|
557
|
+
const partId = props.partID || props.partId;
|
|
558
|
+
if (partId) deltaParts.add(partId);
|
|
559
|
+
if (props.field === "text" && props.delta) {
|
|
560
|
+
process.stdout.write(props.delta);
|
|
561
|
+
lastOutputTime = Date.now();
|
|
562
|
+
}
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
|
|
433
566
|
case "message.part.updated": {
|
|
434
567
|
if (!props.part) break;
|
|
435
568
|
const part = props.part;
|
|
436
569
|
const partId = part.id || `${props.messageID}-${props.index}`;
|
|
437
570
|
|
|
438
|
-
if (
|
|
439
|
-
const prev = printedTextLengths.get(partId) || 0;
|
|
440
|
-
const newText = part.text.slice(prev);
|
|
441
|
-
if (newText) {
|
|
442
|
-
process.stdout.write(newText);
|
|
443
|
-
printedTextLengths.set(partId, part.text.length);
|
|
444
|
-
lastOutputTime = Date.now();
|
|
445
|
-
}
|
|
446
|
-
} else if (
|
|
571
|
+
if (
|
|
447
572
|
part.type === "tool-invocation" ||
|
|
448
573
|
part.type === "tool-result"
|
|
449
574
|
) {
|
|
450
575
|
displayToolPart(part, partId);
|
|
451
576
|
lastOutputTime = Date.now();
|
|
452
577
|
}
|
|
578
|
+
if (part.type === "text" && part.text) {
|
|
579
|
+
const prev = printedTextLengths.get(partId) || 0;
|
|
580
|
+
if (prev === 0 && !deltaParts.has(partId)) {
|
|
581
|
+
process.stdout.write(part.text);
|
|
582
|
+
lastOutputTime = Date.now();
|
|
583
|
+
}
|
|
584
|
+
printedTextLengths.set(partId, part.text.length);
|
|
585
|
+
}
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
case "session.status": {
|
|
590
|
+
const status = props.status?.type || props.status;
|
|
591
|
+
if (status && status !== "busy" && status !== "pending") {
|
|
592
|
+
if (turnResolve) {
|
|
593
|
+
turnResolve(status);
|
|
594
|
+
turnResolve = null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
453
597
|
break;
|
|
454
598
|
}
|
|
455
599
|
|
|
456
600
|
case "session.updated": {
|
|
457
|
-
const
|
|
458
|
-
|
|
601
|
+
const info = props.info || props.session || {};
|
|
602
|
+
const status = info.status?.type || info.status;
|
|
603
|
+
if (status && status !== "busy" && status !== "running" && status !== "pending") {
|
|
459
604
|
if (turnResolve) {
|
|
460
605
|
turnResolve(status);
|
|
461
606
|
turnResolve = null;
|
|
@@ -464,6 +609,14 @@ function createEventDisplay(client) {
|
|
|
464
609
|
break;
|
|
465
610
|
}
|
|
466
611
|
|
|
612
|
+
case "message.updated": {
|
|
613
|
+
const info = props.info || {};
|
|
614
|
+
if (info.finish && info.finish !== "pending") {
|
|
615
|
+
lastOutputTime = Date.now();
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
|
|
467
620
|
default:
|
|
468
621
|
break;
|
|
469
622
|
}
|
|
@@ -478,7 +631,7 @@ function createEventDisplay(client) {
|
|
|
478
631
|
if (state === "call" && lastState !== "call") {
|
|
479
632
|
const argsStr = summarizeArgs(inv.args);
|
|
480
633
|
console.log(
|
|
481
|
-
`\n${color.bold(`
|
|
634
|
+
`\n${color.bold(` * ${toolName}`)}${argsStr ? color.dim(` ${argsStr}`) : ""}`,
|
|
482
635
|
);
|
|
483
636
|
displayedToolStates.set(partId, "call");
|
|
484
637
|
} else if (state === "result" && lastState !== "result") {
|
|
@@ -519,10 +672,6 @@ function createEventDisplay(client) {
|
|
|
519
672
|
return str.length > 120 ? str.slice(0, 120) + "..." : str;
|
|
520
673
|
}
|
|
521
674
|
|
|
522
|
-
/**
|
|
523
|
-
* Wait for the current turn to complete via SSE.
|
|
524
|
-
* Returns a promise that resolves with the session status.
|
|
525
|
-
*/
|
|
526
675
|
function waitForTurnEnd() {
|
|
527
676
|
return new Promise((resolve) => {
|
|
528
677
|
turnResolve = resolve;
|
|
@@ -533,6 +682,155 @@ function createEventDisplay(client) {
|
|
|
533
682
|
currentRole = role;
|
|
534
683
|
printedTextLengths.clear();
|
|
535
684
|
displayedToolStates.clear();
|
|
685
|
+
deltaParts.clear();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function stop() {
|
|
689
|
+
running = false;
|
|
690
|
+
if (turnResolve) {
|
|
691
|
+
turnResolve("stopped");
|
|
692
|
+
turnResolve = null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
start,
|
|
698
|
+
stop,
|
|
699
|
+
waitForTurnEnd,
|
|
700
|
+
resetTurn,
|
|
701
|
+
get lastOutputTime() { return lastOutputTime; },
|
|
702
|
+
get connected() { return connected; },
|
|
703
|
+
get hasReceivedAny() { return hasReceivedAny; },
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
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());
|
|
536
834
|
}
|
|
537
835
|
|
|
538
836
|
function stop() {
|
|
@@ -548,23 +846,25 @@ function createEventDisplay(client) {
|
|
|
548
846
|
stop,
|
|
549
847
|
waitForTurnEnd,
|
|
550
848
|
resetTurn,
|
|
849
|
+
registerPrompt,
|
|
850
|
+
set onUserMessage(fn) { onUserMessage = fn; },
|
|
851
|
+
get onUserMessage() { return onUserMessage; },
|
|
551
852
|
get lastOutputTime() { return lastOutputTime; },
|
|
552
853
|
get connected() { return connected; },
|
|
553
854
|
get hasReceivedAny() { return hasReceivedAny; },
|
|
554
855
|
};
|
|
555
856
|
}
|
|
556
857
|
|
|
557
|
-
// ── Turn execution
|
|
858
|
+
// ── Turn execution (headless mode) ──────────────────────────────────────────
|
|
558
859
|
|
|
559
860
|
/**
|
|
560
|
-
* Send a message and wait for the turn to complete.
|
|
861
|
+
* Send a message and wait for the turn to complete (headless mode).
|
|
561
862
|
* Handles /abort and /quit from input queue during execution.
|
|
562
863
|
* Prints heartbeat every 15s when no output is flowing.
|
|
563
|
-
* Warns at 30s of silence, auto-aborts at 120s of silence.
|
|
564
864
|
*
|
|
565
865
|
* @returns {{ status: string, aborted: boolean, quit: boolean }}
|
|
566
866
|
*/
|
|
567
|
-
async function
|
|
867
|
+
async function executeTurnHeadless(
|
|
568
868
|
client,
|
|
569
869
|
sessionId,
|
|
570
870
|
prompt,
|
|
@@ -581,18 +881,14 @@ async function executeTurn(
|
|
|
581
881
|
body.model = modelSpec;
|
|
582
882
|
}
|
|
583
883
|
|
|
584
|
-
// Send async — returns immediately
|
|
585
884
|
await client.session.promptAsync(sessionId, body);
|
|
586
885
|
|
|
587
|
-
// Quick check
|
|
588
|
-
// instantly but promptAsync still returns 204. Poll once after a short
|
|
589
|
-
// delay to catch this before entering the long wait loop.
|
|
886
|
+
// Quick check for immediate model errors
|
|
590
887
|
await sleep(2000);
|
|
591
888
|
try {
|
|
592
889
|
const sessions = await client.session.list();
|
|
593
890
|
const s = sessions?.find?.((ss) => ss.id === sessionId);
|
|
594
891
|
if (s && s.status && s.status !== "running" && s.status !== "pending") {
|
|
595
|
-
// Session already finished — likely an immediate error
|
|
596
892
|
const msgs = await client.session.messages(sessionId);
|
|
597
893
|
const lastMsg = msgs?.[msgs.length - 1];
|
|
598
894
|
const errInfo = lastMsg?.info?.error;
|
|
@@ -603,15 +899,14 @@ async function executeTurn(
|
|
|
603
899
|
}
|
|
604
900
|
}
|
|
605
901
|
} catch {
|
|
606
|
-
// Ignore probe failures
|
|
902
|
+
// Ignore probe failures
|
|
607
903
|
}
|
|
608
904
|
|
|
609
|
-
const HEARTBEAT_INTERVAL = 15_000;
|
|
610
|
-
const SILENCE_WARN = 30_000;
|
|
611
|
-
const SILENCE_ABORT = 120_000;
|
|
905
|
+
const HEARTBEAT_INTERVAL = 15_000;
|
|
906
|
+
const SILENCE_WARN = 30_000;
|
|
907
|
+
const SILENCE_ABORT = 120_000;
|
|
612
908
|
const turnStartTime = Date.now();
|
|
613
909
|
|
|
614
|
-
// Race: SSE turn completion vs user commands vs silence timeout
|
|
615
910
|
return new Promise((resolve) => {
|
|
616
911
|
let resolved = false;
|
|
617
912
|
let warnedSilence = false;
|
|
@@ -624,12 +919,10 @@ async function executeTurn(
|
|
|
624
919
|
resolve(result);
|
|
625
920
|
};
|
|
626
921
|
|
|
627
|
-
// SSE completion
|
|
628
922
|
eventDisplay.waitForTurnEnd().then((status) => {
|
|
629
923
|
done({ status, aborted: false, quit: false });
|
|
630
924
|
});
|
|
631
925
|
|
|
632
|
-
// Heartbeat: show elapsed time when no output is flowing
|
|
633
926
|
const heartbeatId = setInterval(() => {
|
|
634
927
|
if (resolved) return;
|
|
635
928
|
const now = Date.now();
|
|
@@ -637,26 +930,23 @@ async function executeTurn(
|
|
|
637
930
|
const lastOut = eventDisplay.lastOutputTime;
|
|
638
931
|
const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
|
|
639
932
|
|
|
640
|
-
// Silence auto-abort
|
|
641
933
|
if (silenceMs >= SILENCE_ABORT) {
|
|
642
934
|
console.log(
|
|
643
|
-
color.dim(`\n
|
|
935
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s, auto-aborting`),
|
|
644
936
|
);
|
|
645
937
|
client.session.abort(sessionId).catch(() => {});
|
|
646
938
|
done({ status: "aborted", aborted: true, quit: false });
|
|
647
939
|
return;
|
|
648
940
|
}
|
|
649
941
|
|
|
650
|
-
// Silence warning
|
|
651
942
|
if (silenceMs >= SILENCE_WARN && !warnedSilence) {
|
|
652
943
|
warnedSilence = true;
|
|
653
944
|
console.log(
|
|
654
|
-
color.dim(`\n
|
|
945
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s (will abort at ${SILENCE_ABORT / 1000}s)`),
|
|
655
946
|
);
|
|
656
947
|
return;
|
|
657
948
|
}
|
|
658
949
|
|
|
659
|
-
// Regular heartbeat (only when silent for >HEARTBEAT_INTERVAL)
|
|
660
950
|
if (silenceMs >= HEARTBEAT_INTERVAL) {
|
|
661
951
|
process.stdout.write(
|
|
662
952
|
color.dim(` [${elapsed}s] `),
|
|
@@ -664,7 +954,6 @@ async function executeTurn(
|
|
|
664
954
|
}
|
|
665
955
|
}, HEARTBEAT_INTERVAL);
|
|
666
956
|
|
|
667
|
-
// Poll input queue for /abort, /quit
|
|
668
957
|
const pollId = setInterval(() => {
|
|
669
958
|
if (!inputQueue.hasMessages()) return;
|
|
670
959
|
const items = inputQueue.drain();
|
|
@@ -683,7 +972,6 @@ async function executeTurn(
|
|
|
683
972
|
showBeadStatus();
|
|
684
973
|
}
|
|
685
974
|
if (item.type === "message" || item.type === "skip") {
|
|
686
|
-
// Re-queue for processing between cycles
|
|
687
975
|
inputQueue.pushBack(item);
|
|
688
976
|
}
|
|
689
977
|
}
|
|
@@ -691,8 +979,91 @@ async function executeTurn(
|
|
|
691
979
|
});
|
|
692
980
|
}
|
|
693
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
|
+
|
|
694
1065
|
function showBeadStatus() {
|
|
695
|
-
console.log(`\n${color.bold("
|
|
1066
|
+
console.log(`\n${color.bold("-- Status --")}`);
|
|
696
1067
|
const ready = run("bd ready") || " (none)";
|
|
697
1068
|
const inProgress = run("bd list --status=in_progress") || " (none)";
|
|
698
1069
|
console.log(` ${color.bold("Ready:")} ${ready}`);
|
|
@@ -700,60 +1071,26 @@ function showBeadStatus() {
|
|
|
700
1071
|
console.log("");
|
|
701
1072
|
}
|
|
702
1073
|
|
|
703
|
-
// ── Supervisor loop
|
|
1074
|
+
// ── Supervisor loop (headless mode — original CLI) ──────────────────────────
|
|
704
1075
|
|
|
705
|
-
async function
|
|
1076
|
+
async function supervisorLoopHeadless(client, opts, inputQueue) {
|
|
706
1077
|
const plannerModel = parseModelSpec(opts.planner);
|
|
707
1078
|
const executorModel = parseModelSpec(opts.executor);
|
|
708
1079
|
|
|
709
|
-
// Create session first (needed for model probing)
|
|
710
1080
|
log.info("Creating session...");
|
|
711
1081
|
const session = await client.session.create({ title: "mneme auto" });
|
|
712
1082
|
const sessionId = session.id;
|
|
713
1083
|
log.ok(`Session: ${sessionId}`);
|
|
714
1084
|
|
|
715
|
-
//
|
|
716
|
-
// opencode models lists theoretical models, but the provider may reject them
|
|
717
|
-
// at runtime (e.g. Copilot plan doesn't include gpt-5.x). Only a real API
|
|
718
|
-
// call reveals this — sync prompt returns the error, async silently fails.
|
|
1085
|
+
// Validate models
|
|
719
1086
|
log.info("Validating models (API probe)...");
|
|
720
|
-
|
|
721
|
-
{ label: "Planner", spec: opts.planner, parsed: plannerModel },
|
|
722
|
-
{ label: "Executor", spec: opts.executor, parsed: executorModel },
|
|
723
|
-
];
|
|
724
|
-
// Deduplicate if both use the same model
|
|
725
|
-
const seen = new Set();
|
|
726
|
-
for (const m of probeModels) {
|
|
727
|
-
if (seen.has(m.spec)) continue;
|
|
728
|
-
seen.add(m.spec);
|
|
729
|
-
try {
|
|
730
|
-
const result = await client.session.prompt(sessionId, {
|
|
731
|
-
parts: [{ type: "text", text: "Say OK" }],
|
|
732
|
-
model: m.parsed,
|
|
733
|
-
});
|
|
734
|
-
// Check if the response contains an error
|
|
735
|
-
const err = result?.info?.error;
|
|
736
|
-
if (err) {
|
|
737
|
-
const msg = err.data?.message || err.name || "unknown error";
|
|
738
|
-
log.fail(`${m.label} model "${m.spec}" rejected by provider: ${msg}`);
|
|
739
|
-
console.log(color.dim(" Tip: run 'opencode models' to see listed models, but not all may be available on your plan."));
|
|
740
|
-
throw new Error(`${m.label} model unavailable: ${msg}`);
|
|
741
|
-
}
|
|
742
|
-
log.ok(`${m.label} model verified: ${m.spec}`);
|
|
743
|
-
} catch (probeErr) {
|
|
744
|
-
if (probeErr.message.includes("unavailable") || probeErr.message.includes("rejected")) {
|
|
745
|
-
throw probeErr; // re-throw our own errors
|
|
746
|
-
}
|
|
747
|
-
// API call itself failed — might be a transient issue
|
|
748
|
-
log.warn(`${m.label} model probe inconclusive: ${probeErr.message}`);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
1087
|
+
await validateModels(client, sessionId, opts, log);
|
|
751
1088
|
|
|
752
1089
|
// Start SSE event display
|
|
753
1090
|
const eventDisplay = createEventDisplay(client);
|
|
754
1091
|
eventDisplay.start().catch(() => {});
|
|
755
1092
|
|
|
756
|
-
// Inject system context
|
|
1093
|
+
// Inject system context
|
|
757
1094
|
const systemContext = buildSystemContext(opts);
|
|
758
1095
|
try {
|
|
759
1096
|
await client.session.prompt(sessionId, {
|
|
@@ -766,10 +1103,50 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
766
1103
|
}
|
|
767
1104
|
|
|
768
1105
|
let cycle = 0;
|
|
769
|
-
|
|
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
|
+
}
|
|
1147
|
+
|
|
770
1148
|
try {
|
|
771
1149
|
while (cycle < opts.maxCycles) {
|
|
772
|
-
// ── Process queued user commands between cycles ──
|
|
773
1150
|
let userFeedback = null;
|
|
774
1151
|
let shouldSkip = false;
|
|
775
1152
|
|
|
@@ -780,40 +1157,30 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
780
1157
|
log.info("User requested quit.");
|
|
781
1158
|
return;
|
|
782
1159
|
}
|
|
783
|
-
if (item.type === "skip")
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (item.type === "status") {
|
|
787
|
-
showBeadStatus();
|
|
788
|
-
}
|
|
789
|
-
if (item.type === "message") {
|
|
790
|
-
userFeedback = item.text;
|
|
791
|
-
}
|
|
1160
|
+
if (item.type === "skip") shouldSkip = true;
|
|
1161
|
+
if (item.type === "status") showBeadStatus();
|
|
1162
|
+
if (item.type === "message") userFeedback = item.text;
|
|
792
1163
|
}
|
|
793
1164
|
}
|
|
794
1165
|
|
|
795
1166
|
if (shouldSkip) {
|
|
796
1167
|
log.info("Skipping current bead...");
|
|
797
|
-
// Fall through to pick next bead
|
|
798
1168
|
}
|
|
799
1169
|
|
|
800
|
-
// ── Pick a task (first cycle or after skip) ──
|
|
801
1170
|
let plannerPrompt = null;
|
|
802
1171
|
|
|
803
1172
|
if (cycle === 0) {
|
|
804
|
-
// First cycle: use goal or pick a bead
|
|
805
1173
|
if (opts.goal) {
|
|
806
1174
|
plannerPrompt = buildPlannerGoalPrompt(opts.goal);
|
|
807
1175
|
} else {
|
|
808
|
-
|
|
1176
|
+
// No explicit goal, but beads exist — pick from beads
|
|
1177
|
+
plannerPrompt = pickBeadForPlanner(log);
|
|
809
1178
|
}
|
|
810
1179
|
} else {
|
|
811
|
-
// Subsequent cycles: planner reviews executor's work
|
|
812
1180
|
plannerPrompt = buildPlannerReviewPrompt(userFeedback);
|
|
813
1181
|
}
|
|
814
1182
|
|
|
815
1183
|
if (!plannerPrompt) {
|
|
816
|
-
// No work available
|
|
817
1184
|
const open = getOpenBeads();
|
|
818
1185
|
if (open.length === 0) {
|
|
819
1186
|
log.ok("All beads completed! Nothing left to do.");
|
|
@@ -827,97 +1194,262 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
827
1194
|
|
|
828
1195
|
cycle++;
|
|
829
1196
|
|
|
830
|
-
//
|
|
1197
|
+
// Planner turn
|
|
831
1198
|
console.log(
|
|
832
|
-
`\n${color.bold(
|
|
1199
|
+
`\n${color.bold(`-- Cycle ${cycle} / Planner`)} ${color.dim(`(${opts.planner})`)} ${color.bold("--------------------")}`,
|
|
833
1200
|
);
|
|
834
1201
|
|
|
835
1202
|
log.info(`Sending prompt to Planner (${opts.planner})...`);
|
|
836
1203
|
eventDisplay.resetTurn("planner");
|
|
837
|
-
const plannerResult = await
|
|
838
|
-
client,
|
|
839
|
-
|
|
840
|
-
plannerPrompt,
|
|
841
|
-
plannerModel,
|
|
842
|
-
eventDisplay,
|
|
843
|
-
inputQueue,
|
|
1204
|
+
const plannerResult = await executeTurnHeadless(
|
|
1205
|
+
client, sessionId, plannerPrompt, plannerModel,
|
|
1206
|
+
eventDisplay, inputQueue,
|
|
844
1207
|
);
|
|
845
|
-
console.log("");
|
|
1208
|
+
console.log("");
|
|
846
1209
|
|
|
847
|
-
if (plannerResult.quit) {
|
|
848
|
-
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
if (plannerResult.aborted) {
|
|
852
|
-
log.info("Planner turn aborted.");
|
|
853
|
-
continue;
|
|
854
|
-
}
|
|
1210
|
+
if (plannerResult.quit) { log.info("User requested quit."); return; }
|
|
1211
|
+
if (plannerResult.aborted) { log.info("Planner turn aborted."); continue; }
|
|
855
1212
|
|
|
856
|
-
// Check
|
|
857
|
-
// from the session to see the planner's output
|
|
1213
|
+
// Check TASK_DONE
|
|
858
1214
|
let plannerSaidDone = false;
|
|
859
1215
|
try {
|
|
860
1216
|
const messages = await client.session.messages(sessionId);
|
|
861
1217
|
if (messages && messages.length > 0) {
|
|
862
1218
|
const lastMsg = messages[messages.length - 1];
|
|
863
1219
|
const text = extractMessageText(lastMsg);
|
|
864
|
-
if (text.includes("TASK_DONE"))
|
|
865
|
-
plannerSaidDone = true;
|
|
866
|
-
}
|
|
1220
|
+
if (text.includes("TASK_DONE")) plannerSaidDone = true;
|
|
867
1221
|
}
|
|
868
|
-
} catch {
|
|
869
|
-
// Can't check, proceed with executor turn
|
|
870
|
-
}
|
|
1222
|
+
} catch { /* proceed */ }
|
|
871
1223
|
|
|
872
1224
|
if (plannerSaidDone) {
|
|
873
1225
|
log.ok("Planner declared task complete.");
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
if (!nextBead) {
|
|
878
|
-
log.ok("No more tasks. Finished.");
|
|
879
|
-
break;
|
|
880
|
-
}
|
|
881
|
-
// 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; }
|
|
882
1229
|
continue;
|
|
883
1230
|
}
|
|
884
1231
|
|
|
885
|
-
//
|
|
1232
|
+
// Executor turn
|
|
886
1233
|
console.log(
|
|
887
|
-
`\n${color.bold(
|
|
1234
|
+
`\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
|
|
888
1235
|
);
|
|
889
1236
|
|
|
890
1237
|
const executorPrompt = buildExecutorPrompt();
|
|
891
1238
|
log.info(`Sending prompt to Executor (${opts.executor})...`);
|
|
892
1239
|
eventDisplay.resetTurn("executor");
|
|
893
|
-
const executorResult = await
|
|
894
|
-
client,
|
|
895
|
-
|
|
896
|
-
executorPrompt,
|
|
897
|
-
executorModel,
|
|
898
|
-
eventDisplay,
|
|
899
|
-
inputQueue,
|
|
1240
|
+
const executorResult = await executeTurnHeadless(
|
|
1241
|
+
client, sessionId, executorPrompt, executorModel,
|
|
1242
|
+
eventDisplay, inputQueue,
|
|
900
1243
|
);
|
|
901
|
-
console.log("");
|
|
1244
|
+
console.log("");
|
|
902
1245
|
|
|
903
|
-
if (executorResult.quit) {
|
|
904
|
-
|
|
905
|
-
|
|
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);
|
|
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;
|
|
906
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
|
+
|
|
907
1403
|
if (executorResult.aborted) {
|
|
908
|
-
|
|
909
|
-
// Planner will review on next cycle
|
|
1404
|
+
dlog.warn("Executor turn aborted.");
|
|
910
1405
|
}
|
|
911
1406
|
|
|
912
|
-
// Small pause between cycles
|
|
913
1407
|
await sleep(1000);
|
|
914
1408
|
}
|
|
915
1409
|
|
|
916
1410
|
if (cycle >= opts.maxCycles) {
|
|
917
|
-
|
|
1411
|
+
dlog.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
|
|
918
1412
|
}
|
|
919
1413
|
} finally {
|
|
920
|
-
|
|
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
|
+
}
|
|
921
1453
|
}
|
|
922
1454
|
}
|
|
923
1455
|
|
|
@@ -925,27 +1457,24 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
925
1457
|
* Try to pick a bead and return a planner prompt for it.
|
|
926
1458
|
* Returns null if no beads available.
|
|
927
1459
|
*/
|
|
928
|
-
function pickBeadForPlanner() {
|
|
929
|
-
// Check in-progress first
|
|
1460
|
+
function pickBeadForPlanner(logger) {
|
|
930
1461
|
const inProgress = getInProgressBeads();
|
|
931
1462
|
if (inProgress.length > 0) {
|
|
932
1463
|
const beadId = extractBeadId(inProgress[0]);
|
|
933
1464
|
if (beadId) {
|
|
934
|
-
|
|
1465
|
+
logger.info(`Resuming: ${beadId}`);
|
|
935
1466
|
return buildPlannerBeadPrompt(beadId);
|
|
936
1467
|
}
|
|
937
1468
|
}
|
|
938
1469
|
|
|
939
|
-
// Check ready beads
|
|
940
1470
|
const ready = getReadyBeads();
|
|
941
1471
|
if (ready.length === 0) return null;
|
|
942
1472
|
|
|
943
1473
|
const beadId = extractBeadId(ready[0]);
|
|
944
1474
|
if (!beadId) return null;
|
|
945
1475
|
|
|
946
|
-
// Claim it
|
|
947
1476
|
run(`bd update ${beadId} --status=in_progress`);
|
|
948
|
-
|
|
1477
|
+
logger.info(`Picked: ${beadId}`);
|
|
949
1478
|
return buildPlannerBeadPrompt(beadId);
|
|
950
1479
|
}
|
|
951
1480
|
|
|
@@ -980,6 +1509,231 @@ async function waitForInput(inputQueue) {
|
|
|
980
1509
|
}
|
|
981
1510
|
}
|
|
982
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
|
+
|
|
983
1737
|
// ── Main entry point ────────────────────────────────────────────────────────
|
|
984
1738
|
|
|
985
1739
|
export async function auto(argv) {
|
|
@@ -992,42 +1746,131 @@ export async function auto(argv) {
|
|
|
992
1746
|
process.exit(1);
|
|
993
1747
|
}
|
|
994
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) {
|
|
995
1851
|
console.log(
|
|
996
|
-
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor\n`,
|
|
997
|
-
);
|
|
998
|
-
console.log(
|
|
999
|
-
` ${color.bold("Planner:")} ${opts.planner}`,
|
|
1000
|
-
);
|
|
1001
|
-
console.log(
|
|
1002
|
-
` ${color.bold("Executor:")} ${opts.executor}\n`,
|
|
1852
|
+
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor (headless)\n`,
|
|
1003
1853
|
);
|
|
1854
|
+
console.log(` ${color.bold("Planner:")} ${opts.planner}`);
|
|
1855
|
+
console.log(` ${color.bold("Executor:")} ${opts.executor}\n`);
|
|
1004
1856
|
console.log(color.dim("Commands while running:"));
|
|
1005
|
-
console.log(
|
|
1006
|
-
|
|
1007
|
-
);
|
|
1008
|
-
console.log(color.dim(" /
|
|
1009
|
-
console.log(color.dim(" /
|
|
1010
|
-
console.log(color.dim(" /abort → abort current turn"));
|
|
1011
|
-
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"));
|
|
1012
1862
|
|
|
1013
|
-
// Start or attach to server
|
|
1014
1863
|
let serverCtx;
|
|
1015
1864
|
try {
|
|
1016
1865
|
if (opts.attach) {
|
|
1017
1866
|
serverCtx = await attachOpencodeServer(opts.attach);
|
|
1018
|
-
log.ok(
|
|
1019
|
-
`Attached to ${serverCtx.url} (v${serverCtx.version})`,
|
|
1020
|
-
);
|
|
1867
|
+
log.ok(`Attached to ${serverCtx.url} (v${serverCtx.version})`);
|
|
1021
1868
|
} else {
|
|
1022
1869
|
serverCtx = await startOpencodeServer({ port: opts.port });
|
|
1023
1870
|
if (serverCtx.alreadyRunning) {
|
|
1024
|
-
log.ok(
|
|
1025
|
-
`Server already running at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1026
|
-
);
|
|
1871
|
+
log.ok(`Server already running at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1027
1872
|
} else {
|
|
1028
|
-
log.ok(
|
|
1029
|
-
`Server started at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1030
|
-
);
|
|
1873
|
+
log.ok(`Server started at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1031
1874
|
}
|
|
1032
1875
|
}
|
|
1033
1876
|
} catch (err) {
|
|
@@ -1035,18 +1878,15 @@ export async function auto(argv) {
|
|
|
1035
1878
|
process.exit(1);
|
|
1036
1879
|
}
|
|
1037
1880
|
|
|
1038
|
-
// Start input queue
|
|
1039
1881
|
const inputQueue = createInputQueue();
|
|
1040
1882
|
inputQueue.start();
|
|
1041
1883
|
|
|
1042
|
-
// Run supervisor
|
|
1043
1884
|
try {
|
|
1044
|
-
await
|
|
1885
|
+
await supervisorLoopHeadless(serverCtx.client, opts, inputQueue);
|
|
1045
1886
|
} catch (err) {
|
|
1046
1887
|
log.fail(`Supervisor error: ${err.message}`);
|
|
1047
1888
|
} finally {
|
|
1048
1889
|
inputQueue.stop();
|
|
1049
|
-
// Only kill server if WE started it
|
|
1050
1890
|
if (serverCtx.serverProcess) {
|
|
1051
1891
|
log.info("Shutting down server...");
|
|
1052
1892
|
serverCtx.serverProcess.kill("SIGTERM");
|
|
@@ -1055,6 +1895,141 @@ export async function auto(argv) {
|
|
|
1055
1895
|
}
|
|
1056
1896
|
}
|
|
1057
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
|
+
|
|
1058
2033
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1059
2034
|
|
|
1060
2035
|
function sleep(ms) {
|