@xqli02/mneme 0.1.11 → 0.1.13
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 +1155 -209
- package/src/opencode-client.mjs +9 -5
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,31 +500,33 @@ 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;
|
|
519
|
+
let abortController = null;
|
|
395
520
|
|
|
396
|
-
// Track incremental text and tool display state
|
|
397
521
|
const printedTextLengths = new Map();
|
|
398
522
|
const displayedToolStates = new Map();
|
|
399
|
-
const deltaParts = new Set();
|
|
523
|
+
const deltaParts = new Set();
|
|
400
524
|
|
|
401
525
|
async function start() {
|
|
402
526
|
running = true;
|
|
527
|
+
abortController = new AbortController();
|
|
403
528
|
try {
|
|
404
|
-
const iterator = await client.events.subscribe();
|
|
529
|
+
const iterator = await client.events.subscribe({ signal: abortController.signal });
|
|
405
530
|
connected = true;
|
|
406
531
|
hasReceivedAny = false;
|
|
407
532
|
log.ok("SSE event stream connected");
|
|
@@ -412,11 +537,10 @@ function createEventDisplay(client) {
|
|
|
412
537
|
}
|
|
413
538
|
} catch (err) {
|
|
414
539
|
connected = false;
|
|
415
|
-
if (running) {
|
|
540
|
+
if (running && err.name !== "AbortError") {
|
|
416
541
|
console.error(
|
|
417
542
|
color.dim(`\n [events] Stream error: ${err.message}`),
|
|
418
543
|
);
|
|
419
|
-
// Try to reconnect after a brief delay
|
|
420
544
|
await sleep(2000);
|
|
421
545
|
if (running) {
|
|
422
546
|
log.info("Reconnecting SSE...");
|
|
@@ -431,7 +555,6 @@ function createEventDisplay(client) {
|
|
|
431
555
|
const props = event.properties || {};
|
|
432
556
|
|
|
433
557
|
switch (type) {
|
|
434
|
-
// ── Incremental text deltas (streaming) ──
|
|
435
558
|
case "message.part.delta": {
|
|
436
559
|
const partId = props.partID || props.partId;
|
|
437
560
|
if (partId) deltaParts.add(partId);
|
|
@@ -442,7 +565,6 @@ function createEventDisplay(client) {
|
|
|
442
565
|
break;
|
|
443
566
|
}
|
|
444
567
|
|
|
445
|
-
// ── Part snapshots (full text at end, tool states) ──
|
|
446
568
|
case "message.part.updated": {
|
|
447
569
|
if (!props.part) break;
|
|
448
570
|
const part = props.part;
|
|
@@ -455,12 +577,9 @@ function createEventDisplay(client) {
|
|
|
455
577
|
displayToolPart(part, partId);
|
|
456
578
|
lastOutputTime = Date.now();
|
|
457
579
|
}
|
|
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
580
|
if (part.type === "text" && part.text) {
|
|
461
581
|
const prev = printedTextLengths.get(partId) || 0;
|
|
462
582
|
if (prev === 0 && !deltaParts.has(partId)) {
|
|
463
|
-
// We never saw deltas for this part — print the full text
|
|
464
583
|
process.stdout.write(part.text);
|
|
465
584
|
lastOutputTime = Date.now();
|
|
466
585
|
}
|
|
@@ -469,7 +588,6 @@ function createEventDisplay(client) {
|
|
|
469
588
|
break;
|
|
470
589
|
}
|
|
471
590
|
|
|
472
|
-
// ── Session status (busy → idle/completed) ──
|
|
473
591
|
case "session.status": {
|
|
474
592
|
const status = props.status?.type || props.status;
|
|
475
593
|
if (status && status !== "busy" && status !== "pending") {
|
|
@@ -481,7 +599,6 @@ function createEventDisplay(client) {
|
|
|
481
599
|
break;
|
|
482
600
|
}
|
|
483
601
|
|
|
484
|
-
// ── Session updated (metadata; also check for status) ──
|
|
485
602
|
case "session.updated": {
|
|
486
603
|
const info = props.info || props.session || {};
|
|
487
604
|
const status = info.status?.type || info.status;
|
|
@@ -494,12 +611,9 @@ function createEventDisplay(client) {
|
|
|
494
611
|
break;
|
|
495
612
|
}
|
|
496
613
|
|
|
497
|
-
// ── Message-level finish detection ──
|
|
498
614
|
case "message.updated": {
|
|
499
615
|
const info = props.info || {};
|
|
500
616
|
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
617
|
lastOutputTime = Date.now();
|
|
504
618
|
}
|
|
505
619
|
break;
|
|
@@ -519,7 +633,7 @@ function createEventDisplay(client) {
|
|
|
519
633
|
if (state === "call" && lastState !== "call") {
|
|
520
634
|
const argsStr = summarizeArgs(inv.args);
|
|
521
635
|
console.log(
|
|
522
|
-
`\n${color.bold(`
|
|
636
|
+
`\n${color.bold(` * ${toolName}`)}${argsStr ? color.dim(` ${argsStr}`) : ""}`,
|
|
523
637
|
);
|
|
524
638
|
displayedToolStates.set(partId, "call");
|
|
525
639
|
} else if (state === "result" && lastState !== "result") {
|
|
@@ -560,10 +674,6 @@ function createEventDisplay(client) {
|
|
|
560
674
|
return str.length > 120 ? str.slice(0, 120) + "..." : str;
|
|
561
675
|
}
|
|
562
676
|
|
|
563
|
-
/**
|
|
564
|
-
* Wait for the current turn to complete via SSE.
|
|
565
|
-
* Returns a promise that resolves with the session status.
|
|
566
|
-
*/
|
|
567
677
|
function waitForTurnEnd() {
|
|
568
678
|
return new Promise((resolve) => {
|
|
569
679
|
turnResolve = resolve;
|
|
@@ -579,6 +689,164 @@ function createEventDisplay(client) {
|
|
|
579
689
|
|
|
580
690
|
function stop() {
|
|
581
691
|
running = false;
|
|
692
|
+
if (abortController) {
|
|
693
|
+
abortController.abort();
|
|
694
|
+
abortController = null;
|
|
695
|
+
}
|
|
696
|
+
if (turnResolve) {
|
|
697
|
+
turnResolve("stopped");
|
|
698
|
+
turnResolve = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
start,
|
|
704
|
+
stop,
|
|
705
|
+
waitForTurnEnd,
|
|
706
|
+
resetTurn,
|
|
707
|
+
get lastOutputTime() { return lastOutputTime; },
|
|
708
|
+
get connected() { return connected; },
|
|
709
|
+
get hasReceivedAny() { return hasReceivedAny; },
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Daemon event monitor (silent, for daemon mode) ──────────────────────────
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* SSE listener for daemon mode — tracks turn completion and detects
|
|
717
|
+
* user-initiated messages, but produces NO stdout output.
|
|
718
|
+
* All logging goes to file.
|
|
719
|
+
*/
|
|
720
|
+
function createDaemonEventMonitor(client, dlog) {
|
|
721
|
+
let running = false;
|
|
722
|
+
let connected = false;
|
|
723
|
+
let turnResolve = null;
|
|
724
|
+
let lastOutputTime = 0;
|
|
725
|
+
let hasReceivedAny = false;
|
|
726
|
+
let abortController = null;
|
|
727
|
+
|
|
728
|
+
// Track known prompt texts we sent — to distinguish user messages
|
|
729
|
+
const knownPromptTexts = new Set();
|
|
730
|
+
|
|
731
|
+
// Callback for user message detection
|
|
732
|
+
let onUserMessage = null;
|
|
733
|
+
|
|
734
|
+
async function start() {
|
|
735
|
+
running = true;
|
|
736
|
+
abortController = new AbortController();
|
|
737
|
+
try {
|
|
738
|
+
const iterator = await client.events.subscribe({ signal: abortController.signal });
|
|
739
|
+
connected = true;
|
|
740
|
+
hasReceivedAny = false;
|
|
741
|
+
dlog.ok("SSE event stream connected (daemon)");
|
|
742
|
+
for await (const event of iterator) {
|
|
743
|
+
if (!running) break;
|
|
744
|
+
if (!hasReceivedAny) hasReceivedAny = true;
|
|
745
|
+
handleEvent(event);
|
|
746
|
+
}
|
|
747
|
+
} catch (err) {
|
|
748
|
+
connected = false;
|
|
749
|
+
if (running && err.name !== "AbortError") {
|
|
750
|
+
dlog.warn(`SSE stream error: ${err.message}`);
|
|
751
|
+
await sleep(2000);
|
|
752
|
+
if (running) {
|
|
753
|
+
dlog.info("Reconnecting SSE...");
|
|
754
|
+
start().catch(() => {});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function handleEvent(event) {
|
|
761
|
+
const type = event.type || "";
|
|
762
|
+
const props = event.properties || {};
|
|
763
|
+
|
|
764
|
+
switch (type) {
|
|
765
|
+
case "message.part.delta": {
|
|
766
|
+
// Just track that output is happening
|
|
767
|
+
if (props.field === "text" && props.delta) {
|
|
768
|
+
lastOutputTime = Date.now();
|
|
769
|
+
}
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
case "message.part.updated": {
|
|
774
|
+
if (props.part) {
|
|
775
|
+
lastOutputTime = Date.now();
|
|
776
|
+
}
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
case "session.status": {
|
|
781
|
+
const status = props.status?.type || props.status;
|
|
782
|
+
if (status && status !== "busy" && status !== "pending") {
|
|
783
|
+
if (turnResolve) {
|
|
784
|
+
turnResolve(status);
|
|
785
|
+
turnResolve = null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
case "session.updated": {
|
|
792
|
+
const info = props.info || props.session || {};
|
|
793
|
+
const status = info.status?.type || info.status;
|
|
794
|
+
if (status && status !== "busy" && status !== "running" && status !== "pending") {
|
|
795
|
+
if (turnResolve) {
|
|
796
|
+
turnResolve(status);
|
|
797
|
+
turnResolve = null;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
case "message.updated": {
|
|
804
|
+
const info = props.info || {};
|
|
805
|
+
// Detect user-initiated messages: role === "user" with text we didn't send
|
|
806
|
+
const role = info.role;
|
|
807
|
+
if (role === "user" && info.parts) {
|
|
808
|
+
const text = info.parts
|
|
809
|
+
.filter((p) => p.type === "text")
|
|
810
|
+
.map((p) => p.text || "")
|
|
811
|
+
.join("\n")
|
|
812
|
+
.trim();
|
|
813
|
+
if (text && !knownPromptTexts.has(text)) {
|
|
814
|
+
dlog.info(`User message detected: "${text.slice(0, 80)}..."`);
|
|
815
|
+
if (onUserMessage) onUserMessage(text);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (info.finish && info.finish !== "pending") {
|
|
819
|
+
lastOutputTime = Date.now();
|
|
820
|
+
}
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
default:
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function waitForTurnEnd() {
|
|
830
|
+
return new Promise((resolve) => {
|
|
831
|
+
turnResolve = resolve;
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function resetTurn() {
|
|
836
|
+
// Nothing visual to reset in daemon mode
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function registerPrompt(text) {
|
|
840
|
+
// Register a prompt text so we can distinguish our prompts from user's
|
|
841
|
+
knownPromptTexts.add(text.trim());
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function stop() {
|
|
845
|
+
running = false;
|
|
846
|
+
if (abortController) {
|
|
847
|
+
abortController.abort();
|
|
848
|
+
abortController = null;
|
|
849
|
+
}
|
|
582
850
|
if (turnResolve) {
|
|
583
851
|
turnResolve("stopped");
|
|
584
852
|
turnResolve = null;
|
|
@@ -590,23 +858,25 @@ function createEventDisplay(client) {
|
|
|
590
858
|
stop,
|
|
591
859
|
waitForTurnEnd,
|
|
592
860
|
resetTurn,
|
|
861
|
+
registerPrompt,
|
|
862
|
+
set onUserMessage(fn) { onUserMessage = fn; },
|
|
863
|
+
get onUserMessage() { return onUserMessage; },
|
|
593
864
|
get lastOutputTime() { return lastOutputTime; },
|
|
594
865
|
get connected() { return connected; },
|
|
595
866
|
get hasReceivedAny() { return hasReceivedAny; },
|
|
596
867
|
};
|
|
597
868
|
}
|
|
598
869
|
|
|
599
|
-
// ── Turn execution
|
|
870
|
+
// ── Turn execution (headless mode) ──────────────────────────────────────────
|
|
600
871
|
|
|
601
872
|
/**
|
|
602
|
-
* Send a message and wait for the turn to complete.
|
|
873
|
+
* Send a message and wait for the turn to complete (headless mode).
|
|
603
874
|
* Handles /abort and /quit from input queue during execution.
|
|
604
875
|
* Prints heartbeat every 15s when no output is flowing.
|
|
605
|
-
* Warns at 30s of silence, auto-aborts at 120s of silence.
|
|
606
876
|
*
|
|
607
877
|
* @returns {{ status: string, aborted: boolean, quit: boolean }}
|
|
608
878
|
*/
|
|
609
|
-
async function
|
|
879
|
+
async function executeTurnHeadless(
|
|
610
880
|
client,
|
|
611
881
|
sessionId,
|
|
612
882
|
prompt,
|
|
@@ -623,18 +893,14 @@ async function executeTurn(
|
|
|
623
893
|
body.model = modelSpec;
|
|
624
894
|
}
|
|
625
895
|
|
|
626
|
-
// Send async — returns immediately
|
|
627
896
|
await client.session.promptAsync(sessionId, body);
|
|
628
897
|
|
|
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.
|
|
898
|
+
// Quick check for immediate model errors
|
|
632
899
|
await sleep(2000);
|
|
633
900
|
try {
|
|
634
901
|
const sessions = await client.session.list();
|
|
635
902
|
const s = sessions?.find?.((ss) => ss.id === sessionId);
|
|
636
903
|
if (s && s.status && s.status !== "running" && s.status !== "pending") {
|
|
637
|
-
// Session already finished — likely an immediate error
|
|
638
904
|
const msgs = await client.session.messages(sessionId);
|
|
639
905
|
const lastMsg = msgs?.[msgs.length - 1];
|
|
640
906
|
const errInfo = lastMsg?.info?.error;
|
|
@@ -645,15 +911,14 @@ async function executeTurn(
|
|
|
645
911
|
}
|
|
646
912
|
}
|
|
647
913
|
} catch {
|
|
648
|
-
// Ignore probe failures
|
|
914
|
+
// Ignore probe failures
|
|
649
915
|
}
|
|
650
916
|
|
|
651
|
-
const HEARTBEAT_INTERVAL = 15_000;
|
|
652
|
-
const SILENCE_WARN = 30_000;
|
|
653
|
-
const SILENCE_ABORT = 120_000;
|
|
917
|
+
const HEARTBEAT_INTERVAL = 15_000;
|
|
918
|
+
const SILENCE_WARN = 30_000;
|
|
919
|
+
const SILENCE_ABORT = 120_000;
|
|
654
920
|
const turnStartTime = Date.now();
|
|
655
921
|
|
|
656
|
-
// Race: SSE turn completion vs user commands vs silence timeout
|
|
657
922
|
return new Promise((resolve) => {
|
|
658
923
|
let resolved = false;
|
|
659
924
|
let warnedSilence = false;
|
|
@@ -666,12 +931,10 @@ async function executeTurn(
|
|
|
666
931
|
resolve(result);
|
|
667
932
|
};
|
|
668
933
|
|
|
669
|
-
// SSE completion
|
|
670
934
|
eventDisplay.waitForTurnEnd().then((status) => {
|
|
671
935
|
done({ status, aborted: false, quit: false });
|
|
672
936
|
});
|
|
673
937
|
|
|
674
|
-
// Heartbeat: show elapsed time when no output is flowing
|
|
675
938
|
const heartbeatId = setInterval(() => {
|
|
676
939
|
if (resolved) return;
|
|
677
940
|
const now = Date.now();
|
|
@@ -679,26 +942,23 @@ async function executeTurn(
|
|
|
679
942
|
const lastOut = eventDisplay.lastOutputTime;
|
|
680
943
|
const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
|
|
681
944
|
|
|
682
|
-
// Silence auto-abort
|
|
683
945
|
if (silenceMs >= SILENCE_ABORT) {
|
|
684
946
|
console.log(
|
|
685
|
-
color.dim(`\n
|
|
947
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s, auto-aborting`),
|
|
686
948
|
);
|
|
687
949
|
client.session.abort(sessionId).catch(() => {});
|
|
688
950
|
done({ status: "aborted", aborted: true, quit: false });
|
|
689
951
|
return;
|
|
690
952
|
}
|
|
691
953
|
|
|
692
|
-
// Silence warning
|
|
693
954
|
if (silenceMs >= SILENCE_WARN && !warnedSilence) {
|
|
694
955
|
warnedSilence = true;
|
|
695
956
|
console.log(
|
|
696
|
-
color.dim(`\n
|
|
957
|
+
color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s (will abort at ${SILENCE_ABORT / 1000}s)`),
|
|
697
958
|
);
|
|
698
959
|
return;
|
|
699
960
|
}
|
|
700
961
|
|
|
701
|
-
// Regular heartbeat (only when silent for >HEARTBEAT_INTERVAL)
|
|
702
962
|
if (silenceMs >= HEARTBEAT_INTERVAL) {
|
|
703
963
|
process.stdout.write(
|
|
704
964
|
color.dim(` [${elapsed}s] `),
|
|
@@ -706,7 +966,6 @@ async function executeTurn(
|
|
|
706
966
|
}
|
|
707
967
|
}, HEARTBEAT_INTERVAL);
|
|
708
968
|
|
|
709
|
-
// Poll input queue for /abort, /quit
|
|
710
969
|
const pollId = setInterval(() => {
|
|
711
970
|
if (!inputQueue.hasMessages()) return;
|
|
712
971
|
const items = inputQueue.drain();
|
|
@@ -725,7 +984,6 @@ async function executeTurn(
|
|
|
725
984
|
showBeadStatus();
|
|
726
985
|
}
|
|
727
986
|
if (item.type === "message" || item.type === "skip") {
|
|
728
|
-
// Re-queue for processing between cycles
|
|
729
987
|
inputQueue.pushBack(item);
|
|
730
988
|
}
|
|
731
989
|
}
|
|
@@ -733,8 +991,91 @@ async function executeTurn(
|
|
|
733
991
|
});
|
|
734
992
|
}
|
|
735
993
|
|
|
994
|
+
// ── Turn execution (daemon mode) ────────────────────────────────────────────
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Send a message and wait for the turn to complete (daemon mode).
|
|
998
|
+
* No stdout output — all logging to file. No input queue.
|
|
999
|
+
*
|
|
1000
|
+
* @returns {{ status: string, aborted: boolean }}
|
|
1001
|
+
*/
|
|
1002
|
+
async function executeTurnDaemon(
|
|
1003
|
+
client,
|
|
1004
|
+
sessionId,
|
|
1005
|
+
prompt,
|
|
1006
|
+
modelSpec,
|
|
1007
|
+
monitor,
|
|
1008
|
+
dlog,
|
|
1009
|
+
) {
|
|
1010
|
+
monitor.resetTurn();
|
|
1011
|
+
monitor.registerPrompt(prompt);
|
|
1012
|
+
|
|
1013
|
+
const body = {
|
|
1014
|
+
parts: [{ type: "text", text: prompt }],
|
|
1015
|
+
};
|
|
1016
|
+
if (modelSpec) {
|
|
1017
|
+
body.model = modelSpec;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
await client.session.promptAsync(sessionId, body);
|
|
1021
|
+
|
|
1022
|
+
// Quick check for immediate model errors
|
|
1023
|
+
await sleep(2000);
|
|
1024
|
+
try {
|
|
1025
|
+
const sessions = await client.session.list();
|
|
1026
|
+
const s = sessions?.find?.((ss) => ss.id === sessionId);
|
|
1027
|
+
if (s && s.status && s.status !== "running" && s.status !== "pending") {
|
|
1028
|
+
const msgs = await client.session.messages(sessionId);
|
|
1029
|
+
const lastMsg = msgs?.[msgs.length - 1];
|
|
1030
|
+
const errInfo = lastMsg?.info?.error;
|
|
1031
|
+
if (errInfo) {
|
|
1032
|
+
const errMsg = errInfo.data?.message || errInfo.name || "unknown";
|
|
1033
|
+
dlog.fail(`Model error: ${errMsg}`);
|
|
1034
|
+
return { status: "error", aborted: true };
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} catch {
|
|
1038
|
+
// Ignore probe failures
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const SILENCE_ABORT = 120_000;
|
|
1042
|
+
const turnStartTime = Date.now();
|
|
1043
|
+
|
|
1044
|
+
return new Promise((resolve) => {
|
|
1045
|
+
let resolved = false;
|
|
1046
|
+
|
|
1047
|
+
const done = (result) => {
|
|
1048
|
+
if (resolved) return;
|
|
1049
|
+
resolved = true;
|
|
1050
|
+
clearInterval(silenceCheckId);
|
|
1051
|
+
resolve(result);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
monitor.waitForTurnEnd().then((status) => {
|
|
1055
|
+
done({ status, aborted: false });
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// Check for silence timeouts
|
|
1059
|
+
const silenceCheckId = setInterval(() => {
|
|
1060
|
+
if (resolved) return;
|
|
1061
|
+
const now = Date.now();
|
|
1062
|
+
const elapsed = Math.round((now - turnStartTime) / 1000);
|
|
1063
|
+
const lastOut = monitor.lastOutputTime;
|
|
1064
|
+
const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
|
|
1065
|
+
|
|
1066
|
+
if (silenceMs >= SILENCE_ABORT) {
|
|
1067
|
+
dlog.warn(`${elapsed}s elapsed, no output for ${Math.round(silenceMs / 1000)}s — auto-aborting`);
|
|
1068
|
+
client.session.abort(sessionId).catch(() => {});
|
|
1069
|
+
done({ status: "aborted", aborted: true });
|
|
1070
|
+
}
|
|
1071
|
+
}, 15_000);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ── Status display (headless only) ─────────────────────────────────────────
|
|
1076
|
+
|
|
736
1077
|
function showBeadStatus() {
|
|
737
|
-
console.log(`\n${color.bold("
|
|
1078
|
+
console.log(`\n${color.bold("-- Status --")}`);
|
|
738
1079
|
const ready = run("bd ready") || " (none)";
|
|
739
1080
|
const inProgress = run("bd list --status=in_progress") || " (none)";
|
|
740
1081
|
console.log(` ${color.bold("Ready:")} ${ready}`);
|
|
@@ -742,60 +1083,26 @@ function showBeadStatus() {
|
|
|
742
1083
|
console.log("");
|
|
743
1084
|
}
|
|
744
1085
|
|
|
745
|
-
// ── Supervisor loop
|
|
1086
|
+
// ── Supervisor loop (headless mode — original CLI) ──────────────────────────
|
|
746
1087
|
|
|
747
|
-
async function
|
|
1088
|
+
async function supervisorLoopHeadless(client, opts, inputQueue) {
|
|
748
1089
|
const plannerModel = parseModelSpec(opts.planner);
|
|
749
1090
|
const executorModel = parseModelSpec(opts.executor);
|
|
750
1091
|
|
|
751
|
-
// Create session first (needed for model probing)
|
|
752
1092
|
log.info("Creating session...");
|
|
753
1093
|
const session = await client.session.create({ title: "mneme auto" });
|
|
754
1094
|
const sessionId = session.id;
|
|
755
1095
|
log.ok(`Session: ${sessionId}`);
|
|
756
1096
|
|
|
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.
|
|
1097
|
+
// Validate models
|
|
761
1098
|
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
|
-
}
|
|
1099
|
+
await validateModels(client, sessionId, opts, log);
|
|
793
1100
|
|
|
794
1101
|
// Start SSE event display
|
|
795
1102
|
const eventDisplay = createEventDisplay(client);
|
|
796
1103
|
eventDisplay.start().catch(() => {});
|
|
797
1104
|
|
|
798
|
-
// Inject system context
|
|
1105
|
+
// Inject system context
|
|
799
1106
|
const systemContext = buildSystemContext(opts);
|
|
800
1107
|
try {
|
|
801
1108
|
await client.session.prompt(sessionId, {
|
|
@@ -808,10 +1115,50 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
808
1115
|
}
|
|
809
1116
|
|
|
810
1117
|
let cycle = 0;
|
|
1118
|
+
let startMode = "beads"; // default: pick from beads
|
|
1119
|
+
|
|
1120
|
+
// If no explicit goal, ask user whether to pick from beads or discuss a plan.
|
|
1121
|
+
if (!opts.goal) {
|
|
1122
|
+
const choice = await askStartModeHeadless(inputQueue);
|
|
1123
|
+
if (choice === "quit") {
|
|
1124
|
+
eventDisplay.stop();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
startMode = choice; // "beads" or "discuss"
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// If user chose to discuss, enter goal discussion before main loop.
|
|
1131
|
+
if (startMode === "discuss") {
|
|
1132
|
+
const goResult = await goalDiscussionHeadless(
|
|
1133
|
+
client, sessionId, plannerModel, eventDisplay, inputQueue,
|
|
1134
|
+
);
|
|
1135
|
+
if (!goResult) {
|
|
1136
|
+
log.info("User quit during goal discussion.");
|
|
1137
|
+
eventDisplay.stop();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
// Goal discussion complete — planner finalize prompt has produced
|
|
1141
|
+
// the first executor instruction. Jump straight to executor turn.
|
|
1142
|
+
cycle++;
|
|
1143
|
+
console.log(
|
|
1144
|
+
`\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
|
|
1145
|
+
);
|
|
1146
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1147
|
+
log.info(`Sending prompt to Executor (${opts.executor})...`);
|
|
1148
|
+
eventDisplay.resetTurn("executor");
|
|
1149
|
+
const executorResult = await executeTurnHeadless(
|
|
1150
|
+
client, sessionId, executorPrompt, executorModel,
|
|
1151
|
+
eventDisplay, inputQueue,
|
|
1152
|
+
);
|
|
1153
|
+
console.log("");
|
|
1154
|
+
if (executorResult.quit) { log.info("User requested quit."); eventDisplay.stop(); return; }
|
|
1155
|
+
if (executorResult.aborted) { log.info("Executor turn aborted."); }
|
|
1156
|
+
await sleep(1000);
|
|
1157
|
+
// Fall through to the main loop (cycle is now 1, will get planner review)
|
|
1158
|
+
}
|
|
811
1159
|
|
|
812
1160
|
try {
|
|
813
1161
|
while (cycle < opts.maxCycles) {
|
|
814
|
-
// ── Process queued user commands between cycles ──
|
|
815
1162
|
let userFeedback = null;
|
|
816
1163
|
let shouldSkip = false;
|
|
817
1164
|
|
|
@@ -822,40 +1169,30 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
822
1169
|
log.info("User requested quit.");
|
|
823
1170
|
return;
|
|
824
1171
|
}
|
|
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
|
-
}
|
|
1172
|
+
if (item.type === "skip") shouldSkip = true;
|
|
1173
|
+
if (item.type === "status") showBeadStatus();
|
|
1174
|
+
if (item.type === "message") userFeedback = item.text;
|
|
834
1175
|
}
|
|
835
1176
|
}
|
|
836
1177
|
|
|
837
1178
|
if (shouldSkip) {
|
|
838
1179
|
log.info("Skipping current bead...");
|
|
839
|
-
// Fall through to pick next bead
|
|
840
1180
|
}
|
|
841
1181
|
|
|
842
|
-
// ── Pick a task (first cycle or after skip) ──
|
|
843
1182
|
let plannerPrompt = null;
|
|
844
1183
|
|
|
845
1184
|
if (cycle === 0) {
|
|
846
|
-
// First cycle: use goal or pick a bead
|
|
847
1185
|
if (opts.goal) {
|
|
848
1186
|
plannerPrompt = buildPlannerGoalPrompt(opts.goal);
|
|
849
1187
|
} else {
|
|
850
|
-
|
|
1188
|
+
// No explicit goal, but beads exist — pick from beads
|
|
1189
|
+
plannerPrompt = pickBeadForPlanner(log);
|
|
851
1190
|
}
|
|
852
1191
|
} else {
|
|
853
|
-
// Subsequent cycles: planner reviews executor's work
|
|
854
1192
|
plannerPrompt = buildPlannerReviewPrompt(userFeedback);
|
|
855
1193
|
}
|
|
856
1194
|
|
|
857
1195
|
if (!plannerPrompt) {
|
|
858
|
-
// No work available
|
|
859
1196
|
const open = getOpenBeads();
|
|
860
1197
|
if (open.length === 0) {
|
|
861
1198
|
log.ok("All beads completed! Nothing left to do.");
|
|
@@ -869,97 +1206,262 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
869
1206
|
|
|
870
1207
|
cycle++;
|
|
871
1208
|
|
|
872
|
-
//
|
|
1209
|
+
// Planner turn
|
|
873
1210
|
console.log(
|
|
874
|
-
`\n${color.bold(
|
|
1211
|
+
`\n${color.bold(`-- Cycle ${cycle} / Planner`)} ${color.dim(`(${opts.planner})`)} ${color.bold("--------------------")}`,
|
|
875
1212
|
);
|
|
876
1213
|
|
|
877
1214
|
log.info(`Sending prompt to Planner (${opts.planner})...`);
|
|
878
1215
|
eventDisplay.resetTurn("planner");
|
|
879
|
-
const plannerResult = await
|
|
880
|
-
client,
|
|
881
|
-
|
|
882
|
-
plannerPrompt,
|
|
883
|
-
plannerModel,
|
|
884
|
-
eventDisplay,
|
|
885
|
-
inputQueue,
|
|
1216
|
+
const plannerResult = await executeTurnHeadless(
|
|
1217
|
+
client, sessionId, plannerPrompt, plannerModel,
|
|
1218
|
+
eventDisplay, inputQueue,
|
|
886
1219
|
);
|
|
887
|
-
console.log("");
|
|
1220
|
+
console.log("");
|
|
888
1221
|
|
|
889
|
-
if (plannerResult.quit) {
|
|
890
|
-
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
if (plannerResult.aborted) {
|
|
894
|
-
log.info("Planner turn aborted.");
|
|
895
|
-
continue;
|
|
896
|
-
}
|
|
1222
|
+
if (plannerResult.quit) { log.info("User requested quit."); return; }
|
|
1223
|
+
if (plannerResult.aborted) { log.info("Planner turn aborted."); continue; }
|
|
897
1224
|
|
|
898
|
-
// Check
|
|
899
|
-
// from the session to see the planner's output
|
|
1225
|
+
// Check TASK_DONE
|
|
900
1226
|
let plannerSaidDone = false;
|
|
901
1227
|
try {
|
|
902
1228
|
const messages = await client.session.messages(sessionId);
|
|
903
1229
|
if (messages && messages.length > 0) {
|
|
904
1230
|
const lastMsg = messages[messages.length - 1];
|
|
905
1231
|
const text = extractMessageText(lastMsg);
|
|
906
|
-
if (text.includes("TASK_DONE"))
|
|
907
|
-
plannerSaidDone = true;
|
|
908
|
-
}
|
|
1232
|
+
if (text.includes("TASK_DONE")) plannerSaidDone = true;
|
|
909
1233
|
}
|
|
910
|
-
} catch {
|
|
911
|
-
// Can't check, proceed with executor turn
|
|
912
|
-
}
|
|
1234
|
+
} catch { /* proceed */ }
|
|
913
1235
|
|
|
914
1236
|
if (plannerSaidDone) {
|
|
915
1237
|
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
|
|
1238
|
+
cycle = 0;
|
|
1239
|
+
const nextBead = pickBeadForPlanner(log);
|
|
1240
|
+
if (!nextBead) { log.ok("No more tasks. Finished."); break; }
|
|
924
1241
|
continue;
|
|
925
1242
|
}
|
|
926
1243
|
|
|
927
|
-
//
|
|
1244
|
+
// Executor turn
|
|
928
1245
|
console.log(
|
|
929
|
-
`\n${color.bold(
|
|
1246
|
+
`\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
|
|
930
1247
|
);
|
|
931
1248
|
|
|
932
1249
|
const executorPrompt = buildExecutorPrompt();
|
|
933
1250
|
log.info(`Sending prompt to Executor (${opts.executor})...`);
|
|
934
1251
|
eventDisplay.resetTurn("executor");
|
|
935
|
-
const executorResult = await
|
|
936
|
-
client,
|
|
937
|
-
|
|
938
|
-
executorPrompt,
|
|
939
|
-
executorModel,
|
|
940
|
-
eventDisplay,
|
|
941
|
-
inputQueue,
|
|
1252
|
+
const executorResult = await executeTurnHeadless(
|
|
1253
|
+
client, sessionId, executorPrompt, executorModel,
|
|
1254
|
+
eventDisplay, inputQueue,
|
|
942
1255
|
);
|
|
943
|
-
console.log("");
|
|
1256
|
+
console.log("");
|
|
944
1257
|
|
|
945
|
-
if (executorResult.quit) {
|
|
946
|
-
|
|
947
|
-
|
|
1258
|
+
if (executorResult.quit) { log.info("User requested quit."); return; }
|
|
1259
|
+
if (executorResult.aborted) { log.info("Executor turn aborted."); }
|
|
1260
|
+
|
|
1261
|
+
await sleep(1000);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (cycle >= opts.maxCycles) {
|
|
1265
|
+
log.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
|
|
1266
|
+
}
|
|
1267
|
+
} finally {
|
|
1268
|
+
eventDisplay.stop();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ── Supervisor loop (daemon mode) ───────────────────────────────────────────
|
|
1273
|
+
|
|
1274
|
+
async function supervisorLoopDaemon(client, sessionId, opts, dlog) {
|
|
1275
|
+
const plannerModel = parseModelSpec(opts.planner);
|
|
1276
|
+
const executorModel = parseModelSpec(opts.executor);
|
|
1277
|
+
|
|
1278
|
+
// Start SSE event monitor (silent)
|
|
1279
|
+
const monitor = createDaemonEventMonitor(client, dlog);
|
|
1280
|
+
|
|
1281
|
+
// Track whether user is interacting via TUI
|
|
1282
|
+
let userInteracting = false;
|
|
1283
|
+
let userTurnResolve = null;
|
|
1284
|
+
|
|
1285
|
+
monitor.onUserMessage = (text) => {
|
|
1286
|
+
dlog.info(`User typed in TUI, pausing auto loop...`);
|
|
1287
|
+
userInteracting = true;
|
|
1288
|
+
// The user message triggers a model response. We need to wait for
|
|
1289
|
+
// that response to complete before resuming our auto loop.
|
|
1290
|
+
// The next session.status=idle will signal completion.
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
monitor.start().catch(() => {});
|
|
1294
|
+
|
|
1295
|
+
// Inject system context
|
|
1296
|
+
const systemContext = buildSystemContext(opts);
|
|
1297
|
+
monitor.registerPrompt(systemContext);
|
|
1298
|
+
try {
|
|
1299
|
+
await client.session.prompt(sessionId, {
|
|
1300
|
+
noReply: true,
|
|
1301
|
+
parts: [{ type: "text", text: systemContext }],
|
|
1302
|
+
});
|
|
1303
|
+
dlog.ok("Context injected");
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
dlog.warn(`Context injection: ${err.message}`);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
let cycle = 0;
|
|
1309
|
+
|
|
1310
|
+
// If no explicit goal, enter goal discussion with planner.
|
|
1311
|
+
// The discovery prompt lists existing beads (if any) and suggestions.
|
|
1312
|
+
// User discusses in TUI, types /go when ready. If they want to pick
|
|
1313
|
+
// from beads, the planner will incorporate that into its plan.
|
|
1314
|
+
if (!opts.goal) {
|
|
1315
|
+
dlog.info("No goal specified — entering goal discussion with Planner.");
|
|
1316
|
+
const goResult = await goalDiscussionDaemon(
|
|
1317
|
+
client, sessionId, plannerModel, monitor, dlog,
|
|
1318
|
+
);
|
|
1319
|
+
if (!goResult) {
|
|
1320
|
+
dlog.info("Goal discussion ended without /go. Exiting.");
|
|
1321
|
+
monitor.stop();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
// Goal discussion complete — planner finalize prompt has produced
|
|
1325
|
+
// the first executor instruction. Jump straight to executor turn.
|
|
1326
|
+
cycle++;
|
|
1327
|
+
dlog.info(`Cycle ${cycle} / Executor (${opts.executor}) [post-goal-discussion]`);
|
|
1328
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1329
|
+
const executorResult = await executeTurnDaemon(
|
|
1330
|
+
client, sessionId, executorPrompt, executorModel, monitor, dlog,
|
|
1331
|
+
);
|
|
1332
|
+
if (executorResult.aborted) {
|
|
1333
|
+
dlog.warn("Executor turn aborted.");
|
|
1334
|
+
}
|
|
1335
|
+
await sleep(1000);
|
|
1336
|
+
// Fall through to the main loop (cycle is now 1, will get planner review)
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
while (cycle < opts.maxCycles) {
|
|
1341
|
+
// If user is interacting, wait for the model to finish responding
|
|
1342
|
+
// to their message before we send the next auto prompt
|
|
1343
|
+
if (userInteracting) {
|
|
1344
|
+
dlog.info("Waiting for user's turn to complete...");
|
|
1345
|
+
await monitor.waitForTurnEnd();
|
|
1346
|
+
userInteracting = false;
|
|
1347
|
+
dlog.info("User turn complete, resuming auto loop.");
|
|
1348
|
+
// After user intervention, planner should review
|
|
1349
|
+
// (fall through to planner review prompt)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
let plannerPrompt = null;
|
|
1353
|
+
|
|
1354
|
+
if (cycle === 0) {
|
|
1355
|
+
if (opts.goal) {
|
|
1356
|
+
plannerPrompt = buildPlannerGoalPrompt(opts.goal);
|
|
1357
|
+
} else {
|
|
1358
|
+
// No explicit goal, but beads exist — pick from beads
|
|
1359
|
+
plannerPrompt = pickBeadForPlanner(dlog);
|
|
1360
|
+
}
|
|
1361
|
+
} else {
|
|
1362
|
+
plannerPrompt = buildPlannerReviewPrompt(null);
|
|
948
1363
|
}
|
|
1364
|
+
|
|
1365
|
+
if (!plannerPrompt) {
|
|
1366
|
+
const open = getOpenBeads();
|
|
1367
|
+
if (open.length === 0) {
|
|
1368
|
+
dlog.ok("All beads completed! Nothing left to do.");
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
dlog.warn("All beads blocked. Waiting 30s before retry...");
|
|
1372
|
+
await sleep(30_000);
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
cycle++;
|
|
1377
|
+
|
|
1378
|
+
// Planner turn
|
|
1379
|
+
dlog.info(`Cycle ${cycle} / Planner (${opts.planner})`);
|
|
1380
|
+
const plannerResult = await executeTurnDaemon(
|
|
1381
|
+
client, sessionId, plannerPrompt, plannerModel, monitor, dlog,
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
if (plannerResult.aborted) {
|
|
1385
|
+
dlog.warn("Planner turn aborted.");
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check TASK_DONE
|
|
1390
|
+
let plannerSaidDone = false;
|
|
1391
|
+
try {
|
|
1392
|
+
const messages = await client.session.messages(sessionId);
|
|
1393
|
+
if (messages && messages.length > 0) {
|
|
1394
|
+
const lastMsg = messages[messages.length - 1];
|
|
1395
|
+
const text = extractMessageText(lastMsg);
|
|
1396
|
+
if (text.includes("TASK_DONE")) plannerSaidDone = true;
|
|
1397
|
+
}
|
|
1398
|
+
} catch { /* proceed */ }
|
|
1399
|
+
|
|
1400
|
+
if (plannerSaidDone) {
|
|
1401
|
+
dlog.ok("Planner declared task complete.");
|
|
1402
|
+
cycle = 0;
|
|
1403
|
+
const nextBead = pickBeadForPlanner(dlog);
|
|
1404
|
+
if (!nextBead) { dlog.ok("No more tasks. Finished."); break; }
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Executor turn
|
|
1409
|
+
dlog.info(`Cycle ${cycle} / Executor (${opts.executor})`);
|
|
1410
|
+
const executorPrompt = buildExecutorPrompt();
|
|
1411
|
+
const executorResult = await executeTurnDaemon(
|
|
1412
|
+
client, sessionId, executorPrompt, executorModel, monitor, dlog,
|
|
1413
|
+
);
|
|
1414
|
+
|
|
949
1415
|
if (executorResult.aborted) {
|
|
950
|
-
|
|
951
|
-
// Planner will review on next cycle
|
|
1416
|
+
dlog.warn("Executor turn aborted.");
|
|
952
1417
|
}
|
|
953
1418
|
|
|
954
|
-
// Small pause between cycles
|
|
955
1419
|
await sleep(1000);
|
|
956
1420
|
}
|
|
957
1421
|
|
|
958
1422
|
if (cycle >= opts.maxCycles) {
|
|
959
|
-
|
|
1423
|
+
dlog.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
|
|
960
1424
|
}
|
|
961
1425
|
} finally {
|
|
962
|
-
|
|
1426
|
+
monitor.stop();
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Validate models by sending a real test prompt.
|
|
1434
|
+
* Works with both console logger (log) and file logger (dlog).
|
|
1435
|
+
*/
|
|
1436
|
+
async function validateModels(client, sessionId, opts, logger) {
|
|
1437
|
+
const plannerModel = parseModelSpec(opts.planner);
|
|
1438
|
+
const executorModel = parseModelSpec(opts.executor);
|
|
1439
|
+
const probeModels = [
|
|
1440
|
+
{ label: "Planner", spec: opts.planner, parsed: plannerModel },
|
|
1441
|
+
{ label: "Executor", spec: opts.executor, parsed: executorModel },
|
|
1442
|
+
];
|
|
1443
|
+
const seen = new Set();
|
|
1444
|
+
for (const m of probeModels) {
|
|
1445
|
+
if (seen.has(m.spec)) continue;
|
|
1446
|
+
seen.add(m.spec);
|
|
1447
|
+
try {
|
|
1448
|
+
const result = await client.session.prompt(sessionId, {
|
|
1449
|
+
parts: [{ type: "text", text: "Say OK" }],
|
|
1450
|
+
model: m.parsed,
|
|
1451
|
+
});
|
|
1452
|
+
const err = result?.info?.error;
|
|
1453
|
+
if (err) {
|
|
1454
|
+
const msg = err.data?.message || err.name || "unknown error";
|
|
1455
|
+
logger.fail(`${m.label} model "${m.spec}" rejected by provider: ${msg}`);
|
|
1456
|
+
throw new Error(`${m.label} model unavailable: ${msg}`);
|
|
1457
|
+
}
|
|
1458
|
+
logger.ok(`${m.label} model verified: ${m.spec}`);
|
|
1459
|
+
} catch (probeErr) {
|
|
1460
|
+
if (probeErr.message.includes("unavailable") || probeErr.message.includes("rejected")) {
|
|
1461
|
+
throw probeErr;
|
|
1462
|
+
}
|
|
1463
|
+
logger.warn(`${m.label} model probe inconclusive: ${probeErr.message}`);
|
|
1464
|
+
}
|
|
963
1465
|
}
|
|
964
1466
|
}
|
|
965
1467
|
|
|
@@ -967,27 +1469,24 @@ async function supervisorLoop(client, opts, inputQueue) {
|
|
|
967
1469
|
* Try to pick a bead and return a planner prompt for it.
|
|
968
1470
|
* Returns null if no beads available.
|
|
969
1471
|
*/
|
|
970
|
-
function pickBeadForPlanner() {
|
|
971
|
-
// Check in-progress first
|
|
1472
|
+
function pickBeadForPlanner(logger) {
|
|
972
1473
|
const inProgress = getInProgressBeads();
|
|
973
1474
|
if (inProgress.length > 0) {
|
|
974
1475
|
const beadId = extractBeadId(inProgress[0]);
|
|
975
1476
|
if (beadId) {
|
|
976
|
-
|
|
1477
|
+
logger.info(`Resuming: ${beadId}`);
|
|
977
1478
|
return buildPlannerBeadPrompt(beadId);
|
|
978
1479
|
}
|
|
979
1480
|
}
|
|
980
1481
|
|
|
981
|
-
// Check ready beads
|
|
982
1482
|
const ready = getReadyBeads();
|
|
983
1483
|
if (ready.length === 0) return null;
|
|
984
1484
|
|
|
985
1485
|
const beadId = extractBeadId(ready[0]);
|
|
986
1486
|
if (!beadId) return null;
|
|
987
1487
|
|
|
988
|
-
// Claim it
|
|
989
1488
|
run(`bd update ${beadId} --status=in_progress`);
|
|
990
|
-
|
|
1489
|
+
logger.info(`Picked: ${beadId}`);
|
|
991
1490
|
return buildPlannerBeadPrompt(beadId);
|
|
992
1491
|
}
|
|
993
1492
|
|
|
@@ -1022,6 +1521,231 @@ async function waitForInput(inputQueue) {
|
|
|
1022
1521
|
}
|
|
1023
1522
|
}
|
|
1024
1523
|
|
|
1524
|
+
/**
|
|
1525
|
+
* Ask the user whether to pick from beads or discuss a plan (headless mode).
|
|
1526
|
+
* Shows current beads state and waits for the user to choose.
|
|
1527
|
+
*
|
|
1528
|
+
* @returns {"beads"|"discuss"|"quit"}
|
|
1529
|
+
*/
|
|
1530
|
+
async function askStartModeHeadless(inputQueue) {
|
|
1531
|
+
const inProgress = getInProgressBeads();
|
|
1532
|
+
const ready = getReadyBeads();
|
|
1533
|
+
const hasBeads = inProgress.length > 0 || ready.length > 0;
|
|
1534
|
+
|
|
1535
|
+
console.log(`\n${color.bold("-- What would you like to do?")} ${color.bold("--")}`);
|
|
1536
|
+
if (hasBeads) {
|
|
1537
|
+
if (inProgress.length > 0) {
|
|
1538
|
+
console.log(color.dim(` In-progress beads: ${inProgress.length}`));
|
|
1539
|
+
}
|
|
1540
|
+
if (ready.length > 0) {
|
|
1541
|
+
console.log(color.dim(` Ready beads: ${ready.length}`));
|
|
1542
|
+
}
|
|
1543
|
+
} else {
|
|
1544
|
+
console.log(color.dim(" No beads available."));
|
|
1545
|
+
}
|
|
1546
|
+
console.log("");
|
|
1547
|
+
if (hasBeads) {
|
|
1548
|
+
console.log(` ${color.bold("1")} Pick from beads (auto-select a task)`);
|
|
1549
|
+
}
|
|
1550
|
+
console.log(` ${color.bold("2")} Discuss with Planner (plan before execution)`);
|
|
1551
|
+
console.log(color.dim("\n Type 1 or 2, or /quit to exit.\n"));
|
|
1552
|
+
|
|
1553
|
+
while (true) {
|
|
1554
|
+
await waitForInput(inputQueue);
|
|
1555
|
+
const items = inputQueue.drain();
|
|
1556
|
+
for (const item of items) {
|
|
1557
|
+
if (item.type === "quit") return "quit";
|
|
1558
|
+
if (item.type === "message") {
|
|
1559
|
+
const t = item.text.trim();
|
|
1560
|
+
if (t === "1" && hasBeads) return "beads";
|
|
1561
|
+
if (t === "2") return "discuss";
|
|
1562
|
+
console.log(color.dim(` Please type ${hasBeads ? "1 or 2" : "2"}, or /quit to exit.`));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ── Goal discussion (headless mode) ─────────────────────────────────────────
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Interactive goal discussion in headless mode.
|
|
1572
|
+
* Planner suggests goals, user discusses, user types /go to proceed.
|
|
1573
|
+
*
|
|
1574
|
+
* @returns {boolean} true if /go was received and discussion completed,
|
|
1575
|
+
* false if user quit.
|
|
1576
|
+
*/
|
|
1577
|
+
async function goalDiscussionHeadless(client, sessionId, plannerModel, eventDisplay, inputQueue) {
|
|
1578
|
+
console.log(
|
|
1579
|
+
`\n${color.bold("-- Goal Discussion")} ${color.dim("(type /go when ready to start execution)")} ${color.bold("--")}`,
|
|
1580
|
+
);
|
|
1581
|
+
console.log(color.dim(" Discuss with the Planner what to work on. Type /go to begin.\n"));
|
|
1582
|
+
|
|
1583
|
+
// Send discovery prompt to planner
|
|
1584
|
+
const discoveryPrompt = buildPlannerDiscoveryPrompt();
|
|
1585
|
+
log.info("Sending discovery prompt to Planner...");
|
|
1586
|
+
eventDisplay.resetTurn("planner");
|
|
1587
|
+
const discoveryResult = await executeTurnHeadless(
|
|
1588
|
+
client, sessionId, discoveryPrompt, plannerModel,
|
|
1589
|
+
eventDisplay, inputQueue,
|
|
1590
|
+
);
|
|
1591
|
+
console.log("");
|
|
1592
|
+
|
|
1593
|
+
if (discoveryResult.quit) return false;
|
|
1594
|
+
if (discoveryResult.aborted) {
|
|
1595
|
+
log.warn("Discovery prompt aborted. Retrying...");
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Discussion loop: user talks to planner until /go
|
|
1599
|
+
while (true) {
|
|
1600
|
+
// Wait for user input
|
|
1601
|
+
console.log(color.dim("\n [waiting for your input... type /go to start, /quit to exit]\n"));
|
|
1602
|
+
await waitForInput(inputQueue);
|
|
1603
|
+
|
|
1604
|
+
const items = inputQueue.drain();
|
|
1605
|
+
let userText = null;
|
|
1606
|
+
let goReceived = false;
|
|
1607
|
+
let quitReceived = false;
|
|
1608
|
+
|
|
1609
|
+
for (const item of items) {
|
|
1610
|
+
if (item.type === "quit") {
|
|
1611
|
+
quitReceived = true;
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
if (item.type === "message") {
|
|
1615
|
+
// Check if the user typed /go
|
|
1616
|
+
if (item.text.toLowerCase().trim() === "/go") {
|
|
1617
|
+
goReceived = true;
|
|
1618
|
+
break;
|
|
1619
|
+
}
|
|
1620
|
+
userText = item.text;
|
|
1621
|
+
}
|
|
1622
|
+
if (item.type === "abort") {
|
|
1623
|
+
// Ignore /abort during discussion
|
|
1624
|
+
}
|
|
1625
|
+
if (item.type === "status") {
|
|
1626
|
+
showBeadStatus();
|
|
1627
|
+
}
|
|
1628
|
+
if (item.type === "skip") {
|
|
1629
|
+
// Ignore /skip during discussion
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (quitReceived) return false;
|
|
1634
|
+
|
|
1635
|
+
if (goReceived) {
|
|
1636
|
+
// Send finalize prompt to planner
|
|
1637
|
+
console.log(
|
|
1638
|
+
`\n${color.bold("-- Finalizing Goal")} ${color.bold("--------------------")}`,
|
|
1639
|
+
);
|
|
1640
|
+
log.info("Sending finalize prompt to Planner...");
|
|
1641
|
+
eventDisplay.resetTurn("planner");
|
|
1642
|
+
const finalizeResult = await executeTurnHeadless(
|
|
1643
|
+
client, sessionId, buildPlannerFinalizeGoalPrompt(), plannerModel,
|
|
1644
|
+
eventDisplay, inputQueue,
|
|
1645
|
+
);
|
|
1646
|
+
console.log("");
|
|
1647
|
+
|
|
1648
|
+
if (finalizeResult.quit) return false;
|
|
1649
|
+
return true;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (userText) {
|
|
1653
|
+
// Send user's message to planner for continued discussion
|
|
1654
|
+
const discussPrompt = `## Role: Planner (Goal Discussion)
|
|
1655
|
+
|
|
1656
|
+
The user says:
|
|
1657
|
+
|
|
1658
|
+
> ${userText}
|
|
1659
|
+
|
|
1660
|
+
Continue the goal discussion. Help the user refine what to work on.
|
|
1661
|
+
When they're ready, remind them to type \`/go\` to begin execution.`;
|
|
1662
|
+
|
|
1663
|
+
eventDisplay.resetTurn("planner");
|
|
1664
|
+
const discussResult = await executeTurnHeadless(
|
|
1665
|
+
client, sessionId, discussPrompt, plannerModel,
|
|
1666
|
+
eventDisplay, inputQueue,
|
|
1667
|
+
);
|
|
1668
|
+
console.log("");
|
|
1669
|
+
|
|
1670
|
+
if (discussResult.quit) return false;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// ── Goal discussion (daemon mode) ───────────────────────────────────────────
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Goal discussion in daemon+TUI mode.
|
|
1679
|
+
* Sends a discovery prompt to planner, then waits for the user to
|
|
1680
|
+
* type /go in the TUI. While waiting, the user can freely discuss
|
|
1681
|
+
* with the planner via the TUI — the daemon stays paused.
|
|
1682
|
+
*
|
|
1683
|
+
* @returns {boolean} true if /go was received, false if daemon should exit.
|
|
1684
|
+
*/
|
|
1685
|
+
async function goalDiscussionDaemon(client, sessionId, plannerModel, monitor, dlog) {
|
|
1686
|
+
dlog.info("Starting goal discussion (no goal provided)");
|
|
1687
|
+
|
|
1688
|
+
// Send discovery prompt
|
|
1689
|
+
const discoveryPrompt = buildPlannerDiscoveryPrompt();
|
|
1690
|
+
dlog.info("Sending discovery prompt to Planner...");
|
|
1691
|
+
const discoveryResult = await executeTurnDaemon(
|
|
1692
|
+
client, sessionId, discoveryPrompt, plannerModel, monitor, dlog,
|
|
1693
|
+
);
|
|
1694
|
+
|
|
1695
|
+
if (discoveryResult.aborted) {
|
|
1696
|
+
dlog.warn("Discovery prompt aborted");
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Now wait for user to type /go in the TUI.
|
|
1700
|
+
// The monitor detects user messages via SSE. We listen for /go specifically.
|
|
1701
|
+
dlog.info("Waiting for user to type /go in TUI...");
|
|
1702
|
+
|
|
1703
|
+
return new Promise((resolve) => {
|
|
1704
|
+
let resolved = false;
|
|
1705
|
+
|
|
1706
|
+
// Save previous onUserMessage handler
|
|
1707
|
+
const prevHandler = monitor.onUserMessage;
|
|
1708
|
+
|
|
1709
|
+
monitor.onUserMessage = (text) => {
|
|
1710
|
+
if (resolved) return;
|
|
1711
|
+
const trimmed = text.trim().toLowerCase();
|
|
1712
|
+
|
|
1713
|
+
if (trimmed === "/go") {
|
|
1714
|
+
dlog.info("User typed /go — finalizing goal");
|
|
1715
|
+
resolved = true;
|
|
1716
|
+
|
|
1717
|
+
// Restore previous handler
|
|
1718
|
+
monitor.onUserMessage = prevHandler || null;
|
|
1719
|
+
|
|
1720
|
+
// Send finalize prompt
|
|
1721
|
+
(async () => {
|
|
1722
|
+
const finalizePrompt = buildPlannerFinalizeGoalPrompt();
|
|
1723
|
+
monitor.registerPrompt(finalizePrompt);
|
|
1724
|
+
dlog.info("Sending finalize prompt to Planner...");
|
|
1725
|
+
const finalizeResult = await executeTurnDaemon(
|
|
1726
|
+
client, sessionId, finalizePrompt, plannerModel, monitor, dlog,
|
|
1727
|
+
);
|
|
1728
|
+
if (finalizeResult.aborted) {
|
|
1729
|
+
dlog.warn("Finalize prompt aborted");
|
|
1730
|
+
}
|
|
1731
|
+
resolve(true);
|
|
1732
|
+
})();
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Any other user message: the user is discussing with the planner via
|
|
1737
|
+
// TUI. The TUI + opencode handle this automatically (user types →
|
|
1738
|
+
// model responds). The daemon just needs to wait for those turns to
|
|
1739
|
+
// complete before checking for /go again.
|
|
1740
|
+
dlog.info(`User discussing in TUI: "${text.slice(0, 60)}..."`);
|
|
1741
|
+
// Wait for the model's response to finish
|
|
1742
|
+
monitor.waitForTurnEnd().then(() => {
|
|
1743
|
+
dlog.info("Planner response to user complete, still waiting for /go");
|
|
1744
|
+
});
|
|
1745
|
+
};
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1025
1749
|
// ── Main entry point ────────────────────────────────────────────────────────
|
|
1026
1750
|
|
|
1027
1751
|
export async function auto(argv) {
|
|
@@ -1034,42 +1758,131 @@ export async function auto(argv) {
|
|
|
1034
1758
|
process.exit(1);
|
|
1035
1759
|
}
|
|
1036
1760
|
|
|
1761
|
+
// ── Path 1: Internal daemon process (forked by main process) ──
|
|
1762
|
+
if (opts._daemon) {
|
|
1763
|
+
return runDaemonProcess(opts);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// ── Path 2: Headless mode (original CLI) ──
|
|
1767
|
+
if (opts.headless) {
|
|
1768
|
+
return runHeadlessMode(opts);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// ── Path 3: Default — daemon + TUI ──
|
|
1772
|
+
return runDaemonTuiMode(opts);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ── Path 1: Daemon process ──────────────────────────────────────────────────
|
|
1776
|
+
|
|
1777
|
+
async function runDaemonProcess(opts) {
|
|
1778
|
+
const dlog = createFileLogger(LOG_FILE);
|
|
1779
|
+
dlog.info(`Daemon starting — goal: ${opts.goal || "(auto-pick)"}`);
|
|
1780
|
+
dlog.info(`Planner: ${opts.planner}, Executor: ${opts.executor}`);
|
|
1781
|
+
|
|
1782
|
+
const url = opts._daemonUrl;
|
|
1783
|
+
if (!url) {
|
|
1784
|
+
dlog.fail("No server URL provided (--_daemon-url)");
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Connect to the opencode serve instance
|
|
1789
|
+
const client = createClient(url);
|
|
1790
|
+
try {
|
|
1791
|
+
const health = await client.health();
|
|
1792
|
+
if (!health?.healthy) throw new Error("not healthy");
|
|
1793
|
+
dlog.ok(`Connected to server at ${url}`);
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
dlog.fail(`Cannot connect to server at ${url}: ${err.message}`);
|
|
1796
|
+
process.exit(1);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Create or reuse session
|
|
1800
|
+
let sessionId = opts._daemonSessionId;
|
|
1801
|
+
if (!sessionId) {
|
|
1802
|
+
dlog.info("Creating session...");
|
|
1803
|
+
const session = await client.session.create({ title: "mneme auto" });
|
|
1804
|
+
sessionId = session.id;
|
|
1805
|
+
dlog.ok(`Session: ${sessionId}`);
|
|
1806
|
+
|
|
1807
|
+
// Validate models in new session
|
|
1808
|
+
dlog.info("Validating models (API probe)...");
|
|
1809
|
+
try {
|
|
1810
|
+
await validateModels(client, sessionId, opts, dlog);
|
|
1811
|
+
} catch (err) {
|
|
1812
|
+
dlog.fail(`Model validation failed: ${err.message}`);
|
|
1813
|
+
process.exit(1);
|
|
1814
|
+
}
|
|
1815
|
+
} else {
|
|
1816
|
+
dlog.ok(`Reusing session: ${sessionId}`);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Handle signals for clean shutdown
|
|
1820
|
+
let shuttingDown = false;
|
|
1821
|
+
const shutdown = (signal) => {
|
|
1822
|
+
if (shuttingDown) return;
|
|
1823
|
+
shuttingDown = true;
|
|
1824
|
+
dlog.info(`Received ${signal}, shutting down daemon...`);
|
|
1825
|
+
process.exit(0);
|
|
1826
|
+
};
|
|
1827
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1828
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1829
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
1830
|
+
|
|
1831
|
+
// Monitor parent process (the TUI). When it exits, we should too.
|
|
1832
|
+
// In Node.js, when the parent exits, the daemon gets SIGHUP if not
|
|
1833
|
+
// fully detached. We also poll as a fallback.
|
|
1834
|
+
if (process.ppid) {
|
|
1835
|
+
const parentPid = process.ppid;
|
|
1836
|
+
const parentCheckId = setInterval(() => {
|
|
1837
|
+
try {
|
|
1838
|
+
// Signal 0 checks if process exists
|
|
1839
|
+
process.kill(parentPid, 0);
|
|
1840
|
+
} catch {
|
|
1841
|
+
dlog.info("Parent process exited, daemon shutting down.");
|
|
1842
|
+
clearInterval(parentCheckId);
|
|
1843
|
+
process.exit(0);
|
|
1844
|
+
}
|
|
1845
|
+
}, 5000);
|
|
1846
|
+
parentCheckId.unref?.();
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Run the daemon supervisor loop
|
|
1850
|
+
try {
|
|
1851
|
+
await supervisorLoopDaemon(client, sessionId, opts, dlog);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
dlog.fail(`Daemon supervisor error: ${err.message}`);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
dlog.ok("Daemon finished.");
|
|
1857
|
+
process.exit(0);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// ── Path 2: Headless mode (original CLI) ────────────────────────────────────
|
|
1861
|
+
|
|
1862
|
+
async function runHeadlessMode(opts) {
|
|
1037
1863
|
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`,
|
|
1864
|
+
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor (headless)\n`,
|
|
1045
1865
|
);
|
|
1866
|
+
console.log(` ${color.bold("Planner:")} ${opts.planner}`);
|
|
1867
|
+
console.log(` ${color.bold("Executor:")} ${opts.executor}\n`);
|
|
1046
1868
|
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"));
|
|
1869
|
+
console.log(color.dim(" Type any message -> inject feedback to planner"));
|
|
1870
|
+
console.log(color.dim(" /status -> show bead status"));
|
|
1871
|
+
console.log(color.dim(" /skip -> skip current bead"));
|
|
1872
|
+
console.log(color.dim(" /abort -> abort current turn"));
|
|
1873
|
+
console.log(color.dim(" /quit -> stop and exit\n"));
|
|
1054
1874
|
|
|
1055
|
-
// Start or attach to server
|
|
1056
1875
|
let serverCtx;
|
|
1057
1876
|
try {
|
|
1058
1877
|
if (opts.attach) {
|
|
1059
1878
|
serverCtx = await attachOpencodeServer(opts.attach);
|
|
1060
|
-
log.ok(
|
|
1061
|
-
`Attached to ${serverCtx.url} (v${serverCtx.version})`,
|
|
1062
|
-
);
|
|
1879
|
+
log.ok(`Attached to ${serverCtx.url} (v${serverCtx.version})`);
|
|
1063
1880
|
} else {
|
|
1064
1881
|
serverCtx = await startOpencodeServer({ port: opts.port });
|
|
1065
1882
|
if (serverCtx.alreadyRunning) {
|
|
1066
|
-
log.ok(
|
|
1067
|
-
`Server already running at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1068
|
-
);
|
|
1883
|
+
log.ok(`Server already running at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1069
1884
|
} else {
|
|
1070
|
-
log.ok(
|
|
1071
|
-
`Server started at ${serverCtx.url} (v${serverCtx.version})`,
|
|
1072
|
-
);
|
|
1885
|
+
log.ok(`Server started at ${serverCtx.url} (v${serverCtx.version})`);
|
|
1073
1886
|
}
|
|
1074
1887
|
}
|
|
1075
1888
|
} catch (err) {
|
|
@@ -1077,24 +1890,157 @@ export async function auto(argv) {
|
|
|
1077
1890
|
process.exit(1);
|
|
1078
1891
|
}
|
|
1079
1892
|
|
|
1080
|
-
// Start input queue
|
|
1081
1893
|
const inputQueue = createInputQueue();
|
|
1082
1894
|
inputQueue.start();
|
|
1083
1895
|
|
|
1084
|
-
// Run supervisor
|
|
1085
1896
|
try {
|
|
1086
|
-
await
|
|
1897
|
+
await supervisorLoopHeadless(serverCtx.client, opts, inputQueue);
|
|
1087
1898
|
} catch (err) {
|
|
1088
1899
|
log.fail(`Supervisor error: ${err.message}`);
|
|
1089
1900
|
} finally {
|
|
1090
1901
|
inputQueue.stop();
|
|
1091
|
-
// Only kill server if WE started it
|
|
1092
1902
|
if (serverCtx.serverProcess) {
|
|
1093
1903
|
log.info("Shutting down server...");
|
|
1094
1904
|
serverCtx.serverProcess.kill("SIGTERM");
|
|
1095
1905
|
}
|
|
1096
1906
|
log.ok("mneme auto finished.");
|
|
1907
|
+
process.exit(0);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// ── Path 3: Daemon + TUI mode (default) ────────────────────────────────────
|
|
1912
|
+
|
|
1913
|
+
async function runDaemonTuiMode(opts) {
|
|
1914
|
+
console.log(
|
|
1915
|
+
`\n${color.bold("mneme auto")} — dual-agent autonomous supervisor\n`,
|
|
1916
|
+
);
|
|
1917
|
+
console.log(` ${color.bold("Planner:")} ${opts.planner}`);
|
|
1918
|
+
console.log(` ${color.bold("Executor:")} ${opts.executor}`);
|
|
1919
|
+
console.log(` ${color.bold("Mode:")} daemon + TUI\n`);
|
|
1920
|
+
|
|
1921
|
+
// Step 1: Start opencode serve (if not running)
|
|
1922
|
+
let serverUrl;
|
|
1923
|
+
let weStartedServer = false;
|
|
1924
|
+
|
|
1925
|
+
if (opts.attach) {
|
|
1926
|
+
// User provided a URL
|
|
1927
|
+
serverUrl = opts.attach;
|
|
1928
|
+
log.info(`Using provided server: ${serverUrl}`);
|
|
1929
|
+
try {
|
|
1930
|
+
const client = createClient(serverUrl);
|
|
1931
|
+
const health = await client.health();
|
|
1932
|
+
if (!health?.healthy) throw new Error("not healthy");
|
|
1933
|
+
log.ok(`Server healthy (v${health.version})`);
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
log.fail(`Cannot connect to ${serverUrl}: ${err.message}`);
|
|
1936
|
+
process.exit(1);
|
|
1937
|
+
}
|
|
1938
|
+
} else {
|
|
1939
|
+
serverUrl = `http://127.0.0.1:${opts.port}`;
|
|
1940
|
+
// Check if already running
|
|
1941
|
+
try {
|
|
1942
|
+
const client = createClient(serverUrl);
|
|
1943
|
+
const health = await client.health();
|
|
1944
|
+
if (health?.healthy) {
|
|
1945
|
+
log.ok(`Server already running at ${serverUrl} (v${health.version})`);
|
|
1946
|
+
} else {
|
|
1947
|
+
throw new Error("not healthy");
|
|
1948
|
+
}
|
|
1949
|
+
} catch {
|
|
1950
|
+
// Need to start it
|
|
1951
|
+
log.info(`Starting opencode serve on port ${opts.port}...`);
|
|
1952
|
+
try {
|
|
1953
|
+
const ctx = await startOpencodeServer({ port: opts.port, detached: true });
|
|
1954
|
+
weStartedServer = !ctx.alreadyRunning;
|
|
1955
|
+
log.ok(`Server started at ${ctx.url} (v${ctx.version})`);
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
log.fail(`Failed to start server: ${err.message}`);
|
|
1958
|
+
process.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Step 2: Create session and validate models before forking daemon
|
|
1964
|
+
log.info("Creating session and validating models...");
|
|
1965
|
+
const client = createClient(serverUrl);
|
|
1966
|
+
let sessionId;
|
|
1967
|
+
try {
|
|
1968
|
+
const session = await client.session.create({ title: "mneme auto" });
|
|
1969
|
+
sessionId = session.id;
|
|
1970
|
+
log.ok(`Session: ${sessionId}`);
|
|
1971
|
+
await validateModels(client, sessionId, opts, log);
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
log.fail(`Setup failed: ${err.message}`);
|
|
1974
|
+
if (weStartedServer) {
|
|
1975
|
+
log.info("Stopping server we started...");
|
|
1976
|
+
run(`kill $(ps aux | grep 'opencode.*serve.*--port.*${opts.port}' | grep -v grep | awk '{print $2}') 2>/dev/null`);
|
|
1977
|
+
}
|
|
1978
|
+
process.exit(1);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Step 3: Fork daemon process
|
|
1982
|
+
log.info("Forking daemon process...");
|
|
1983
|
+
|
|
1984
|
+
// Build daemon argv
|
|
1985
|
+
const daemonArgs = [
|
|
1986
|
+
"auto",
|
|
1987
|
+
"--_daemon",
|
|
1988
|
+
"--_daemon-url", serverUrl,
|
|
1989
|
+
"--_daemon-session", sessionId,
|
|
1990
|
+
"--planner", opts.planner,
|
|
1991
|
+
"--executor", opts.executor,
|
|
1992
|
+
"--max-cycles", String(opts.maxCycles),
|
|
1993
|
+
];
|
|
1994
|
+
if (opts.goal) {
|
|
1995
|
+
daemonArgs.push(opts.goal);
|
|
1097
1996
|
}
|
|
1997
|
+
|
|
1998
|
+
// Fork using the mneme CLI entry point
|
|
1999
|
+
const mnemeEntry = join(
|
|
2000
|
+
new URL(".", import.meta.url).pathname,
|
|
2001
|
+
"..", "..", "bin", "mneme.mjs",
|
|
2002
|
+
);
|
|
2003
|
+
|
|
2004
|
+
const daemon = fork(mnemeEntry, daemonArgs, {
|
|
2005
|
+
detached: true,
|
|
2006
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
daemon.unref();
|
|
2010
|
+
// Disconnect IPC so parent can exit cleanly
|
|
2011
|
+
daemon.disconnect();
|
|
2012
|
+
|
|
2013
|
+
log.ok(`Daemon forked (PID: ${daemon.pid})`);
|
|
2014
|
+
log.info(`Log file: ${LOG_FILE}`);
|
|
2015
|
+
|
|
2016
|
+
// Small delay to let daemon connect before we launch TUI
|
|
2017
|
+
await sleep(1000);
|
|
2018
|
+
|
|
2019
|
+
// Step 4: exec opencode attach (replaces this process)
|
|
2020
|
+
log.info(`Launching TUI: opencode attach ${serverUrl}`);
|
|
2021
|
+
console.log(color.dim(" Type directly in the TUI to intervene. The daemon pauses automatically.\n"));
|
|
2022
|
+
|
|
2023
|
+
try {
|
|
2024
|
+
execSync(`opencode attach ${serverUrl}`, {
|
|
2025
|
+
stdio: "inherit",
|
|
2026
|
+
// This blocks until the TUI exits
|
|
2027
|
+
});
|
|
2028
|
+
} catch {
|
|
2029
|
+
// TUI exited (normal or error)
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// TUI exited — kill daemon
|
|
2033
|
+
log.info("TUI exited.");
|
|
2034
|
+
try {
|
|
2035
|
+
process.kill(daemon.pid, "SIGTERM");
|
|
2036
|
+
log.info(`Sent SIGTERM to daemon (PID: ${daemon.pid})`);
|
|
2037
|
+
} catch {
|
|
2038
|
+
// Daemon may have already exited
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// If we started the server and --attach wasn't used, optionally stop it
|
|
2042
|
+
// (leave it running — user can `mneme down` manually)
|
|
2043
|
+
log.ok("mneme auto finished.");
|
|
1098
2044
|
}
|
|
1099
2045
|
|
|
1100
2046
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|