@xqli02/mneme 0.1.10 → 0.1.12

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