moflo 4.9.22 → 4.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +19 -16
  2. package/.claude/guidance/shipped/moflo-core-guidance.md +0 -2
  3. package/.claude/guidance/shipped/moflo-spell-runner.md +1 -0
  4. package/.claude/guidance/shipped/moflo-spell-scheduling.md +225 -0
  5. package/.claude/guidance/shipped/moflo-spell-troubleshooting.md +1 -0
  6. package/.claude/skills/fl/phases.md +67 -0
  7. package/.claude/skills/spell-schedule/SKILL.md +18 -5
  8. package/README.md +1 -1
  9. package/bin/index-guidance.mjs +32 -6
  10. package/bin/session-start-launcher.mjs +15 -8
  11. package/dist/src/cli/commands/daemon.js +13 -17
  12. package/dist/src/cli/commands/hooks.js +3 -6
  13. package/dist/src/cli/commands/spell-schedule.js +237 -49
  14. package/dist/src/cli/init/settings-generator.js +5 -6
  15. package/dist/src/cli/mcp-tools/memory-tools.js +16 -5
  16. package/dist/src/cli/memory/bridge-embedder.js +26 -6
  17. package/dist/src/cli/memory/bridge-entries.js +33 -15
  18. package/dist/src/cli/services/daemon-autostart-lifecycle.js +62 -0
  19. package/dist/src/cli/services/daemon-dashboard.js +192 -18
  20. package/dist/src/cli/services/daemon-readiness.js +19 -31
  21. package/dist/src/cli/services/ephemeral-namespace-purge.js +61 -33
  22. package/dist/src/cli/services/headless-worker-executor.js +7 -94
  23. package/dist/src/cli/services/worker-daemon.js +40 -66
  24. package/dist/src/cli/spells/core/runner.js +12 -0
  25. package/dist/src/cli/spells/scheduler/scheduler.js +24 -9
  26. package/dist/src/cli/spells/schema/validator.js +2 -1
  27. package/dist/src/cli/spells/schema/validators/top-level.js +18 -0
  28. package/dist/src/cli/version.js +1 -1
  29. package/package.json +4 -2
@@ -44,11 +44,9 @@ export const EMBEDDING_MODEL_OPT_OUT = 'none';
44
44
  */
45
45
  export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
46
46
  /**
47
- * Namespaces that store internal moflo run-tracking, never user knowledge.
48
- * Writes here skip embedding generation entirely both `embedding` and
49
- * `embedding_model` land as NULL, distinct from the opt-out path which still
50
- * tags rows with `'none'`. Existing rows in these namespaces are hard-deleted
51
- * on upgrade by `services/ephemeral-namespace-purge.ts`.
47
+ * Namespaces that skip embedding generation in the bridge write path. Rows
48
+ * land with both `embedding` and `embedding_model` NULL (distinct from the
49
+ * opt-out path which still tags rows with `'none'`).
52
50
  *
53
51
  * Members:
54
52
  * - `hive-mind` — MCP broadcast traffic (msg:*, agent_join, consensus_propose)
@@ -56,7 +54,11 @@ export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
56
54
  * - `epic-state` — Epic progress (epic-N, story-M) written by commands/epic.ts
57
55
  * - `test-bridge-fix` — Single 2026-04-23 row left over from a one-off test
58
56
  *
59
- * See story #729 for the source-trace and rationale.
57
+ * See story #729 for the source-trace and rationale. The session-start
58
+ * launcher only purges {@link PURGE_ON_SESSION_START_NAMESPACES} — a strict
59
+ * subset that *excludes* `tasklist`, because the dashboard's Flo Runs tab
60
+ * (`daemon-dashboard.ts handleSpells`) reads tasklist; purging it on every
61
+ * session would empty the tab between sessions (#968).
60
62
  */
61
63
  export const EPHEMERAL_NAMESPACES = new Set([
62
64
  'hive-mind',
@@ -64,6 +66,24 @@ export const EPHEMERAL_NAMESPACES = new Set([
64
66
  'epic-state',
65
67
  'test-bridge-fix',
66
68
  ]);
69
+ /**
70
+ * Subset of {@link EPHEMERAL_NAMESPACES} that the session-start launcher
71
+ * hard-purges via `services/ephemeral-namespace-purge.ts`. Excludes
72
+ * `tasklist` — those rows back the dashboard's "Flo Runs" tab and are
73
+ * trimmed by row-count retention instead of bulk purge (#968).
74
+ */
75
+ export const PURGE_ON_SESSION_START_NAMESPACES = new Set([
76
+ 'hive-mind',
77
+ 'epic-state',
78
+ 'test-bridge-fix',
79
+ ]);
80
+ /**
81
+ * Maximum number of `tasklist` rows kept across session restarts. The
82
+ * session-start retention pass deletes oldest rows beyond this cap, so the
83
+ * dashboard's "Flo Runs" tab shows recent history without unbounded growth
84
+ * (#968). Sized for ~2 weeks of /flo activity at typical use.
85
+ */
86
+ export const TASKLIST_RETENTION_CAP = 200;
67
87
  let cachedEmbedder = null;
68
88
  let testOverride = null;
69
89
  class LazyFastembedBridgeEmbedder {
@@ -435,14 +435,30 @@ export async function bridgeGetEntry(options) {
435
435
  });
436
436
  }
437
437
  /**
438
- * Soft-delete an entry. Guarded, cache-invalidated, attested.
438
+ * Hard-delete an entry. Guarded, cache-invalidated, attested.
439
+ *
440
+ * Failure modes (issue #963): every non-success path now carries a
441
+ * human-readable `error` so MCP callers can surface the reason instead
442
+ * of seeing a silent `{ deleted: false }`.
439
443
  */
440
444
  export async function bridgeDeleteEntry(options) {
441
445
  return withDb(options.dbPath, async (ctx, registry) => {
442
446
  const { key, namespace = 'default' } = options;
447
+ const deleteFail = (error) => ({ success: false, deleted: false, key, namespace, remainingEntries: 0, error });
443
448
  const guardResult = await guardValidate(registry, 'delete', { key, namespace });
444
449
  if (!guardResult.allowed) {
445
- return { success: false, deleted: false, key, namespace, remainingEntries: 0, error: `MutationGuard rejected: ${guardResult.reason}` };
450
+ return deleteFail(`MutationGuard rejected: ${guardResult.reason}`);
451
+ }
452
+ let existed = false;
453
+ try {
454
+ const existsRows = execRows(ctx.db, `SELECT 1 as found FROM memory_entries WHERE key = ? AND namespace = ? AND status = 'active' LIMIT 1`, [key, namespace]);
455
+ existed = existsRows.length > 0;
456
+ }
457
+ catch (err) {
458
+ return deleteFail(`DB read failed during delete pre-check: ${errorDetail(err)}`);
459
+ }
460
+ if (!existed) {
461
+ return deleteFail(`Key '${key}' not found in namespace '${namespace}'`);
446
462
  }
447
463
  let changes = 0;
448
464
  try {
@@ -454,28 +470,30 @@ export async function bridgeDeleteEntry(options) {
454
470
  // db.getRowsModified() to read the row count from the last statement.
455
471
  changes = ctx.db.getRowsModified?.() ?? 0;
456
472
  }
457
- catch {
458
- return null;
473
+ catch (err) {
474
+ return deleteFail(`DELETE failed: ${errorDetail(err)}`);
459
475
  }
460
- if (changes > 0)
461
- persistBridgeDb(ctx.db, options.dbPath);
462
- await cacheInvalidate(registry, makeEntryCacheKey(namespace, key));
463
- if (changes > 0) {
464
- await logAttestation(registry, 'delete', key, { namespace });
476
+ if (changes === 0) {
477
+ // SELECT found the row but DELETE removed nothing. Most likely cause:
478
+ // bridge holds an in-memory snapshot that diverged from disk
479
+ // (sql.js writeback semantics — see feedback_sqljs_writeback_clobber.md).
480
+ return deleteFail(`Internal inconsistency: row matched SELECT but DELETE removed 0 rows (key='${key}', namespace='${namespace}'). Possible bridge cache staleness — restart the daemon and retry.`);
465
481
  }
482
+ persistBridgeDb(ctx.db, options.dbPath);
483
+ await cacheInvalidate(registry, makeEntryCacheKey(namespace, key));
484
+ await logAttestation(registry, 'delete', key, { namespace });
466
485
  let remaining = 0;
467
486
  try {
468
- const result = ctx.db.exec(`SELECT COUNT(*) as cnt FROM memory_entries WHERE status = 'active'`);
469
- remaining = result[0]?.values?.[0]?.[0] ?? 0;
487
+ const countRows = execRows(ctx.db, `SELECT COUNT(*) as cnt FROM memory_entries WHERE status = 'active'`);
488
+ remaining = Number(countRows[0]?.cnt ?? 0);
470
489
  }
471
490
  catch {
472
- // Non-fatal
491
+ // Non-fatal — count is informational
473
492
  }
474
- if (changes > 0)
475
- refreshVectorStatsCache();
493
+ refreshVectorStatsCache();
476
494
  return {
477
495
  success: true,
478
- deleted: changes > 0,
496
+ deleted: true,
479
497
  key,
480
498
  namespace,
481
499
  remainingEntries: remaining,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Daemon Autostart Lifecycle
3
+ *
4
+ * Reconciles the OS-native daemon login service against the count of enabled
5
+ * scheduled spells. Replaces the old prompt-based flow in `daemon-readiness.ts`
6
+ * (which left users with stale autostart entries when their last schedule was
7
+ * cancelled — see #960, #961).
8
+ *
9
+ * Idempotent: callers invoke `reconcileDaemonAutostart` after every mutation
10
+ * to `scheduled-spells`. It installs once, uninstalls once, and is a no-op
11
+ * for every other transition.
12
+ */
13
+ import { isDaemonInstalled as defaultIsDaemonInstalled, installDaemonService as defaultInstallDaemonService, uninstallDaemonService as defaultUninstallDaemonService, } from './daemon-service.js';
14
+ const NOOP = { transition: 'noop', message: null, warning: null };
15
+ /**
16
+ * Reconcile OS-native daemon autostart against schedule count.
17
+ *
18
+ * - count ≥ 1 + not installed → install
19
+ * - count = 0 + installed → uninstall
20
+ * - all other states → noop
21
+ *
22
+ * Never throws. Install/uninstall failures are returned as non-fatal warnings
23
+ * — the caller decides whether to print them. The schedule mutation itself is
24
+ * always considered the primary operation; autostart is best-effort.
25
+ */
26
+ export function reconcileDaemonAutostart(options) {
27
+ if (options.skip)
28
+ return NOOP;
29
+ const isInstalled = (options.isDaemonInstalled ?? defaultIsDaemonInstalled)(options.projectRoot);
30
+ if (options.enabledScheduleCount >= 1 && !isInstalled) {
31
+ const result = (options.installDaemonService ?? defaultInstallDaemonService)(options.projectRoot);
32
+ if (result.success) {
33
+ return {
34
+ transition: 'installed',
35
+ message: 'Daemon registered as OS login service so this schedule survives reboot.',
36
+ warning: null,
37
+ };
38
+ }
39
+ return {
40
+ transition: 'noop',
41
+ message: null,
42
+ warning: `Could not register daemon as OS login service: ${result.message}`,
43
+ };
44
+ }
45
+ if (options.enabledScheduleCount === 0 && isInstalled) {
46
+ const result = (options.uninstallDaemonService ?? defaultUninstallDaemonService)(options.projectRoot);
47
+ if (result.success) {
48
+ return {
49
+ transition: 'uninstalled',
50
+ message: 'No enabled schedules remain — daemon unregistered from OS login services.',
51
+ warning: null,
52
+ };
53
+ }
54
+ return {
55
+ transition: 'noop',
56
+ message: null,
57
+ warning: `Could not unregister daemon from OS login services: ${result.message}`,
58
+ };
59
+ }
60
+ return NOOP;
61
+ }
62
+ //# sourceMappingURL=daemon-autostart-lifecycle.js.map
@@ -1,8 +1,14 @@
1
1
  /**
2
- * Daemon Dashboard — Lightweight localhost HTTP server
2
+ * The Luminarium — Lightweight localhost HTTP server
3
3
  *
4
- * Serves a read-only VanJS dashboard for daemon status, spell logs,
5
- * and memory stats. Binds to 127.0.0.1 only (no auth needed).
4
+ * Serves the moflo Luminarium (read-only daemon view) for status,
5
+ * scheduled spells, executions, and memory stats. Binds to 127.0.0.1
6
+ * only (no auth needed).
7
+ *
8
+ * Internal/code identifiers retain the term "dashboard" (CLI flags
9
+ * `--dashboard-port` / `--no-dashboard`, internal port constant) for
10
+ * stability with existing consumer scripts; only user-facing surface
11
+ * is rebranded.
6
12
  *
7
13
  * @module daemon-dashboard
8
14
  */
@@ -78,10 +84,17 @@ function tryParseSafe(s) {
78
84
  }
79
85
  function handleStatus(daemon) {
80
86
  const status = daemon.getStatus();
87
+ // Index config rows by worker type so the row renderer can show a
88
+ // "disabled" badge instead of "Last run: never" for default-off workers
89
+ // (audit, predict, document — see #968 user feedback).
90
+ const configByType = new Map();
91
+ for (const w of status.config.workers)
92
+ configByType.set(w.type, { enabled: w.enabled });
81
93
  const workers = [];
82
94
  for (const [type, state] of status.workers) {
83
95
  workers.push({
84
96
  type,
97
+ enabled: configByType.get(type)?.enabled ?? true,
85
98
  isRunning: state.isRunning,
86
99
  lastRun: state.lastRun?.toISOString() ?? null,
87
100
  nextRun: state.nextRun?.toISOString() ?? null,
@@ -330,6 +343,9 @@ async function handleRequest(req, res, daemon, opts) {
330
343
  else if (url === '/api/schedules') {
331
344
  sendJson(res, 200, await handleSchedules(daemon, opts));
332
345
  }
346
+ else if (url === '/api/schedules/events') {
347
+ handleSchedulesEventStream(req, res, daemon);
348
+ }
333
349
  else if (url === '/api/spells') {
334
350
  sendJson(res, 200, await handleSpells(opts.memory));
335
351
  }
@@ -400,6 +416,71 @@ function getSchedulerErrorCode(err) {
400
416
  }
401
417
  return null;
402
418
  }
419
+ /** Heartbeat interval for the schedule-event SSE stream (keeps proxies + idle clients alive). */
420
+ const SSE_HEARTBEAT_MS = 25_000;
421
+ /**
422
+ * Stream `schedule:*` events to the client via Server-Sent Events.
423
+ *
424
+ * Subscribes to `scheduler.on(listener)` and forwards each event as an SSE
425
+ * frame (`event: <type>\ndata: <JSON>\n\n`). Sends an initial `ready` frame
426
+ * so the client can distinguish "connected, waiting" from "scheduler down",
427
+ * plus a comment heartbeat every 25s. Cleans up on client disconnect.
428
+ *
429
+ * Returns 503 when the scheduler is not attached so the client can fall back
430
+ * to polling. Reuses duck-typing via `daemon.getScheduler()` — no new types
431
+ * exported from this module.
432
+ */
433
+ function handleSchedulesEventStream(req, res, daemon) {
434
+ const scheduler = daemon.getScheduler();
435
+ if (!scheduler) {
436
+ sendJson(res, 503, { error: 'Scheduler not attached' });
437
+ return;
438
+ }
439
+ res.writeHead(200, {
440
+ 'Content-Type': 'text/event-stream',
441
+ 'Cache-Control': 'no-cache, no-transform',
442
+ 'Connection': 'keep-alive',
443
+ 'X-Accel-Buffering': 'no',
444
+ });
445
+ // Initial frame so the client knows the channel is live even before
446
+ // the first scheduler event arrives (which may be minutes away).
447
+ res.write(`event: ready\ndata: ${JSON.stringify({ timestamp: Date.now() })}\n\n`);
448
+ let cleaned = false;
449
+ const cleanup = () => {
450
+ if (cleaned)
451
+ return;
452
+ cleaned = true;
453
+ clearInterval(heartbeat);
454
+ unsubscribe();
455
+ try {
456
+ res.end();
457
+ }
458
+ catch { /* already ended */ }
459
+ };
460
+ const unsubscribe = scheduler.on((event) => {
461
+ try {
462
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
463
+ }
464
+ catch {
465
+ // Half-open socket: the request may never emit 'close'. Defer cleanup
466
+ // to the next microtask so we don't splice the listeners array we are
467
+ // currently being iterated from inside.
468
+ queueMicrotask(cleanup);
469
+ }
470
+ });
471
+ const heartbeat = setInterval(() => {
472
+ try {
473
+ res.write(`: ping ${Date.now()}\n\n`);
474
+ }
475
+ catch {
476
+ queueMicrotask(cleanup);
477
+ }
478
+ }, SSE_HEARTBEAT_MS);
479
+ if (typeof heartbeat.unref === 'function')
480
+ heartbeat.unref();
481
+ req.on('close', cleanup);
482
+ req.on('error', cleanup);
483
+ }
403
484
  // ============================================================================
404
485
  // Server lifecycle
405
486
  // ============================================================================
@@ -479,11 +560,33 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
479
560
  <head>
480
561
  <meta charset="utf-8">
481
562
  <meta name="viewport" content="width=device-width, initial-scale=1">
482
- <title>MoFlo Dashboard</title>
563
+ <title>The Luminarium</title>
564
+ <meta name="description" content="The Luminarium — moflo daemon, scheduled spells, and live event stream">
565
+ <meta property="og:title" content="The Luminarium">
566
+ <meta property="og:description" content="The Luminarium — moflo daemon, scheduled spells, and live event stream">
567
+ <link rel="preconnect" href="https://fonts.googleapis.com">
568
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
569
+ <link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&display=swap" rel="stylesheet">
483
570
  <style>
484
571
  * { margin: 0; padding: 0; box-sizing: border-box; }
485
572
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 20px; }
486
- h1 { color: #58a6ff; margin-bottom: 4px; font-size: 1.5rem; }
573
+ /* Wizardy chain Cinzel Decorative is the Google Font; the rest are
574
+ the most likely system serifs across macOS / Windows / Linux so the
575
+ title still reads "arcane" if the user is offline or behind a font-CDN
576
+ block (Georgia ships everywhere; serif is the universal fallback). */
577
+ h1 { font-family: 'Cinzel Decorative', 'Cinzel', 'Trajan Pro', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; font-weight: 900; letter-spacing: 0.04em; margin-bottom: 4px; font-size: 1.85rem; }
578
+ /* Luminous gradient flowing across the whole title (amber → pale gold → pale
579
+ cyan). background-clip: text paints the gradient through the glyphs;
580
+ color: transparent reveals it. text-shadow doesn't paint on transparent
581
+ text, so the glow uses filter: drop-shadow which respects rendered
582
+ glyph shape. Mid-gradient hue chosen for the glow tint. */
583
+ h1 .luminarium-title {
584
+ background: linear-gradient(90deg, #f59e0b 0%, #fde68a 50%, #67e8f4 100%);
585
+ -webkit-background-clip: text;
586
+ background-clip: text;
587
+ color: transparent;
588
+ filter: drop-shadow(0 0 14px rgba(253, 230, 138, 0.22));
589
+ }
487
590
  h2 { color: #8b949e; font-size: 1.1rem; margin: 16px 0 12px; border-bottom: 1px solid #21262d; padding-bottom: 6px; }
488
591
  .header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
489
592
  .subtitle { color: #8b949e; font-size: 0.85rem; }
@@ -531,13 +634,13 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
531
634
  </head>
532
635
  <body>
533
636
  <div class="header">
534
- <h1>MoFlo Dashboard</h1>
535
- <span class="subtitle">read-only &bull; localhost</span>
637
+ <h1><span class="luminarium-title">The Luminarium</span></h1>
638
+ <span class="subtitle">moflo daemon &bull; localhost</span>
536
639
  </div>
537
640
  <div id="status-bar" class="status-bar"><div class="empty">Loading...</div></div>
538
641
  <div class="nav" id="nav"></div>
539
642
  <div id="panel-workers" class="panel"></div>
540
- <div id="panel-schedules" class="panel" style="display:none"></div>
643
+ <div id="panel-schedules" class="panel" style="display:none"><div id="schedules-active"></div><div id="schedules-events"></div></div>
541
644
  <div id="panel-executions" class="panel" style="display:none"></div>
542
645
  <div id="panel-memory" class="panel" style="display:none"></div>
543
646
  <div id="poll-indicator" class="poll-indicator"></div>
@@ -613,15 +716,25 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
613
716
 
614
717
  function renderWorkers(s) {
615
718
  if (!s) return;
616
- const rows = s.workers.map(w =>
617
- '<tr><td>' + esc(w.type) + '</td>' +
618
- '<td>' + (w.isRunning ? badge('running','yellow') : badge('idle','gray')) + '</td>' +
619
- '<td>' + w.runCount + '</td>' +
620
- '<td>' + pct(w.successCount, w.failureCount) + '</td>' +
621
- '<td>' + fmtDuration(w.averageDurationMs) + '</td>' +
622
- '<td>' + fmtTimeAgo(w.lastRun) + '</td>' +
623
- '<td>' + (w.nextRun ? fmtTime(w.nextRun) : '-') + '</td></tr>'
624
- ).join('');
719
+ // Disabled workers show a clear "disabled" badge and dim "—" cells
720
+ // instead of "idle"/"never" those terms imply the worker is healthy
721
+ // but quiet, which misled users into thinking audit/predict/document
722
+ // were broken (#968).
723
+ const rows = s.workers.map(w => {
724
+ const statusBadge = w.enabled === false
725
+ ? badge('disabled', 'gray')
726
+ : (w.isRunning ? badge('running', 'yellow') : badge('idle', 'gray'));
727
+ const dim = '<span class="dim">—</span>';
728
+ const lastRun = w.enabled === false && !w.lastRun ? dim : fmtTimeAgo(w.lastRun);
729
+ const nextRun = w.enabled === false ? dim : (w.nextRun ? fmtTime(w.nextRun) : '-');
730
+ return '<tr><td>' + esc(w.type) + '</td>' +
731
+ '<td>' + statusBadge + '</td>' +
732
+ '<td>' + w.runCount + '</td>' +
733
+ '<td>' + pct(w.successCount, w.failureCount) + '</td>' +
734
+ '<td>' + fmtDuration(w.averageDurationMs) + '</td>' +
735
+ '<td>' + lastRun + '</td>' +
736
+ '<td>' + nextRun + '</td></tr>';
737
+ }).join('');
625
738
  document.getElementById('panel-workers').innerHTML =
626
739
  '<h2>Worker Status</h2>' +
627
740
  '<table><thead><tr><th>Worker</th><th>Status</th><th>Runs</th><th>Success</th><th>Avg</th><th>Last Run</th><th>Next Run</th></tr></thead>' +
@@ -643,7 +756,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
643
756
  window.__schedAction = scheduleAction;
644
757
 
645
758
  function renderSchedules(sc) {
646
- const el = document.getElementById('panel-schedules');
759
+ const el = document.getElementById('schedules-active');
647
760
  if (!sc) { el.innerHTML = '<div class="empty">Loading...</div>'; return; }
648
761
 
649
762
  if (sc.disabledInConfig) {
@@ -684,6 +797,67 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
684
797
  if (sc.history && sc.history.length) renderSchedulesHistory(el, sc.history, /*append*/ true);
685
798
  }
686
799
 
800
+ // Live events tail (Server-Sent Events from /api/schedules/events).
801
+ // The events div lives outside renderSchedules' write target so polled
802
+ // re-renders of the schedules panel don't touch it; pushSchedEvent is
803
+ // the only writer. Single source of truth for known event types + their
804
+ // badge color — adding a new schedule:* type means one entry here.
805
+ const SCHED_EVENT_BADGES = {
806
+ 'schedule:due': 'gray',
807
+ 'schedule:started': 'gray',
808
+ 'schedule:completed': 'green',
809
+ 'schedule:failed': 'red',
810
+ 'schedule:skipped': 'yellow',
811
+ 'schedule:disabled': 'yellow',
812
+ 'schedule:catchup': 'yellow',
813
+ };
814
+ const SCHED_EVENT_TYPES = Object.keys(SCHED_EVENT_BADGES);
815
+ const SCHED_EVENTS_MAX = 50;
816
+ const schedEvents = [];
817
+
818
+ function renderEventsTail() {
819
+ const el = document.getElementById('schedules-events');
820
+ if (!el) return;
821
+ if (schedEvents.length === 0) {
822
+ el.innerHTML = '<h2>Live Events</h2><div class="empty">Waiting for scheduler activity…</div>';
823
+ return;
824
+ }
825
+ const rows = schedEvents.map(e => {
826
+ const t = e.type || '?';
827
+ const short = String(t).replace('schedule:', '');
828
+ return '<tr><td>' + new Date(e.timestamp || Date.now()).toLocaleTimeString() + '</td>' +
829
+ '<td>' + badge(short, SCHED_EVENT_BADGES[t] || 'gray') + '</td>' +
830
+ '<td>' + esc(e.spellName || '-') + '</td>' +
831
+ '<td>' + esc(e.message || '') + '</td></tr>';
832
+ }).join('');
833
+ el.innerHTML = '<h2>Live Events</h2>' +
834
+ '<table><thead><tr><th>Time</th><th>Event</th><th>Spell</th><th>Detail</th></tr></thead>' +
835
+ '<tbody>' + rows + '</tbody></table>';
836
+ }
837
+ function pushSchedEvent(ev) {
838
+ schedEvents.unshift(ev);
839
+ if (schedEvents.length > SCHED_EVENTS_MAX) schedEvents.length = SCHED_EVENTS_MAX;
840
+ renderEventsTail();
841
+ }
842
+ renderEventsTail(); // Initial empty-state paint.
843
+
844
+ // Subscribe via EventSource. Browser handles auto-reconnect with backoff.
845
+ let evtSource = null;
846
+ function connectEventStream() {
847
+ try { if (evtSource) evtSource.close(); } catch (e) { /* ignore */ }
848
+ try {
849
+ evtSource = new EventSource('/api/schedules/events');
850
+ SCHED_EVENT_TYPES.forEach(t => {
851
+ evtSource.addEventListener(t, ev => {
852
+ try { pushSchedEvent(JSON.parse(ev.data)); } catch (e) { /* malformed frame */ }
853
+ });
854
+ });
855
+ } catch (e) {
856
+ console.warn('Event stream unavailable:', e);
857
+ }
858
+ }
859
+ connectEventStream();
860
+
687
861
  function renderSchedulesHistory(el, history, append) {
688
862
  const rows = history.map(h => {
689
863
  const statusBadge = h.success === true ? badge('pass','green')
@@ -1,29 +1,36 @@
1
1
  /**
2
2
  * Daemon Readiness Check for Scheduled Spells
3
3
  *
4
- * Lazy three-state flow: only triggered when creating schedules.
5
- * 1. Is daemon running? If not, prompt to start it.
6
- * 2. Is daemon installed as OS service? If not, prompt to install.
7
- * Always creates the schedule regardless the daemon picks it up on next start.
4
+ * Confirms the daemon is currently running so a freshly-created schedule
5
+ * actually fires. Prompts the user to start it interactively, or warns
6
+ * non-interactively. Always returns regardless of state the caller still
7
+ * writes the schedule, and the daemon picks it up on next start.
8
+ *
9
+ * OS-autostart install/uninstall is no longer handled here — see
10
+ * `daemon-autostart-lifecycle.ts`. That side effect is now driven by the
11
+ * count of enabled schedules, not a per-create prompt.
8
12
  */
9
13
  import { join, resolve } from 'path';
10
14
  import { mkdirSync, openSync, closeSync } from 'fs';
11
15
  import { spawn } from 'child_process';
12
16
  import { getDaemonLockHolder } from './daemon-lock.js';
13
- import { isDaemonInstalled, installDaemonService } from './daemon-service.js';
17
+ import { isDaemonInstalled } from './daemon-service.js';
14
18
  import { locateMofloCliBin } from './moflo-require.js';
15
19
  import { registerBackgroundPid } from './process-registry.js';
16
20
  /**
17
21
  * Ensure the daemon is ready for scheduled spell execution.
18
22
  *
19
- * Checks daemon state and prompts the user to start/install as needed.
20
- * Always returns — never throws. The caller should create the schedule
21
- * regardless of the result, since the daemon can pick it up later.
23
+ * Checks daemon state and prompts the user to start it as needed. Always
24
+ * returns — never throws. The caller should create the schedule regardless
25
+ * of the result, since the daemon can pick it up later. OS-autostart is
26
+ * reconciled separately by the create/cancel paths via
27
+ * `reconcileDaemonAutostart`.
22
28
  */
23
29
  export async function ensureDaemonForScheduling(options) {
24
30
  const resolvedRoot = resolve(options.projectRoot);
25
31
  const promptFn = options.promptConfirm ?? defaultPromptConfirm;
26
32
  const startFn = options.startDaemon ?? defaultStartDaemon;
33
+ const installedFn = options.isDaemonInstalledFn ?? isDaemonInstalled;
27
34
  const result = {
28
35
  daemonRunning: false,
29
36
  daemonInstalled: false,
@@ -50,29 +57,10 @@ export async function ensureDaemonForScheduling(options) {
50
57
  result.warnings.push('Daemon is not running. Start it with: moflo daemon start');
51
58
  }
52
59
  }
53
- // Step 2: Check if daemon is installed as OS autostart service. This
54
- // check is independent of the running state — a user with the daemon
55
- // currently down still needs the autostart warning so their new schedule
56
- // survives the next reboot.
57
- result.daemonInstalled = isDaemonInstalled(resolvedRoot);
58
- if (!result.daemonInstalled) {
59
- if (options.interactive && result.daemonRunning) {
60
- const shouldInstall = await promptFn('Register the daemon as a login service so schedules survive reboots?');
61
- if (shouldInstall) {
62
- const installResult = installDaemonService(resolvedRoot);
63
- result.daemonInstalled = installResult.success;
64
- if (!installResult.success) {
65
- result.warnings.push(`Failed to install daemon service: ${installResult.message}`);
66
- }
67
- }
68
- else {
69
- result.warnings.push("Daemon is not set to autostart. Run 'moflo daemon install' so this schedule survives reboot.");
70
- }
71
- }
72
- else {
73
- result.warnings.push("Daemon is not set to autostart. Run 'moflo daemon install' so this schedule survives reboot.");
74
- }
75
- }
60
+ // Surface OS install state purely informationally no prompts. The
61
+ // create/cancel commands reconcile install/uninstall via
62
+ // reconcileDaemonAutostart, driven by the enabled-schedule count.
63
+ result.daemonInstalled = installedFn(resolvedRoot);
76
64
  return result;
77
65
  }
78
66
  async function defaultPromptConfirm(message) {