@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.
@@ -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 # Auto-pick from ready beads
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-5.2 --executor github-copilot/claude-opus-4.6
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
- stopOpencodeServer,
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 Auto-pick from ready beads
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
- Commands while running:
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(" quitting after current turn..."));
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(" will skip current bead after this cycle"),
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(" aborting current turn..."));
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(" queued, will send to planner next cycle"),
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; // "planner" | "executor" — for display prefixing
393
- let lastOutputTime = 0; // Date.now() of last SSE output
394
- let hasReceivedAny = false; // true once any event arrives
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(); // parts that received delta events
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(` ${toolName}`)}${argsStr ? color.dim(` ${argsStr}`) : ""}`,
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 executeTurn(
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: if model is invalid, the session may error out almost
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 — fall through to normal wait
914
+ // Ignore probe failures
649
915
  }
650
916
 
651
- const HEARTBEAT_INTERVAL = 15_000; // print elapsed every 15s of silence
652
- const SILENCE_WARN = 30_000; // warn after 30s of no output
653
- const SILENCE_ABORT = 120_000; // auto-abort after 120s of no output
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 ${elapsed}s elapsed — no output for ${Math.round(silenceMs / 1000)}s, auto-aborting`),
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 ${elapsed}s elapsed — no output for ${Math.round(silenceMs / 1000)}s (will abort at ${SILENCE_ABORT / 1000}s)`),
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("── Status ──")}`);
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 supervisorLoop(client, opts, inputQueue) {
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
- // ── Validate models by sending a real test prompt ──
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
- const probeModels = [
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 (noReply)
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
- shouldSkip = true;
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
- plannerPrompt = pickBeadForPlanner();
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
- // ── Planner turn ──
1209
+ // Planner turn
873
1210
  console.log(
874
- `\n${color.bold(`── Cycle ${cycle} · Planner`)} ${color.dim(`(${opts.planner})`)} ${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 executeTurn(
880
- client,
881
- sessionId,
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(""); // newline after output
1220
+ console.log("");
888
1221
 
889
- if (plannerResult.quit) {
890
- log.info("User requested quit.");
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 if planner said TASK_DONE — we need to read the last message
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
- // Pick next bead on next cycle
917
- cycle = 0; // reset cycle counter for next task
918
- const nextBead = pickBeadForPlanner();
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
- // ── Executor turn ──
1244
+ // Executor turn
928
1245
  console.log(
929
- `\n${color.bold(`── Cycle ${cycle} · Executor`)} ${color.dim(`(${opts.executor})`)} ${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 executeTurn(
936
- client,
937
- sessionId,
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(""); // newline after output
1256
+ console.log("");
944
1257
 
945
- if (executorResult.quit) {
946
- log.info("User requested quit.");
947
- return;
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
- log.info("Executor turn aborted.");
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
- log.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
1423
+ dlog.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
960
1424
  }
961
1425
  } finally {
962
- eventDisplay.stop();
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
- log.info(`Resuming: ${beadId}`);
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
- log.info(`Picked: ${beadId}`);
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
- color.dim(" Type any message → inject feedback to planner"),
1049
- );
1050
- console.log(color.dim(" /status → show bead status"));
1051
- console.log(color.dim(" /skip skip current bead"));
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 supervisorLoop(serverCtx.client, opts, inputQueue);
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 ─────────────────────────────────────────────────────────────────