@xqli02/mneme 0.1.13 → 0.1.14

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,103 +1,47 @@
1
1
  /**
2
- * mneme auto — Dual-agent autonomous supervisor loop.
2
+ * mneme auto — Launch opencode with oh-my-opencode agents and mneme tools.
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)
4
+ * This is a simple launcher that:
5
+ * 1. Ensures Dolt is running (required for beads task management)
6
+ * 2. Launches opencode TUI (default) or headless mode (--headless)
8
7
  *
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
- *
15
- * Uses two agents in the same opencode session:
16
- * - Planner (default: gpt-4.1): analyzes goal, breaks down tasks, reviews results
17
- * - Executor (default: claude-opus-4.6): writes code, runs commands, implements changes
8
+ * Agent orchestration (planning, delegation, auto-continuation) is handled
9
+ * entirely by oh-my-opencode's built-in system (Sisyphus, Ralph Loop, etc.).
10
+ * Mneme provides supplementary tools (beads/ledger) via the plugin in
11
+ * .opencode/plugins/mneme.ts.
18
12
  *
19
13
  * Usage:
20
- * mneme auto # Daemon + TUI mode (default)
21
- * mneme auto "Build auth module" # Start with a specific goal
22
- * mneme auto --headless # CLI mode (no TUI)
23
- * mneme auto --attach http://localhost:4096
24
- * mneme auto --port 4096
25
- * mneme auto --planner github-copilot/gpt-4.1 --executor github-copilot/claude-opus-4.6
14
+ * mneme auto # Launch opencode TUI
15
+ * mneme auto "Build auth module" # TUI with initial goal
16
+ * mneme auto --headless "Fix bug" # Headless mode (opencode run)
17
+ * mneme auto --headless # Headless, prompts for goal
18
+ * mneme auto --port 4096 # Specify serve port
26
19
  */
27
20
 
28
- import { readFileSync, existsSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
29
- import { join } from "node:path";
30
- import { createInterface } from "node:readline";
31
- import { fork, execSync } from "node:child_process";
32
- import {
33
- startOpencodeServer,
34
- attachOpencodeServer,
35
- parseModelSpec,
36
- findOpencodeProcess,
37
- } from "../opencode-server.mjs";
38
- import { createClient } from "../opencode-client.mjs";
39
- import { color, log, run, has } from "../utils.mjs";
40
-
41
- // ── Default models ──────────────────────────────────────────────────────────
42
-
43
- const DEFAULT_PLANNER = "github-copilot/gpt-4.1";
44
- const DEFAULT_EXECUTOR = "github-copilot/claude-opus-4.6";
45
-
46
- // ── Log file path ───────────────────────────────────────────────────────────
47
-
48
- const LOG_FILE = ".mneme-auto.log";
21
+ import { spawnSync } from "node:child_process";
22
+ import { isPortOpen, startDoltServer } from "../dolt.mjs";
23
+ import { has, log, color } from "../utils.mjs";
49
24
 
50
25
  // ── Argument parsing ────────────────────────────────────────────────────────
51
26
 
52
27
  function parseArgs(argv) {
53
28
  const opts = {
54
29
  goal: null,
55
- attach: null,
56
- port: 4097,
57
- maxCycles: 50, // planner-executor cycles
58
- planner: DEFAULT_PLANNER,
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
30
+ headless: false,
31
+ port: null, // let opencode pick a port unless specified
64
32
  };
65
33
  const positional = [];
66
34
 
67
35
  for (let i = 0; i < argv.length; i++) {
68
36
  const arg = argv[i];
69
- if (arg === "--attach" && argv[i + 1]) {
70
- opts.attach = argv[++i];
71
- } else if (arg.startsWith("--attach=")) {
72
- opts.attach = arg.split("=").slice(1).join("=");
37
+ if (arg === "--headless" || arg === "-H") {
38
+ opts.headless = true;
73
39
  } else if (arg === "--port" && argv[i + 1]) {
74
40
  opts.port = parseInt(argv[++i], 10);
75
41
  } else if (arg.startsWith("--port=")) {
76
42
  opts.port = parseInt(arg.split("=")[1], 10);
77
- } else if (arg === "--max-cycles" && argv[i + 1]) {
78
- opts.maxCycles = parseInt(argv[++i], 10);
79
- } else if (arg === "--planner" && argv[i + 1]) {
80
- opts.planner = argv[++i];
81
- } else if (arg.startsWith("--planner=")) {
82
- opts.planner = arg.split("=").slice(1).join("=");
83
- } else if (arg === "--executor" && argv[i + 1]) {
84
- opts.executor = argv[++i];
85
- } else if (arg.startsWith("--executor=")) {
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("=");
99
43
  } else if (arg === "--help" || arg === "-h") {
100
- showHelp();
44
+ printHelp();
101
45
  process.exit(0);
102
46
  } else if (!arg.startsWith("-")) {
103
47
  positional.push(arg);
@@ -111,1940 +55,136 @@ function parseArgs(argv) {
111
55
  return opts;
112
56
  }
113
57
 
114
- function showHelp() {
58
+ function printHelp() {
115
59
  console.log(`
116
- ${color.bold("mneme auto")} — Dual-agent autonomous supervisor
117
-
118
- Usage:
119
- mneme auto Daemon + TUI mode (default)
120
- mneme auto "Build auth module" Start with a specific goal
121
- mneme auto --headless CLI mode (no TUI, streams to stdout)
122
- mneme auto --attach URL Attach to existing server
123
- mneme auto --port PORT Use specific port (default: 4097)
124
-
125
- Options:
126
- --planner MODEL Planner model (default: ${DEFAULT_PLANNER})
127
- --executor MODEL Executor model (default: ${DEFAULT_EXECUTOR})
128
- --max-cycles N Max planner-executor cycles (default: 50)
129
- --headless Use CLI mode instead of TUI
130
- --attach URL Attach to running opencode server
131
- --port PORT Port for auto-started server
132
-
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:
145
- Type any message Inject feedback (sent to planner next turn)
146
- /go Finish goal discussion and start execution
147
- /status Show bead status
148
- /skip Skip current bead
149
- /abort Abort current turn
150
- /quit Stop and exit
60
+ ${color.bold("mneme auto")} — Launch opencode with mneme tools and oh-my-opencode agents
61
+
62
+ ${color.bold("USAGE")}
63
+ mneme auto [options] [goal]
64
+
65
+ ${color.bold("OPTIONS")}
66
+ --headless, -H Run in headless mode (opencode run, no TUI)
67
+ --port <num> Port for opencode serve (default: auto)
68
+ -h, --help Show this help
69
+
70
+ ${color.bold("EXAMPLES")}
71
+ mneme auto # Launch TUI
72
+ mneme auto "Build auth module" # TUI with initial message
73
+ mneme auto --headless "Fix bug" # Headless single run
74
+ mneme auto -H # Headless, interactive
75
+
76
+ ${color.bold("AGENT SYSTEM")}
77
+ oh-my-opencode provides multi-agent orchestration:
78
+ - ${color.blue("Sisyphus")} Primary orchestrator (Tab to switch agents)
79
+ - ${color.blue("Hephaestus")} Deep coding agent
80
+ - ${color.blue("Prometheus")} Planning agent
81
+ - ${color.blue("Atlas")} Execution conductor
82
+
83
+ Mneme provides supplementary tools:
84
+ - ${color.blue("mneme_ready")} List available tasks
85
+ - ${color.blue("mneme_facts")} Read ledger facts
86
+ - ${color.blue("mneme_update")} Update task status/notes
87
+ - ${color.blue("mneme_propose_fact")} Propose new facts
88
+ - ... and more (see .opencode/plugins/mneme.ts)
151
89
  `);
152
90
  }
153
91
 
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
-
177
- // ── Bead management ─────────────────────────────────────────────────────────
178
-
179
- function getReadyBeads() {
180
- const output = run("bd ready --json");
181
- if (!output) return [];
182
- try {
183
- return JSON.parse(output);
184
- } catch {
185
- return parseBeadText(run("bd ready") || "");
186
- }
187
- }
188
-
189
- function getInProgressBeads() {
190
- const output = run("bd list --status=in_progress --json");
191
- if (!output) return [];
192
- try {
193
- return JSON.parse(output);
194
- } catch {
195
- return [];
196
- }
197
- }
198
-
199
- function getOpenBeads() {
200
- const output = run("bd list --status=open --json");
201
- if (!output) return [];
202
- try {
203
- return JSON.parse(output);
204
- } catch {
205
- return [];
206
- }
207
- }
208
-
209
- function getBeadDetails(id) {
210
- return run(`bd show ${id}`) || "";
211
- }
212
-
213
- function parseBeadText(text) {
214
- if (!text || text.includes("No ready work")) return [];
215
- return text
216
- .split("\n")
217
- .filter((l) => l.trim())
218
- .map((line) => {
219
- const idMatch = line.match(/([\w-]+)\s/);
220
- return idMatch ? { id: idMatch[1], raw: line } : null;
221
- })
222
- .filter(Boolean);
223
- }
224
-
225
- // ── Prompt composition ──────────────────────────────────────────────────────
226
-
227
- function readFacts() {
228
- const factsDir = ".ledger/facts";
229
- if (!existsSync(factsDir)) return "";
230
- const files = readdirSync(factsDir).filter((f) => f.endsWith(".md"));
231
- const parts = [];
232
- for (const file of files) {
233
- const content = readFileSync(join(factsDir, file), "utf-8");
234
- parts.push(`## ${file}\n\n${content}`);
235
- }
236
- return parts.join("\n\n---\n\n");
237
- }
238
-
239
- function readAgentsRules() {
240
- if (existsSync("AGENTS.md")) {
241
- return readFileSync("AGENTS.md", "utf-8");
242
- }
243
- return "";
244
- }
245
-
246
- /**
247
- * Build the initial system context (sent as first message, noReply).
248
- */
249
- function buildSystemContext(opts) {
250
- let ctx = "# Session Context (injected by mneme auto)\n\n";
251
- ctx +=
252
- "This session uses a dual-agent architecture:\n";
253
- ctx += ` - **Planner** (${opts.planner}): analyzes goals, breaks down tasks, reviews results, decides next steps\n`;
254
- ctx += ` - **Executor** (${opts.executor}): implements code changes, runs commands, updates beads\n\n`;
255
- ctx +=
256
- "The planner and executor alternate turns. Both agents can see the full conversation.\n";
257
- ctx +=
258
- "The planner should output structured instructions. The executor should follow them.\n\n";
259
-
260
- const agents = readAgentsRules();
261
- if (agents) {
262
- ctx += "## Agent Rules (AGENTS.md)\n\n" + agents + "\n\n";
263
- }
264
-
265
- const facts = readFacts();
266
- if (facts) {
267
- ctx += "## Long-term Facts (Ledger)\n\n" + facts + "\n\n";
268
- }
269
-
270
- return ctx;
271
- }
272
-
273
- /**
274
- * Build the planner's initial prompt for a bead.
275
- */
276
- function buildPlannerBeadPrompt(beadId) {
277
- const details = getBeadDetails(beadId);
278
- return `## Role: Planner
279
-
280
- You are the PLANNER. Your job is to analyze the task, break it into concrete steps, and give clear instructions to the Executor.
281
-
282
- ## Current Task (Bead: ${beadId})
283
-
284
- \`\`\`
285
- ${details}
286
- \`\`\`
287
-
288
- ## Instructions
289
-
290
- 1. Analyze this task carefully
291
- 2. Break it into specific, actionable steps
292
- 3. Give the Executor clear instructions for what to implement FIRST
293
- 4. Be specific about file paths, function names, and expected behavior
294
- 5. Use \`mneme update ${beadId} --notes="..."\` to track progress
295
- 6. When the task is fully complete, include "TASK_DONE" in your response
296
-
297
- Output your plan and the first instruction for the Executor.`;
298
- }
299
-
300
- /**
301
- * Build the planner's initial prompt for a user-specified goal.
302
- */
303
- function buildPlannerGoalPrompt(goal) {
304
- return `## Role: Planner
305
-
306
- You are the PLANNER. Your job is to analyze the goal, create a plan, and give clear instructions to the Executor.
307
-
308
- ## Goal
309
-
310
- > ${goal}
311
-
312
- ## Instructions
313
-
314
- 1. Check existing beads with \`mneme ready\` and \`mneme list --status=open\`
315
- 2. If this maps to an existing bead, claim it: \`mneme update <id> --status=in_progress\`
316
- 3. If not, create a new bead: \`mneme create --title="..." --description="..." --type=task -p 2\`
317
- 4. Break the goal into specific, actionable steps
318
- 5. Give the Executor clear instructions for what to implement FIRST
319
- 6. When all work is complete, include "TASK_DONE" in your response
320
-
321
- Output your plan and the first instruction for the Executor.`;
322
- }
323
-
324
- /**
325
- * Build the planner's review prompt (after seeing executor results).
326
- */
327
- function buildPlannerReviewPrompt(userFeedback) {
328
- let prompt = `## Role: Planner
329
-
330
- Review the Executor's work above. Then decide:
331
-
332
- 1. If more work is needed: give the next specific instruction for the Executor
333
- 2. If there were errors: explain what went wrong and how to fix it
334
- 3. If the task is fully complete: say "TASK_DONE" and summarize what was accomplished
335
-
336
- Be specific and actionable.`;
337
-
338
- if (userFeedback) {
339
- prompt += `\n\n## User Feedback\n\nThe user has provided input that takes priority:\n\n> ${userFeedback}\n\nIncorporate this into your next instruction.`;
340
- }
341
-
342
- return prompt;
343
- }
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
-
405
- /**
406
- * Build the executor's prompt (wrapping the planner's output).
407
- */
408
- function buildExecutorPrompt() {
409
- return `## Role: Executor
410
-
411
- You are the EXECUTOR. Follow the Planner's instructions above.
412
-
413
- Rules:
414
- - Implement exactly what the Planner asked for
415
- - Run tests/builds if the Planner requested it
416
- - Use \`mneme update <id> --notes="..."\` to record progress
417
- - Use \`mneme close <id> --reason="..."\` when told the task is done
418
- - Commit changes with clear messages
419
- - Report what you did when finished so the Planner can review`;
420
- }
421
-
422
- // ── User input handling (headless mode only) ────────────────────────────────
423
-
424
- /**
425
- * Non-blocking stdin reader with message queue.
426
- * Only used in --headless mode.
427
- */
428
- function createInputQueue() {
429
- const queue = [];
430
- let rl = null;
431
- let closed = false;
432
-
433
- function start() {
434
- if (!process.stdin.isTTY) return;
435
- rl = createInterface({
436
- input: process.stdin,
437
- output: process.stdout,
438
- prompt: "",
439
- });
440
-
441
- rl.on("line", (line) => {
442
- const trimmed = line.trim();
443
- if (!trimmed) return;
444
-
445
- if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/stop") {
446
- console.log(color.dim(" -> quitting after current turn..."));
447
- queue.push({ type: "quit" });
448
- } else if (trimmed === "/status") {
449
- queue.push({ type: "status" });
450
- } else if (trimmed === "/skip") {
451
- console.log(
452
- color.dim(" -> will skip current bead after this cycle"),
453
- );
454
- queue.push({ type: "skip" });
455
- } else if (trimmed === "/abort") {
456
- console.log(color.dim(" -> aborting current turn..."));
457
- queue.push({ type: "abort" });
458
- } else {
459
- queue.push({ type: "message", text: trimmed });
460
- console.log(
461
- color.dim(" -> queued, will send to planner next cycle"),
462
- );
463
- }
464
- });
465
-
466
- rl.on("close", () => {
467
- closed = true;
468
- });
469
- }
470
-
471
- function drain() {
472
- return queue.splice(0);
473
- }
474
-
475
- function pushBack(item) {
476
- queue.unshift(item);
477
- }
92
+ // ── Ensure Dolt is running ──────────────────────────────────────────────────
478
93
 
479
- function hasMessages() {
480
- return queue.length > 0;
94
+ function ensureDolt() {
95
+ if (!has("dolt")) {
96
+ log.warn("dolt is not installed — beads task management will not work");
97
+ return false;
481
98
  }
482
-
483
- function stop() {
484
- if (rl) {
485
- rl.close();
486
- rl = null;
99
+ if (!isPortOpen()) {
100
+ log.info("Starting Dolt server...");
101
+ const ok = startDoltServer();
102
+ if (!ok) {
103
+ log.warn("Failed to start Dolt server — beads tools may not work");
104
+ return false;
487
105
  }
488
- closed = true;
106
+ log.ok("Dolt server started");
489
107
  }
490
-
491
- return {
492
- start,
493
- drain,
494
- pushBack,
495
- hasMessages,
496
- stop,
497
- get closed() {
498
- return closed;
499
- },
500
- };
108
+ return true;
501
109
  }
502
110
 
503
- // ── Event display (streaming, headless mode) ────────────────────────────────
111
+ // ── Main ────────────────────────────────────────────────────────────────────
504
112
 
505
- /**
506
- * Subscribe to SSE events and display agent output in real-time.
507
- * Returns an object with methods to control display and detect turn completion.
508
- *
509
- * Also tracks `lastOutputTime` so callers can detect stalls.
510
- * Only used in --headless mode.
511
- */
512
- function createEventDisplay(client) {
513
- let running = false;
514
- let connected = false;
515
- let turnResolve = null;
516
- let currentRole = null;
517
- let lastOutputTime = 0;
518
- let hasReceivedAny = false;
519
- let abortController = null;
113
+ export async function auto(argv) {
114
+ const opts = parseArgs(argv);
520
115
 
521
- const printedTextLengths = new Map();
522
- const displayedToolStates = new Map();
523
- const deltaParts = new Set();
116
+ // Ensure Dolt is running for beads
117
+ ensureDolt();
524
118
 
525
- async function start() {
526
- running = true;
527
- abortController = new AbortController();
528
- try {
529
- const iterator = await client.events.subscribe({ signal: abortController.signal });
530
- connected = true;
531
- hasReceivedAny = false;
532
- log.ok("SSE event stream connected");
533
- for await (const event of iterator) {
534
- if (!running) break;
535
- if (!hasReceivedAny) hasReceivedAny = true;
536
- handleEvent(event);
537
- }
538
- } catch (err) {
539
- connected = false;
540
- if (running && err.name !== "AbortError") {
541
- console.error(
542
- color.dim(`\n [events] Stream error: ${err.message}`),
119
+ if (opts.headless) {
120
+ // Headless mode: opencode run
121
+ const args = ["run"];
122
+ if (opts.port) args.push("--port", String(opts.port));
123
+ if (opts.goal) {
124
+ args.push(opts.goal);
125
+ } else {
126
+ // No goal provided — ask interactively
127
+ const { createInterface } = await import("node:readline");
128
+ const rl = createInterface({
129
+ input: process.stdin,
130
+ output: process.stdout,
131
+ });
132
+ const goal = await new Promise((resolve) => {
133
+ rl.question(
134
+ `${color.cyan("Goal")} ${color.dim("(what should the agent work on?)")}\n> `,
135
+ (answer) => {
136
+ rl.close();
137
+ resolve(answer.trim());
138
+ },
543
139
  );
544
- await sleep(2000);
545
- if (running) {
546
- log.info("Reconnecting SSE...");
547
- start().catch(() => {});
548
- }
549
- }
550
- }
551
- }
552
-
553
- function handleEvent(event) {
554
- const type = event.type || "";
555
- const props = event.properties || {};
556
-
557
- switch (type) {
558
- case "message.part.delta": {
559
- const partId = props.partID || props.partId;
560
- if (partId) deltaParts.add(partId);
561
- if (props.field === "text" && props.delta) {
562
- process.stdout.write(props.delta);
563
- lastOutputTime = Date.now();
564
- }
565
- break;
566
- }
567
-
568
- case "message.part.updated": {
569
- if (!props.part) break;
570
- const part = props.part;
571
- const partId = part.id || `${props.messageID}-${props.index}`;
572
-
573
- if (
574
- part.type === "tool-invocation" ||
575
- part.type === "tool-result"
576
- ) {
577
- displayToolPart(part, partId);
578
- lastOutputTime = Date.now();
579
- }
580
- if (part.type === "text" && part.text) {
581
- const prev = printedTextLengths.get(partId) || 0;
582
- if (prev === 0 && !deltaParts.has(partId)) {
583
- process.stdout.write(part.text);
584
- lastOutputTime = Date.now();
585
- }
586
- printedTextLengths.set(partId, part.text.length);
587
- }
588
- break;
589
- }
590
-
591
- case "session.status": {
592
- const status = props.status?.type || props.status;
593
- if (status && status !== "busy" && status !== "pending") {
594
- if (turnResolve) {
595
- turnResolve(status);
596
- turnResolve = null;
597
- }
598
- }
599
- break;
600
- }
601
-
602
- case "session.updated": {
603
- const info = props.info || props.session || {};
604
- const status = info.status?.type || info.status;
605
- if (status && status !== "busy" && status !== "running" && status !== "pending") {
606
- if (turnResolve) {
607
- turnResolve(status);
608
- turnResolve = null;
609
- }
610
- }
611
- break;
612
- }
613
-
614
- case "message.updated": {
615
- const info = props.info || {};
616
- if (info.finish && info.finish !== "pending") {
617
- lastOutputTime = Date.now();
618
- }
619
- break;
140
+ });
141
+ if (!goal) {
142
+ log.fail("No goal provided. Exiting.");
143
+ process.exit(1);
620
144
  }
621
-
622
- default:
623
- break;
145
+ args.push(goal);
624
146
  }
625
- }
626
147
 
627
- function displayToolPart(part, partId) {
628
- const inv = part.toolInvocation || {};
629
- const toolName = inv.toolName || part.tool || "tool";
630
- const state = inv.state || part.state || "call";
631
- const lastState = displayedToolStates.get(partId);
632
-
633
- if (state === "call" && lastState !== "call") {
634
- const argsStr = summarizeArgs(inv.args);
148
+ log.info(`Running: opencode ${args.join(" ")}`);
149
+ const result = spawnSync("opencode", args, {
150
+ stdio: "inherit",
151
+ cwd: process.cwd(),
152
+ });
153
+ process.exit(result.status ?? 0);
154
+ } else {
155
+ // TUI mode: opencode [goal]
156
+ // If a goal is provided, we pass it as positional arg
157
+ // opencode TUI doesn't accept a message arg directly,
158
+ // so we start TUI and let the user type or the prompt.md guides the agent
159
+ const args = [];
160
+ if (opts.port) args.push("--port", String(opts.port));
161
+
162
+ if (opts.goal) {
163
+ // Use 'opencode run' even in "TUI" mode when goal is provided,
164
+ // or better: launch TUI and let the user paste the goal.
165
+ // Actually, opencode TUI doesn't take an initial message.
166
+ // So for goal-based usage, we should tell the user.
635
167
  console.log(
636
- `\n${color.bold(` * ${toolName}`)}${argsStr ? color.dim(` ${argsStr}`) : ""}`,
168
+ `\n${color.bold("Goal:")} ${opts.goal}\n` +
169
+ `${color.dim("Paste the goal into the TUI prompt to begin.\n")}`,
637
170
  );
638
- displayedToolStates.set(partId, "call");
639
- } else if (state === "result" && lastState !== "result") {
640
- const result = inv.result ?? part.result ?? "";
641
- const resultStr =
642
- typeof result === "string" ? result : JSON.stringify(result);
643
- if (resultStr) {
644
- const lines = resultStr.split("\n");
645
- const preview = lines.slice(0, 8);
646
- if (lines.length > 8)
647
- preview.push(
648
- color.dim(` ... (${lines.length - 8} more lines)`),
649
- );
650
- console.log(
651
- color.dim(preview.map((l) => " " + l).join("\n")),
652
- );
653
- }
654
- displayedToolStates.set(partId, "result");
655
- }
656
- }
657
-
658
- function summarizeArgs(args) {
659
- if (!args) return "";
660
- if (typeof args === "string") {
661
- return args.length > 80 ? args.slice(0, 80) + "..." : args;
662
- }
663
- const pairs = [];
664
- for (const [k, v] of Object.entries(args)) {
665
- const val =
666
- typeof v === "string"
667
- ? v.length > 50
668
- ? v.slice(0, 50) + "..."
669
- : v
670
- : JSON.stringify(v);
671
- pairs.push(`${k}=${val}`);
672
- }
673
- const str = pairs.join(" ");
674
- return str.length > 120 ? str.slice(0, 120) + "..." : str;
675
- }
676
-
677
- function waitForTurnEnd() {
678
- return new Promise((resolve) => {
679
- turnResolve = resolve;
680
- });
681
- }
682
-
683
- function resetTurn(role) {
684
- currentRole = role;
685
- printedTextLengths.clear();
686
- displayedToolStates.clear();
687
- deltaParts.clear();
688
- }
689
-
690
- function stop() {
691
- running = false;
692
- if (abortController) {
693
- abortController.abort();
694
- abortController = null;
695
171
  }
696
- if (turnResolve) {
697
- turnResolve("stopped");
698
- turnResolve = null;
699
- }
700
- }
701
-
702
- return {
703
- start,
704
- stop,
705
- waitForTurnEnd,
706
- resetTurn,
707
- get lastOutputTime() { return lastOutputTime; },
708
- get connected() { return connected; },
709
- get hasReceivedAny() { return hasReceivedAny; },
710
- };
711
- }
712
-
713
- // ── Daemon event monitor (silent, for daemon mode) ──────────────────────────
714
-
715
- /**
716
- * SSE listener for daemon mode — tracks turn completion and detects
717
- * user-initiated messages, but produces NO stdout output.
718
- * All logging goes to file.
719
- */
720
- function createDaemonEventMonitor(client, dlog) {
721
- let running = false;
722
- let connected = false;
723
- let turnResolve = null;
724
- let lastOutputTime = 0;
725
- let hasReceivedAny = false;
726
- let abortController = null;
727
-
728
- // Track known prompt texts we sent — to distinguish user messages
729
- const knownPromptTexts = new Set();
730
-
731
- // Callback for user message detection
732
- let onUserMessage = null;
733
-
734
- async function start() {
735
- running = true;
736
- abortController = new AbortController();
737
- try {
738
- const iterator = await client.events.subscribe({ signal: abortController.signal });
739
- connected = true;
740
- hasReceivedAny = false;
741
- dlog.ok("SSE event stream connected (daemon)");
742
- for await (const event of iterator) {
743
- if (!running) break;
744
- if (!hasReceivedAny) hasReceivedAny = true;
745
- handleEvent(event);
746
- }
747
- } catch (err) {
748
- connected = false;
749
- if (running && err.name !== "AbortError") {
750
- dlog.warn(`SSE stream error: ${err.message}`);
751
- await sleep(2000);
752
- if (running) {
753
- dlog.info("Reconnecting SSE...");
754
- start().catch(() => {});
755
- }
756
- }
757
- }
758
- }
759
172
 
760
- function handleEvent(event) {
761
- const type = event.type || "";
762
- const props = event.properties || {};
763
-
764
- switch (type) {
765
- case "message.part.delta": {
766
- // Just track that output is happening
767
- if (props.field === "text" && props.delta) {
768
- lastOutputTime = Date.now();
769
- }
770
- break;
771
- }
772
-
773
- case "message.part.updated": {
774
- if (props.part) {
775
- lastOutputTime = Date.now();
776
- }
777
- break;
778
- }
779
-
780
- case "session.status": {
781
- const status = props.status?.type || props.status;
782
- if (status && status !== "busy" && status !== "pending") {
783
- if (turnResolve) {
784
- turnResolve(status);
785
- turnResolve = null;
786
- }
787
- }
788
- break;
789
- }
790
-
791
- case "session.updated": {
792
- const info = props.info || props.session || {};
793
- const status = info.status?.type || info.status;
794
- if (status && status !== "busy" && status !== "running" && status !== "pending") {
795
- if (turnResolve) {
796
- turnResolve(status);
797
- turnResolve = null;
798
- }
799
- }
800
- break;
801
- }
802
-
803
- case "message.updated": {
804
- const info = props.info || {};
805
- // Detect user-initiated messages: role === "user" with text we didn't send
806
- const role = info.role;
807
- if (role === "user" && info.parts) {
808
- const text = info.parts
809
- .filter((p) => p.type === "text")
810
- .map((p) => p.text || "")
811
- .join("\n")
812
- .trim();
813
- if (text && !knownPromptTexts.has(text)) {
814
- dlog.info(`User message detected: "${text.slice(0, 80)}..."`);
815
- if (onUserMessage) onUserMessage(text);
816
- }
817
- }
818
- if (info.finish && info.finish !== "pending") {
819
- lastOutputTime = Date.now();
820
- }
821
- break;
822
- }
823
-
824
- default:
825
- break;
826
- }
827
- }
173
+ log.info("Launching opencode TUI...");
174
+ console.log(
175
+ color.dim(
176
+ " Agents: Sisyphus, Hephaestus, Prometheus, Atlas (Tab to switch)",
177
+ ),
178
+ );
179
+ console.log(
180
+ color.dim(" Mneme tools: mneme_ready, mneme_facts, mneme_update, ..."),
181
+ );
182
+ console.log();
828
183
 
829
- function waitForTurnEnd() {
830
- return new Promise((resolve) => {
831
- turnResolve = resolve;
184
+ const result = spawnSync("opencode", args, {
185
+ stdio: "inherit",
186
+ cwd: process.cwd(),
832
187
  });
188
+ process.exit(result.status ?? 0);
833
189
  }
834
-
835
- function resetTurn() {
836
- // Nothing visual to reset in daemon mode
837
- }
838
-
839
- function registerPrompt(text) {
840
- // Register a prompt text so we can distinguish our prompts from user's
841
- knownPromptTexts.add(text.trim());
842
- }
843
-
844
- function stop() {
845
- running = false;
846
- if (abortController) {
847
- abortController.abort();
848
- abortController = null;
849
- }
850
- if (turnResolve) {
851
- turnResolve("stopped");
852
- turnResolve = null;
853
- }
854
- }
855
-
856
- return {
857
- start,
858
- stop,
859
- waitForTurnEnd,
860
- resetTurn,
861
- registerPrompt,
862
- set onUserMessage(fn) { onUserMessage = fn; },
863
- get onUserMessage() { return onUserMessage; },
864
- get lastOutputTime() { return lastOutputTime; },
865
- get connected() { return connected; },
866
- get hasReceivedAny() { return hasReceivedAny; },
867
- };
868
- }
869
-
870
- // ── Turn execution (headless mode) ──────────────────────────────────────────
871
-
872
- /**
873
- * Send a message and wait for the turn to complete (headless mode).
874
- * Handles /abort and /quit from input queue during execution.
875
- * Prints heartbeat every 15s when no output is flowing.
876
- *
877
- * @returns {{ status: string, aborted: boolean, quit: boolean }}
878
- */
879
- async function executeTurnHeadless(
880
- client,
881
- sessionId,
882
- prompt,
883
- modelSpec,
884
- eventDisplay,
885
- inputQueue,
886
- ) {
887
- eventDisplay.resetTurn();
888
-
889
- const body = {
890
- parts: [{ type: "text", text: prompt }],
891
- };
892
- if (modelSpec) {
893
- body.model = modelSpec;
894
- }
895
-
896
- await client.session.promptAsync(sessionId, body);
897
-
898
- // Quick check for immediate model errors
899
- await sleep(2000);
900
- try {
901
- const sessions = await client.session.list();
902
- const s = sessions?.find?.((ss) => ss.id === sessionId);
903
- if (s && s.status && s.status !== "running" && s.status !== "pending") {
904
- const msgs = await client.session.messages(sessionId);
905
- const lastMsg = msgs?.[msgs.length - 1];
906
- const errInfo = lastMsg?.info?.error;
907
- if (errInfo) {
908
- const errMsg = errInfo.data?.message || errInfo.name || "unknown";
909
- log.fail(`Model error: ${errMsg}`);
910
- return { status: "error", aborted: true, quit: false };
911
- }
912
- }
913
- } catch {
914
- // Ignore probe failures
915
- }
916
-
917
- const HEARTBEAT_INTERVAL = 15_000;
918
- const SILENCE_WARN = 30_000;
919
- const SILENCE_ABORT = 120_000;
920
- const turnStartTime = Date.now();
921
-
922
- return new Promise((resolve) => {
923
- let resolved = false;
924
- let warnedSilence = false;
925
-
926
- const done = (result) => {
927
- if (resolved) return;
928
- resolved = true;
929
- clearInterval(pollId);
930
- clearInterval(heartbeatId);
931
- resolve(result);
932
- };
933
-
934
- eventDisplay.waitForTurnEnd().then((status) => {
935
- done({ status, aborted: false, quit: false });
936
- });
937
-
938
- const heartbeatId = setInterval(() => {
939
- if (resolved) return;
940
- const now = Date.now();
941
- const elapsed = Math.round((now - turnStartTime) / 1000);
942
- const lastOut = eventDisplay.lastOutputTime;
943
- const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
944
-
945
- if (silenceMs >= SILENCE_ABORT) {
946
- console.log(
947
- color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s, auto-aborting`),
948
- );
949
- client.session.abort(sessionId).catch(() => {});
950
- done({ status: "aborted", aborted: true, quit: false });
951
- return;
952
- }
953
-
954
- if (silenceMs >= SILENCE_WARN && !warnedSilence) {
955
- warnedSilence = true;
956
- console.log(
957
- color.dim(`\n [${elapsed}s] no output for ${Math.round(silenceMs / 1000)}s (will abort at ${SILENCE_ABORT / 1000}s)`),
958
- );
959
- return;
960
- }
961
-
962
- if (silenceMs >= HEARTBEAT_INTERVAL) {
963
- process.stdout.write(
964
- color.dim(` [${elapsed}s] `),
965
- );
966
- }
967
- }, HEARTBEAT_INTERVAL);
968
-
969
- const pollId = setInterval(() => {
970
- if (!inputQueue.hasMessages()) return;
971
- const items = inputQueue.drain();
972
- for (const item of items) {
973
- if (item.type === "quit") {
974
- client.session.abort(sessionId).catch(() => {});
975
- done({ status: "quit", aborted: false, quit: true });
976
- return;
977
- }
978
- if (item.type === "abort") {
979
- client.session.abort(sessionId).catch(() => {});
980
- done({ status: "aborted", aborted: true, quit: false });
981
- return;
982
- }
983
- if (item.type === "status") {
984
- showBeadStatus();
985
- }
986
- if (item.type === "message" || item.type === "skip") {
987
- inputQueue.pushBack(item);
988
- }
989
- }
990
- }, 200);
991
- });
992
- }
993
-
994
- // ── Turn execution (daemon mode) ────────────────────────────────────────────
995
-
996
- /**
997
- * Send a message and wait for the turn to complete (daemon mode).
998
- * No stdout output — all logging to file. No input queue.
999
- *
1000
- * @returns {{ status: string, aborted: boolean }}
1001
- */
1002
- async function executeTurnDaemon(
1003
- client,
1004
- sessionId,
1005
- prompt,
1006
- modelSpec,
1007
- monitor,
1008
- dlog,
1009
- ) {
1010
- monitor.resetTurn();
1011
- monitor.registerPrompt(prompt);
1012
-
1013
- const body = {
1014
- parts: [{ type: "text", text: prompt }],
1015
- };
1016
- if (modelSpec) {
1017
- body.model = modelSpec;
1018
- }
1019
-
1020
- await client.session.promptAsync(sessionId, body);
1021
-
1022
- // Quick check for immediate model errors
1023
- await sleep(2000);
1024
- try {
1025
- const sessions = await client.session.list();
1026
- const s = sessions?.find?.((ss) => ss.id === sessionId);
1027
- if (s && s.status && s.status !== "running" && s.status !== "pending") {
1028
- const msgs = await client.session.messages(sessionId);
1029
- const lastMsg = msgs?.[msgs.length - 1];
1030
- const errInfo = lastMsg?.info?.error;
1031
- if (errInfo) {
1032
- const errMsg = errInfo.data?.message || errInfo.name || "unknown";
1033
- dlog.fail(`Model error: ${errMsg}`);
1034
- return { status: "error", aborted: true };
1035
- }
1036
- }
1037
- } catch {
1038
- // Ignore probe failures
1039
- }
1040
-
1041
- const SILENCE_ABORT = 120_000;
1042
- const turnStartTime = Date.now();
1043
-
1044
- return new Promise((resolve) => {
1045
- let resolved = false;
1046
-
1047
- const done = (result) => {
1048
- if (resolved) return;
1049
- resolved = true;
1050
- clearInterval(silenceCheckId);
1051
- resolve(result);
1052
- };
1053
-
1054
- monitor.waitForTurnEnd().then((status) => {
1055
- done({ status, aborted: false });
1056
- });
1057
-
1058
- // Check for silence timeouts
1059
- const silenceCheckId = setInterval(() => {
1060
- if (resolved) return;
1061
- const now = Date.now();
1062
- const elapsed = Math.round((now - turnStartTime) / 1000);
1063
- const lastOut = monitor.lastOutputTime;
1064
- const silenceMs = lastOut > 0 ? now - lastOut : now - turnStartTime;
1065
-
1066
- if (silenceMs >= SILENCE_ABORT) {
1067
- dlog.warn(`${elapsed}s elapsed, no output for ${Math.round(silenceMs / 1000)}s — auto-aborting`);
1068
- client.session.abort(sessionId).catch(() => {});
1069
- done({ status: "aborted", aborted: true });
1070
- }
1071
- }, 15_000);
1072
- });
1073
- }
1074
-
1075
- // ── Status display (headless only) ─────────────────────────────────────────
1076
-
1077
- function showBeadStatus() {
1078
- console.log(`\n${color.bold("-- Status --")}`);
1079
- const ready = run("bd ready") || " (none)";
1080
- const inProgress = run("bd list --status=in_progress") || " (none)";
1081
- console.log(` ${color.bold("Ready:")} ${ready}`);
1082
- console.log(` ${color.bold("In Progress:")} ${inProgress}`);
1083
- console.log("");
1084
- }
1085
-
1086
- // ── Supervisor loop (headless mode — original CLI) ──────────────────────────
1087
-
1088
- async function supervisorLoopHeadless(client, opts, inputQueue) {
1089
- const plannerModel = parseModelSpec(opts.planner);
1090
- const executorModel = parseModelSpec(opts.executor);
1091
-
1092
- log.info("Creating session...");
1093
- const session = await client.session.create({ title: "mneme auto" });
1094
- const sessionId = session.id;
1095
- log.ok(`Session: ${sessionId}`);
1096
-
1097
- // Validate models
1098
- log.info("Validating models (API probe)...");
1099
- await validateModels(client, sessionId, opts, log);
1100
-
1101
- // Start SSE event display
1102
- const eventDisplay = createEventDisplay(client);
1103
- eventDisplay.start().catch(() => {});
1104
-
1105
- // Inject system context
1106
- const systemContext = buildSystemContext(opts);
1107
- try {
1108
- await client.session.prompt(sessionId, {
1109
- noReply: true,
1110
- parts: [{ type: "text", text: systemContext }],
1111
- });
1112
- log.ok("Context injected");
1113
- } catch (err) {
1114
- log.warn(`Context injection: ${err.message}`);
1115
- }
1116
-
1117
- let cycle = 0;
1118
- let startMode = "beads"; // default: pick from beads
1119
-
1120
- // If no explicit goal, ask user whether to pick from beads or discuss a plan.
1121
- if (!opts.goal) {
1122
- const choice = await askStartModeHeadless(inputQueue);
1123
- if (choice === "quit") {
1124
- eventDisplay.stop();
1125
- return;
1126
- }
1127
- startMode = choice; // "beads" or "discuss"
1128
- }
1129
-
1130
- // If user chose to discuss, enter goal discussion before main loop.
1131
- if (startMode === "discuss") {
1132
- const goResult = await goalDiscussionHeadless(
1133
- client, sessionId, plannerModel, eventDisplay, inputQueue,
1134
- );
1135
- if (!goResult) {
1136
- log.info("User quit during goal discussion.");
1137
- eventDisplay.stop();
1138
- return;
1139
- }
1140
- // Goal discussion complete — planner finalize prompt has produced
1141
- // the first executor instruction. Jump straight to executor turn.
1142
- cycle++;
1143
- console.log(
1144
- `\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
1145
- );
1146
- const executorPrompt = buildExecutorPrompt();
1147
- log.info(`Sending prompt to Executor (${opts.executor})...`);
1148
- eventDisplay.resetTurn("executor");
1149
- const executorResult = await executeTurnHeadless(
1150
- client, sessionId, executorPrompt, executorModel,
1151
- eventDisplay, inputQueue,
1152
- );
1153
- console.log("");
1154
- if (executorResult.quit) { log.info("User requested quit."); eventDisplay.stop(); return; }
1155
- if (executorResult.aborted) { log.info("Executor turn aborted."); }
1156
- await sleep(1000);
1157
- // Fall through to the main loop (cycle is now 1, will get planner review)
1158
- }
1159
-
1160
- try {
1161
- while (cycle < opts.maxCycles) {
1162
- let userFeedback = null;
1163
- let shouldSkip = false;
1164
-
1165
- if (inputQueue.hasMessages()) {
1166
- const items = inputQueue.drain();
1167
- for (const item of items) {
1168
- if (item.type === "quit") {
1169
- log.info("User requested quit.");
1170
- return;
1171
- }
1172
- if (item.type === "skip") shouldSkip = true;
1173
- if (item.type === "status") showBeadStatus();
1174
- if (item.type === "message") userFeedback = item.text;
1175
- }
1176
- }
1177
-
1178
- if (shouldSkip) {
1179
- log.info("Skipping current bead...");
1180
- }
1181
-
1182
- let plannerPrompt = null;
1183
-
1184
- if (cycle === 0) {
1185
- if (opts.goal) {
1186
- plannerPrompt = buildPlannerGoalPrompt(opts.goal);
1187
- } else {
1188
- // No explicit goal, but beads exist — pick from beads
1189
- plannerPrompt = pickBeadForPlanner(log);
1190
- }
1191
- } else {
1192
- plannerPrompt = buildPlannerReviewPrompt(userFeedback);
1193
- }
1194
-
1195
- if (!plannerPrompt) {
1196
- const open = getOpenBeads();
1197
- if (open.length === 0) {
1198
- log.ok("All beads completed! Nothing left to do.");
1199
- break;
1200
- }
1201
- log.warn("All beads blocked. Waiting for user input...");
1202
- console.log(color.dim(" Type a message or /quit to exit."));
1203
- await waitForInput(inputQueue);
1204
- continue;
1205
- }
1206
-
1207
- cycle++;
1208
-
1209
- // Planner turn
1210
- console.log(
1211
- `\n${color.bold(`-- Cycle ${cycle} / Planner`)} ${color.dim(`(${opts.planner})`)} ${color.bold("--------------------")}`,
1212
- );
1213
-
1214
- log.info(`Sending prompt to Planner (${opts.planner})...`);
1215
- eventDisplay.resetTurn("planner");
1216
- const plannerResult = await executeTurnHeadless(
1217
- client, sessionId, plannerPrompt, plannerModel,
1218
- eventDisplay, inputQueue,
1219
- );
1220
- console.log("");
1221
-
1222
- if (plannerResult.quit) { log.info("User requested quit."); return; }
1223
- if (plannerResult.aborted) { log.info("Planner turn aborted."); continue; }
1224
-
1225
- // Check TASK_DONE
1226
- let plannerSaidDone = false;
1227
- try {
1228
- const messages = await client.session.messages(sessionId);
1229
- if (messages && messages.length > 0) {
1230
- const lastMsg = messages[messages.length - 1];
1231
- const text = extractMessageText(lastMsg);
1232
- if (text.includes("TASK_DONE")) plannerSaidDone = true;
1233
- }
1234
- } catch { /* proceed */ }
1235
-
1236
- if (plannerSaidDone) {
1237
- log.ok("Planner declared task complete.");
1238
- cycle = 0;
1239
- const nextBead = pickBeadForPlanner(log);
1240
- if (!nextBead) { log.ok("No more tasks. Finished."); break; }
1241
- continue;
1242
- }
1243
-
1244
- // Executor turn
1245
- console.log(
1246
- `\n${color.bold(`-- Cycle ${cycle} / Executor`)} ${color.dim(`(${opts.executor})`)} ${color.bold("-------------------")}`,
1247
- );
1248
-
1249
- const executorPrompt = buildExecutorPrompt();
1250
- log.info(`Sending prompt to Executor (${opts.executor})...`);
1251
- eventDisplay.resetTurn("executor");
1252
- const executorResult = await executeTurnHeadless(
1253
- client, sessionId, executorPrompt, executorModel,
1254
- eventDisplay, inputQueue,
1255
- );
1256
- console.log("");
1257
-
1258
- if (executorResult.quit) { log.info("User requested quit."); return; }
1259
- if (executorResult.aborted) { log.info("Executor turn aborted."); }
1260
-
1261
- await sleep(1000);
1262
- }
1263
-
1264
- if (cycle >= opts.maxCycles) {
1265
- log.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
1266
- }
1267
- } finally {
1268
- eventDisplay.stop();
1269
- }
1270
- }
1271
-
1272
- // ── Supervisor loop (daemon mode) ───────────────────────────────────────────
1273
-
1274
- async function supervisorLoopDaemon(client, sessionId, opts, dlog) {
1275
- const plannerModel = parseModelSpec(opts.planner);
1276
- const executorModel = parseModelSpec(opts.executor);
1277
-
1278
- // Start SSE event monitor (silent)
1279
- const monitor = createDaemonEventMonitor(client, dlog);
1280
-
1281
- // Track whether user is interacting via TUI
1282
- let userInteracting = false;
1283
- let userTurnResolve = null;
1284
-
1285
- monitor.onUserMessage = (text) => {
1286
- dlog.info(`User typed in TUI, pausing auto loop...`);
1287
- userInteracting = true;
1288
- // The user message triggers a model response. We need to wait for
1289
- // that response to complete before resuming our auto loop.
1290
- // The next session.status=idle will signal completion.
1291
- };
1292
-
1293
- monitor.start().catch(() => {});
1294
-
1295
- // Inject system context
1296
- const systemContext = buildSystemContext(opts);
1297
- monitor.registerPrompt(systemContext);
1298
- try {
1299
- await client.session.prompt(sessionId, {
1300
- noReply: true,
1301
- parts: [{ type: "text", text: systemContext }],
1302
- });
1303
- dlog.ok("Context injected");
1304
- } catch (err) {
1305
- dlog.warn(`Context injection: ${err.message}`);
1306
- }
1307
-
1308
- let cycle = 0;
1309
-
1310
- // If no explicit goal, enter goal discussion with planner.
1311
- // The discovery prompt lists existing beads (if any) and suggestions.
1312
- // User discusses in TUI, types /go when ready. If they want to pick
1313
- // from beads, the planner will incorporate that into its plan.
1314
- if (!opts.goal) {
1315
- dlog.info("No goal specified — entering goal discussion with Planner.");
1316
- const goResult = await goalDiscussionDaemon(
1317
- client, sessionId, plannerModel, monitor, dlog,
1318
- );
1319
- if (!goResult) {
1320
- dlog.info("Goal discussion ended without /go. Exiting.");
1321
- monitor.stop();
1322
- return;
1323
- }
1324
- // Goal discussion complete — planner finalize prompt has produced
1325
- // the first executor instruction. Jump straight to executor turn.
1326
- cycle++;
1327
- dlog.info(`Cycle ${cycle} / Executor (${opts.executor}) [post-goal-discussion]`);
1328
- const executorPrompt = buildExecutorPrompt();
1329
- const executorResult = await executeTurnDaemon(
1330
- client, sessionId, executorPrompt, executorModel, monitor, dlog,
1331
- );
1332
- if (executorResult.aborted) {
1333
- dlog.warn("Executor turn aborted.");
1334
- }
1335
- await sleep(1000);
1336
- // Fall through to the main loop (cycle is now 1, will get planner review)
1337
- }
1338
-
1339
- try {
1340
- while (cycle < opts.maxCycles) {
1341
- // If user is interacting, wait for the model to finish responding
1342
- // to their message before we send the next auto prompt
1343
- if (userInteracting) {
1344
- dlog.info("Waiting for user's turn to complete...");
1345
- await monitor.waitForTurnEnd();
1346
- userInteracting = false;
1347
- dlog.info("User turn complete, resuming auto loop.");
1348
- // After user intervention, planner should review
1349
- // (fall through to planner review prompt)
1350
- }
1351
-
1352
- let plannerPrompt = null;
1353
-
1354
- if (cycle === 0) {
1355
- if (opts.goal) {
1356
- plannerPrompt = buildPlannerGoalPrompt(opts.goal);
1357
- } else {
1358
- // No explicit goal, but beads exist — pick from beads
1359
- plannerPrompt = pickBeadForPlanner(dlog);
1360
- }
1361
- } else {
1362
- plannerPrompt = buildPlannerReviewPrompt(null);
1363
- }
1364
-
1365
- if (!plannerPrompt) {
1366
- const open = getOpenBeads();
1367
- if (open.length === 0) {
1368
- dlog.ok("All beads completed! Nothing left to do.");
1369
- break;
1370
- }
1371
- dlog.warn("All beads blocked. Waiting 30s before retry...");
1372
- await sleep(30_000);
1373
- continue;
1374
- }
1375
-
1376
- cycle++;
1377
-
1378
- // Planner turn
1379
- dlog.info(`Cycle ${cycle} / Planner (${opts.planner})`);
1380
- const plannerResult = await executeTurnDaemon(
1381
- client, sessionId, plannerPrompt, plannerModel, monitor, dlog,
1382
- );
1383
-
1384
- if (plannerResult.aborted) {
1385
- dlog.warn("Planner turn aborted.");
1386
- continue;
1387
- }
1388
-
1389
- // Check TASK_DONE
1390
- let plannerSaidDone = false;
1391
- try {
1392
- const messages = await client.session.messages(sessionId);
1393
- if (messages && messages.length > 0) {
1394
- const lastMsg = messages[messages.length - 1];
1395
- const text = extractMessageText(lastMsg);
1396
- if (text.includes("TASK_DONE")) plannerSaidDone = true;
1397
- }
1398
- } catch { /* proceed */ }
1399
-
1400
- if (plannerSaidDone) {
1401
- dlog.ok("Planner declared task complete.");
1402
- cycle = 0;
1403
- const nextBead = pickBeadForPlanner(dlog);
1404
- if (!nextBead) { dlog.ok("No more tasks. Finished."); break; }
1405
- continue;
1406
- }
1407
-
1408
- // Executor turn
1409
- dlog.info(`Cycle ${cycle} / Executor (${opts.executor})`);
1410
- const executorPrompt = buildExecutorPrompt();
1411
- const executorResult = await executeTurnDaemon(
1412
- client, sessionId, executorPrompt, executorModel, monitor, dlog,
1413
- );
1414
-
1415
- if (executorResult.aborted) {
1416
- dlog.warn("Executor turn aborted.");
1417
- }
1418
-
1419
- await sleep(1000);
1420
- }
1421
-
1422
- if (cycle >= opts.maxCycles) {
1423
- dlog.warn(`Reached max cycles (${opts.maxCycles}). Stopping.`);
1424
- }
1425
- } finally {
1426
- monitor.stop();
1427
- }
1428
- }
1429
-
1430
- // ── Shared helpers ──────────────────────────────────────────────────────────
1431
-
1432
- /**
1433
- * Validate models by sending a real test prompt.
1434
- * Works with both console logger (log) and file logger (dlog).
1435
- */
1436
- async function validateModels(client, sessionId, opts, logger) {
1437
- const plannerModel = parseModelSpec(opts.planner);
1438
- const executorModel = parseModelSpec(opts.executor);
1439
- const probeModels = [
1440
- { label: "Planner", spec: opts.planner, parsed: plannerModel },
1441
- { label: "Executor", spec: opts.executor, parsed: executorModel },
1442
- ];
1443
- const seen = new Set();
1444
- for (const m of probeModels) {
1445
- if (seen.has(m.spec)) continue;
1446
- seen.add(m.spec);
1447
- try {
1448
- const result = await client.session.prompt(sessionId, {
1449
- parts: [{ type: "text", text: "Say OK" }],
1450
- model: m.parsed,
1451
- });
1452
- const err = result?.info?.error;
1453
- if (err) {
1454
- const msg = err.data?.message || err.name || "unknown error";
1455
- logger.fail(`${m.label} model "${m.spec}" rejected by provider: ${msg}`);
1456
- throw new Error(`${m.label} model unavailable: ${msg}`);
1457
- }
1458
- logger.ok(`${m.label} model verified: ${m.spec}`);
1459
- } catch (probeErr) {
1460
- if (probeErr.message.includes("unavailable") || probeErr.message.includes("rejected")) {
1461
- throw probeErr;
1462
- }
1463
- logger.warn(`${m.label} model probe inconclusive: ${probeErr.message}`);
1464
- }
1465
- }
1466
- }
1467
-
1468
- /**
1469
- * Try to pick a bead and return a planner prompt for it.
1470
- * Returns null if no beads available.
1471
- */
1472
- function pickBeadForPlanner(logger) {
1473
- const inProgress = getInProgressBeads();
1474
- if (inProgress.length > 0) {
1475
- const beadId = extractBeadId(inProgress[0]);
1476
- if (beadId) {
1477
- logger.info(`Resuming: ${beadId}`);
1478
- return buildPlannerBeadPrompt(beadId);
1479
- }
1480
- }
1481
-
1482
- const ready = getReadyBeads();
1483
- if (ready.length === 0) return null;
1484
-
1485
- const beadId = extractBeadId(ready[0]);
1486
- if (!beadId) return null;
1487
-
1488
- run(`bd update ${beadId} --status=in_progress`);
1489
- logger.info(`Picked: ${beadId}`);
1490
- return buildPlannerBeadPrompt(beadId);
1491
- }
1492
-
1493
- function extractBeadId(bead) {
1494
- return bead.id || bead.raw?.match(/([\w-]+)/)?.[1] || null;
1495
- }
1496
-
1497
- function extractMessageText(msg) {
1498
- if (!msg) return "";
1499
- if (typeof msg === "string") return msg;
1500
- if (msg.content) {
1501
- if (typeof msg.content === "string") return msg.content;
1502
- if (Array.isArray(msg.content)) {
1503
- return msg.content
1504
- .filter((p) => p.type === "text")
1505
- .map((p) => p.text || "")
1506
- .join("\n");
1507
- }
1508
- }
1509
- if (msg.parts) {
1510
- return msg.parts
1511
- .filter((p) => p.type === "text")
1512
- .map((p) => p.text || "")
1513
- .join("\n");
1514
- }
1515
- return "";
1516
- }
1517
-
1518
- async function waitForInput(inputQueue) {
1519
- while (!inputQueue.hasMessages() && !inputQueue.closed) {
1520
- await sleep(500);
1521
- }
1522
- }
1523
-
1524
- /**
1525
- * Ask the user whether to pick from beads or discuss a plan (headless mode).
1526
- * Shows current beads state and waits for the user to choose.
1527
- *
1528
- * @returns {"beads"|"discuss"|"quit"}
1529
- */
1530
- async function askStartModeHeadless(inputQueue) {
1531
- const inProgress = getInProgressBeads();
1532
- const ready = getReadyBeads();
1533
- const hasBeads = inProgress.length > 0 || ready.length > 0;
1534
-
1535
- console.log(`\n${color.bold("-- What would you like to do?")} ${color.bold("--")}`);
1536
- if (hasBeads) {
1537
- if (inProgress.length > 0) {
1538
- console.log(color.dim(` In-progress beads: ${inProgress.length}`));
1539
- }
1540
- if (ready.length > 0) {
1541
- console.log(color.dim(` Ready beads: ${ready.length}`));
1542
- }
1543
- } else {
1544
- console.log(color.dim(" No beads available."));
1545
- }
1546
- console.log("");
1547
- if (hasBeads) {
1548
- console.log(` ${color.bold("1")} Pick from beads (auto-select a task)`);
1549
- }
1550
- console.log(` ${color.bold("2")} Discuss with Planner (plan before execution)`);
1551
- console.log(color.dim("\n Type 1 or 2, or /quit to exit.\n"));
1552
-
1553
- while (true) {
1554
- await waitForInput(inputQueue);
1555
- const items = inputQueue.drain();
1556
- for (const item of items) {
1557
- if (item.type === "quit") return "quit";
1558
- if (item.type === "message") {
1559
- const t = item.text.trim();
1560
- if (t === "1" && hasBeads) return "beads";
1561
- if (t === "2") return "discuss";
1562
- console.log(color.dim(` Please type ${hasBeads ? "1 or 2" : "2"}, or /quit to exit.`));
1563
- }
1564
- }
1565
- }
1566
- }
1567
-
1568
- // ── Goal discussion (headless mode) ─────────────────────────────────────────
1569
-
1570
- /**
1571
- * Interactive goal discussion in headless mode.
1572
- * Planner suggests goals, user discusses, user types /go to proceed.
1573
- *
1574
- * @returns {boolean} true if /go was received and discussion completed,
1575
- * false if user quit.
1576
- */
1577
- async function goalDiscussionHeadless(client, sessionId, plannerModel, eventDisplay, inputQueue) {
1578
- console.log(
1579
- `\n${color.bold("-- Goal Discussion")} ${color.dim("(type /go when ready to start execution)")} ${color.bold("--")}`,
1580
- );
1581
- console.log(color.dim(" Discuss with the Planner what to work on. Type /go to begin.\n"));
1582
-
1583
- // Send discovery prompt to planner
1584
- const discoveryPrompt = buildPlannerDiscoveryPrompt();
1585
- log.info("Sending discovery prompt to Planner...");
1586
- eventDisplay.resetTurn("planner");
1587
- const discoveryResult = await executeTurnHeadless(
1588
- client, sessionId, discoveryPrompt, plannerModel,
1589
- eventDisplay, inputQueue,
1590
- );
1591
- console.log("");
1592
-
1593
- if (discoveryResult.quit) return false;
1594
- if (discoveryResult.aborted) {
1595
- log.warn("Discovery prompt aborted. Retrying...");
1596
- }
1597
-
1598
- // Discussion loop: user talks to planner until /go
1599
- while (true) {
1600
- // Wait for user input
1601
- console.log(color.dim("\n [waiting for your input... type /go to start, /quit to exit]\n"));
1602
- await waitForInput(inputQueue);
1603
-
1604
- const items = inputQueue.drain();
1605
- let userText = null;
1606
- let goReceived = false;
1607
- let quitReceived = false;
1608
-
1609
- for (const item of items) {
1610
- if (item.type === "quit") {
1611
- quitReceived = true;
1612
- break;
1613
- }
1614
- if (item.type === "message") {
1615
- // Check if the user typed /go
1616
- if (item.text.toLowerCase().trim() === "/go") {
1617
- goReceived = true;
1618
- break;
1619
- }
1620
- userText = item.text;
1621
- }
1622
- if (item.type === "abort") {
1623
- // Ignore /abort during discussion
1624
- }
1625
- if (item.type === "status") {
1626
- showBeadStatus();
1627
- }
1628
- if (item.type === "skip") {
1629
- // Ignore /skip during discussion
1630
- }
1631
- }
1632
-
1633
- if (quitReceived) return false;
1634
-
1635
- if (goReceived) {
1636
- // Send finalize prompt to planner
1637
- console.log(
1638
- `\n${color.bold("-- Finalizing Goal")} ${color.bold("--------------------")}`,
1639
- );
1640
- log.info("Sending finalize prompt to Planner...");
1641
- eventDisplay.resetTurn("planner");
1642
- const finalizeResult = await executeTurnHeadless(
1643
- client, sessionId, buildPlannerFinalizeGoalPrompt(), plannerModel,
1644
- eventDisplay, inputQueue,
1645
- );
1646
- console.log("");
1647
-
1648
- if (finalizeResult.quit) return false;
1649
- return true;
1650
- }
1651
-
1652
- if (userText) {
1653
- // Send user's message to planner for continued discussion
1654
- const discussPrompt = `## Role: Planner (Goal Discussion)
1655
-
1656
- The user says:
1657
-
1658
- > ${userText}
1659
-
1660
- Continue the goal discussion. Help the user refine what to work on.
1661
- When they're ready, remind them to type \`/go\` to begin execution.`;
1662
-
1663
- eventDisplay.resetTurn("planner");
1664
- const discussResult = await executeTurnHeadless(
1665
- client, sessionId, discussPrompt, plannerModel,
1666
- eventDisplay, inputQueue,
1667
- );
1668
- console.log("");
1669
-
1670
- if (discussResult.quit) return false;
1671
- }
1672
- }
1673
- }
1674
-
1675
- // ── Goal discussion (daemon mode) ───────────────────────────────────────────
1676
-
1677
- /**
1678
- * Goal discussion in daemon+TUI mode.
1679
- * Sends a discovery prompt to planner, then waits for the user to
1680
- * type /go in the TUI. While waiting, the user can freely discuss
1681
- * with the planner via the TUI — the daemon stays paused.
1682
- *
1683
- * @returns {boolean} true if /go was received, false if daemon should exit.
1684
- */
1685
- async function goalDiscussionDaemon(client, sessionId, plannerModel, monitor, dlog) {
1686
- dlog.info("Starting goal discussion (no goal provided)");
1687
-
1688
- // Send discovery prompt
1689
- const discoveryPrompt = buildPlannerDiscoveryPrompt();
1690
- dlog.info("Sending discovery prompt to Planner...");
1691
- const discoveryResult = await executeTurnDaemon(
1692
- client, sessionId, discoveryPrompt, plannerModel, monitor, dlog,
1693
- );
1694
-
1695
- if (discoveryResult.aborted) {
1696
- dlog.warn("Discovery prompt aborted");
1697
- }
1698
-
1699
- // Now wait for user to type /go in the TUI.
1700
- // The monitor detects user messages via SSE. We listen for /go specifically.
1701
- dlog.info("Waiting for user to type /go in TUI...");
1702
-
1703
- return new Promise((resolve) => {
1704
- let resolved = false;
1705
-
1706
- // Save previous onUserMessage handler
1707
- const prevHandler = monitor.onUserMessage;
1708
-
1709
- monitor.onUserMessage = (text) => {
1710
- if (resolved) return;
1711
- const trimmed = text.trim().toLowerCase();
1712
-
1713
- if (trimmed === "/go") {
1714
- dlog.info("User typed /go — finalizing goal");
1715
- resolved = true;
1716
-
1717
- // Restore previous handler
1718
- monitor.onUserMessage = prevHandler || null;
1719
-
1720
- // Send finalize prompt
1721
- (async () => {
1722
- const finalizePrompt = buildPlannerFinalizeGoalPrompt();
1723
- monitor.registerPrompt(finalizePrompt);
1724
- dlog.info("Sending finalize prompt to Planner...");
1725
- const finalizeResult = await executeTurnDaemon(
1726
- client, sessionId, finalizePrompt, plannerModel, monitor, dlog,
1727
- );
1728
- if (finalizeResult.aborted) {
1729
- dlog.warn("Finalize prompt aborted");
1730
- }
1731
- resolve(true);
1732
- })();
1733
- return;
1734
- }
1735
-
1736
- // Any other user message: the user is discussing with the planner via
1737
- // TUI. The TUI + opencode handle this automatically (user types →
1738
- // model responds). The daemon just needs to wait for those turns to
1739
- // complete before checking for /go again.
1740
- dlog.info(`User discussing in TUI: "${text.slice(0, 60)}..."`);
1741
- // Wait for the model's response to finish
1742
- monitor.waitForTurnEnd().then(() => {
1743
- dlog.info("Planner response to user complete, still waiting for /go");
1744
- });
1745
- };
1746
- });
1747
- }
1748
-
1749
- // ── Main entry point ────────────────────────────────────────────────────────
1750
-
1751
- export async function auto(argv) {
1752
- const opts = parseArgs(argv);
1753
-
1754
- if (!has("opencode")) {
1755
- log.fail(
1756
- "opencode is not installed. Run: curl -fsSL https://opencode.ai/install | bash",
1757
- );
1758
- process.exit(1);
1759
- }
1760
-
1761
- // ── Path 1: Internal daemon process (forked by main process) ──
1762
- if (opts._daemon) {
1763
- return runDaemonProcess(opts);
1764
- }
1765
-
1766
- // ── Path 2: Headless mode (original CLI) ──
1767
- if (opts.headless) {
1768
- return runHeadlessMode(opts);
1769
- }
1770
-
1771
- // ── Path 3: Default — daemon + TUI ──
1772
- return runDaemonTuiMode(opts);
1773
- }
1774
-
1775
- // ── Path 1: Daemon process ──────────────────────────────────────────────────
1776
-
1777
- async function runDaemonProcess(opts) {
1778
- const dlog = createFileLogger(LOG_FILE);
1779
- dlog.info(`Daemon starting — goal: ${opts.goal || "(auto-pick)"}`);
1780
- dlog.info(`Planner: ${opts.planner}, Executor: ${opts.executor}`);
1781
-
1782
- const url = opts._daemonUrl;
1783
- if (!url) {
1784
- dlog.fail("No server URL provided (--_daemon-url)");
1785
- process.exit(1);
1786
- }
1787
-
1788
- // Connect to the opencode serve instance
1789
- const client = createClient(url);
1790
- try {
1791
- const health = await client.health();
1792
- if (!health?.healthy) throw new Error("not healthy");
1793
- dlog.ok(`Connected to server at ${url}`);
1794
- } catch (err) {
1795
- dlog.fail(`Cannot connect to server at ${url}: ${err.message}`);
1796
- process.exit(1);
1797
- }
1798
-
1799
- // Create or reuse session
1800
- let sessionId = opts._daemonSessionId;
1801
- if (!sessionId) {
1802
- dlog.info("Creating session...");
1803
- const session = await client.session.create({ title: "mneme auto" });
1804
- sessionId = session.id;
1805
- dlog.ok(`Session: ${sessionId}`);
1806
-
1807
- // Validate models in new session
1808
- dlog.info("Validating models (API probe)...");
1809
- try {
1810
- await validateModels(client, sessionId, opts, dlog);
1811
- } catch (err) {
1812
- dlog.fail(`Model validation failed: ${err.message}`);
1813
- process.exit(1);
1814
- }
1815
- } else {
1816
- dlog.ok(`Reusing session: ${sessionId}`);
1817
- }
1818
-
1819
- // Handle signals for clean shutdown
1820
- let shuttingDown = false;
1821
- const shutdown = (signal) => {
1822
- if (shuttingDown) return;
1823
- shuttingDown = true;
1824
- dlog.info(`Received ${signal}, shutting down daemon...`);
1825
- process.exit(0);
1826
- };
1827
- process.on("SIGTERM", () => shutdown("SIGTERM"));
1828
- process.on("SIGINT", () => shutdown("SIGINT"));
1829
- process.on("SIGHUP", () => shutdown("SIGHUP"));
1830
-
1831
- // Monitor parent process (the TUI). When it exits, we should too.
1832
- // In Node.js, when the parent exits, the daemon gets SIGHUP if not
1833
- // fully detached. We also poll as a fallback.
1834
- if (process.ppid) {
1835
- const parentPid = process.ppid;
1836
- const parentCheckId = setInterval(() => {
1837
- try {
1838
- // Signal 0 checks if process exists
1839
- process.kill(parentPid, 0);
1840
- } catch {
1841
- dlog.info("Parent process exited, daemon shutting down.");
1842
- clearInterval(parentCheckId);
1843
- process.exit(0);
1844
- }
1845
- }, 5000);
1846
- parentCheckId.unref?.();
1847
- }
1848
-
1849
- // Run the daemon supervisor loop
1850
- try {
1851
- await supervisorLoopDaemon(client, sessionId, opts, dlog);
1852
- } catch (err) {
1853
- dlog.fail(`Daemon supervisor error: ${err.message}`);
1854
- }
1855
-
1856
- dlog.ok("Daemon finished.");
1857
- process.exit(0);
1858
- }
1859
-
1860
- // ── Path 2: Headless mode (original CLI) ────────────────────────────────────
1861
-
1862
- async function runHeadlessMode(opts) {
1863
- console.log(
1864
- `\n${color.bold("mneme auto")} — dual-agent autonomous supervisor (headless)\n`,
1865
- );
1866
- console.log(` ${color.bold("Planner:")} ${opts.planner}`);
1867
- console.log(` ${color.bold("Executor:")} ${opts.executor}\n`);
1868
- console.log(color.dim("Commands while running:"));
1869
- console.log(color.dim(" Type any message -> inject feedback to planner"));
1870
- console.log(color.dim(" /status -> show bead status"));
1871
- console.log(color.dim(" /skip -> skip current bead"));
1872
- console.log(color.dim(" /abort -> abort current turn"));
1873
- console.log(color.dim(" /quit -> stop and exit\n"));
1874
-
1875
- let serverCtx;
1876
- try {
1877
- if (opts.attach) {
1878
- serverCtx = await attachOpencodeServer(opts.attach);
1879
- log.ok(`Attached to ${serverCtx.url} (v${serverCtx.version})`);
1880
- } else {
1881
- serverCtx = await startOpencodeServer({ port: opts.port });
1882
- if (serverCtx.alreadyRunning) {
1883
- log.ok(`Server already running at ${serverCtx.url} (v${serverCtx.version})`);
1884
- } else {
1885
- log.ok(`Server started at ${serverCtx.url} (v${serverCtx.version})`);
1886
- }
1887
- }
1888
- } catch (err) {
1889
- log.fail(err.message);
1890
- process.exit(1);
1891
- }
1892
-
1893
- const inputQueue = createInputQueue();
1894
- inputQueue.start();
1895
-
1896
- try {
1897
- await supervisorLoopHeadless(serverCtx.client, opts, inputQueue);
1898
- } catch (err) {
1899
- log.fail(`Supervisor error: ${err.message}`);
1900
- } finally {
1901
- inputQueue.stop();
1902
- if (serverCtx.serverProcess) {
1903
- log.info("Shutting down server...");
1904
- serverCtx.serverProcess.kill("SIGTERM");
1905
- }
1906
- log.ok("mneme auto finished.");
1907
- process.exit(0);
1908
- }
1909
- }
1910
-
1911
- // ── Path 3: Daemon + TUI mode (default) ────────────────────────────────────
1912
-
1913
- async function runDaemonTuiMode(opts) {
1914
- console.log(
1915
- `\n${color.bold("mneme auto")} — dual-agent autonomous supervisor\n`,
1916
- );
1917
- console.log(` ${color.bold("Planner:")} ${opts.planner}`);
1918
- console.log(` ${color.bold("Executor:")} ${opts.executor}`);
1919
- console.log(` ${color.bold("Mode:")} daemon + TUI\n`);
1920
-
1921
- // Step 1: Start opencode serve (if not running)
1922
- let serverUrl;
1923
- let weStartedServer = false;
1924
-
1925
- if (opts.attach) {
1926
- // User provided a URL
1927
- serverUrl = opts.attach;
1928
- log.info(`Using provided server: ${serverUrl}`);
1929
- try {
1930
- const client = createClient(serverUrl);
1931
- const health = await client.health();
1932
- if (!health?.healthy) throw new Error("not healthy");
1933
- log.ok(`Server healthy (v${health.version})`);
1934
- } catch (err) {
1935
- log.fail(`Cannot connect to ${serverUrl}: ${err.message}`);
1936
- process.exit(1);
1937
- }
1938
- } else {
1939
- serverUrl = `http://127.0.0.1:${opts.port}`;
1940
- // Check if already running
1941
- try {
1942
- const client = createClient(serverUrl);
1943
- const health = await client.health();
1944
- if (health?.healthy) {
1945
- log.ok(`Server already running at ${serverUrl} (v${health.version})`);
1946
- } else {
1947
- throw new Error("not healthy");
1948
- }
1949
- } catch {
1950
- // Need to start it
1951
- log.info(`Starting opencode serve on port ${opts.port}...`);
1952
- try {
1953
- const ctx = await startOpencodeServer({ port: opts.port, detached: true });
1954
- weStartedServer = !ctx.alreadyRunning;
1955
- log.ok(`Server started at ${ctx.url} (v${ctx.version})`);
1956
- } catch (err) {
1957
- log.fail(`Failed to start server: ${err.message}`);
1958
- process.exit(1);
1959
- }
1960
- }
1961
- }
1962
-
1963
- // Step 2: Create session and validate models before forking daemon
1964
- log.info("Creating session and validating models...");
1965
- const client = createClient(serverUrl);
1966
- let sessionId;
1967
- try {
1968
- const session = await client.session.create({ title: "mneme auto" });
1969
- sessionId = session.id;
1970
- log.ok(`Session: ${sessionId}`);
1971
- await validateModels(client, sessionId, opts, log);
1972
- } catch (err) {
1973
- log.fail(`Setup failed: ${err.message}`);
1974
- if (weStartedServer) {
1975
- log.info("Stopping server we started...");
1976
- run(`kill $(ps aux | grep 'opencode.*serve.*--port.*${opts.port}' | grep -v grep | awk '{print $2}') 2>/dev/null`);
1977
- }
1978
- process.exit(1);
1979
- }
1980
-
1981
- // Step 3: Fork daemon process
1982
- log.info("Forking daemon process...");
1983
-
1984
- // Build daemon argv
1985
- const daemonArgs = [
1986
- "auto",
1987
- "--_daemon",
1988
- "--_daemon-url", serverUrl,
1989
- "--_daemon-session", sessionId,
1990
- "--planner", opts.planner,
1991
- "--executor", opts.executor,
1992
- "--max-cycles", String(opts.maxCycles),
1993
- ];
1994
- if (opts.goal) {
1995
- daemonArgs.push(opts.goal);
1996
- }
1997
-
1998
- // Fork using the mneme CLI entry point
1999
- const mnemeEntry = join(
2000
- new URL(".", import.meta.url).pathname,
2001
- "..", "..", "bin", "mneme.mjs",
2002
- );
2003
-
2004
- const daemon = fork(mnemeEntry, daemonArgs, {
2005
- detached: true,
2006
- stdio: ["ignore", "ignore", "ignore", "ipc"],
2007
- });
2008
-
2009
- daemon.unref();
2010
- // Disconnect IPC so parent can exit cleanly
2011
- daemon.disconnect();
2012
-
2013
- log.ok(`Daemon forked (PID: ${daemon.pid})`);
2014
- log.info(`Log file: ${LOG_FILE}`);
2015
-
2016
- // Small delay to let daemon connect before we launch TUI
2017
- await sleep(1000);
2018
-
2019
- // Step 4: exec opencode attach (replaces this process)
2020
- log.info(`Launching TUI: opencode attach ${serverUrl}`);
2021
- console.log(color.dim(" Type directly in the TUI to intervene. The daemon pauses automatically.\n"));
2022
-
2023
- try {
2024
- execSync(`opencode attach ${serverUrl}`, {
2025
- stdio: "inherit",
2026
- // This blocks until the TUI exits
2027
- });
2028
- } catch {
2029
- // TUI exited (normal or error)
2030
- }
2031
-
2032
- // TUI exited — kill daemon
2033
- log.info("TUI exited.");
2034
- try {
2035
- process.kill(daemon.pid, "SIGTERM");
2036
- log.info(`Sent SIGTERM to daemon (PID: ${daemon.pid})`);
2037
- } catch {
2038
- // Daemon may have already exited
2039
- }
2040
-
2041
- // If we started the server and --attach wasn't used, optionally stop it
2042
- // (leave it running — user can `mneme down` manually)
2043
- log.ok("mneme auto finished.");
2044
- }
2045
-
2046
- // ── Helpers ─────────────────────────────────────────────────────────────────
2047
-
2048
- function sleep(ms) {
2049
- return new Promise((resolve) => setTimeout(resolve, ms));
2050
190
  }