@xqli02/mneme 0.1.11 → 0.1.12

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