moflo 4.9.22 → 4.9.23

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 +187 -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 Arcane Console — 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 Arcane Console (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,28 @@ 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 Arcane Console</title>
564
+ <meta name="description" content="The Arcane Console — moflo daemon, scheduled spells, and live event stream">
565
+ <meta property="og:title" content="The Arcane Console">
566
+ <meta property="og:description" content="The Arcane Console — 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
+ /* Per-word arcane palette. Hues chosen at L≈45-50%, S≈60-70% so they read on
579
+ both #0d1117 (dark) and #ffffff (light) — WCAG-AA at large-text size on
580
+ both. Each word's shadow matches its own hue so the glow doesn't bleed
581
+ a single color across all three. */
582
+ h1 .w-the { color: #8b5cf6; text-shadow: 0 0 18px rgba(139, 92, 246, 0.18); }
583
+ h1 .w-arcane { color: #2563eb; text-shadow: 0 0 18px rgba(37, 99, 235, 0.18); }
584
+ h1 .w-console { color: #059669; text-shadow: 0 0 18px rgba(5, 150, 105, 0.18); }
487
585
  h2 { color: #8b949e; font-size: 1.1rem; margin: 16px 0 12px; border-bottom: 1px solid #21262d; padding-bottom: 6px; }
488
586
  .header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
489
587
  .subtitle { color: #8b949e; font-size: 0.85rem; }
@@ -531,13 +629,13 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
531
629
  </head>
532
630
  <body>
533
631
  <div class="header">
534
- <h1>MoFlo Dashboard</h1>
535
- <span class="subtitle">read-only &bull; localhost</span>
632
+ <h1><span class="w-the">The</span> <span class="w-arcane">Arcane</span> <span class="w-console">Console</span></h1>
633
+ <span class="subtitle">moflo daemon &bull; localhost</span>
536
634
  </div>
537
635
  <div id="status-bar" class="status-bar"><div class="empty">Loading...</div></div>
538
636
  <div class="nav" id="nav"></div>
539
637
  <div id="panel-workers" class="panel"></div>
540
- <div id="panel-schedules" class="panel" style="display:none"></div>
638
+ <div id="panel-schedules" class="panel" style="display:none"><div id="schedules-active"></div><div id="schedules-events"></div></div>
541
639
  <div id="panel-executions" class="panel" style="display:none"></div>
542
640
  <div id="panel-memory" class="panel" style="display:none"></div>
543
641
  <div id="poll-indicator" class="poll-indicator"></div>
@@ -613,15 +711,25 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
613
711
 
614
712
  function renderWorkers(s) {
615
713
  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('');
714
+ // Disabled workers show a clear "disabled" badge and dim "—" cells
715
+ // instead of "idle"/"never" those terms imply the worker is healthy
716
+ // but quiet, which misled users into thinking audit/predict/document
717
+ // were broken (#968).
718
+ const rows = s.workers.map(w => {
719
+ const statusBadge = w.enabled === false
720
+ ? badge('disabled', 'gray')
721
+ : (w.isRunning ? badge('running', 'yellow') : badge('idle', 'gray'));
722
+ const dim = '<span class="dim">—</span>';
723
+ const lastRun = w.enabled === false && !w.lastRun ? dim : fmtTimeAgo(w.lastRun);
724
+ const nextRun = w.enabled === false ? dim : (w.nextRun ? fmtTime(w.nextRun) : '-');
725
+ return '<tr><td>' + esc(w.type) + '</td>' +
726
+ '<td>' + statusBadge + '</td>' +
727
+ '<td>' + w.runCount + '</td>' +
728
+ '<td>' + pct(w.successCount, w.failureCount) + '</td>' +
729
+ '<td>' + fmtDuration(w.averageDurationMs) + '</td>' +
730
+ '<td>' + lastRun + '</td>' +
731
+ '<td>' + nextRun + '</td></tr>';
732
+ }).join('');
625
733
  document.getElementById('panel-workers').innerHTML =
626
734
  '<h2>Worker Status</h2>' +
627
735
  '<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 +751,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
643
751
  window.__schedAction = scheduleAction;
644
752
 
645
753
  function renderSchedules(sc) {
646
- const el = document.getElementById('panel-schedules');
754
+ const el = document.getElementById('schedules-active');
647
755
  if (!sc) { el.innerHTML = '<div class="empty">Loading...</div>'; return; }
648
756
 
649
757
  if (sc.disabledInConfig) {
@@ -684,6 +792,67 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
684
792
  if (sc.history && sc.history.length) renderSchedulesHistory(el, sc.history, /*append*/ true);
685
793
  }
686
794
 
795
+ // Live events tail (Server-Sent Events from /api/schedules/events).
796
+ // The events div lives outside renderSchedules' write target so polled
797
+ // re-renders of the schedules panel don't touch it; pushSchedEvent is
798
+ // the only writer. Single source of truth for known event types + their
799
+ // badge color — adding a new schedule:* type means one entry here.
800
+ const SCHED_EVENT_BADGES = {
801
+ 'schedule:due': 'gray',
802
+ 'schedule:started': 'gray',
803
+ 'schedule:completed': 'green',
804
+ 'schedule:failed': 'red',
805
+ 'schedule:skipped': 'yellow',
806
+ 'schedule:disabled': 'yellow',
807
+ 'schedule:catchup': 'yellow',
808
+ };
809
+ const SCHED_EVENT_TYPES = Object.keys(SCHED_EVENT_BADGES);
810
+ const SCHED_EVENTS_MAX = 50;
811
+ const schedEvents = [];
812
+
813
+ function renderEventsTail() {
814
+ const el = document.getElementById('schedules-events');
815
+ if (!el) return;
816
+ if (schedEvents.length === 0) {
817
+ el.innerHTML = '<h2>Live Events</h2><div class="empty">Waiting for scheduler activity…</div>';
818
+ return;
819
+ }
820
+ const rows = schedEvents.map(e => {
821
+ const t = e.type || '?';
822
+ const short = String(t).replace('schedule:', '');
823
+ return '<tr><td>' + new Date(e.timestamp || Date.now()).toLocaleTimeString() + '</td>' +
824
+ '<td>' + badge(short, SCHED_EVENT_BADGES[t] || 'gray') + '</td>' +
825
+ '<td>' + esc(e.spellName || '-') + '</td>' +
826
+ '<td>' + esc(e.message || '') + '</td></tr>';
827
+ }).join('');
828
+ el.innerHTML = '<h2>Live Events</h2>' +
829
+ '<table><thead><tr><th>Time</th><th>Event</th><th>Spell</th><th>Detail</th></tr></thead>' +
830
+ '<tbody>' + rows + '</tbody></table>';
831
+ }
832
+ function pushSchedEvent(ev) {
833
+ schedEvents.unshift(ev);
834
+ if (schedEvents.length > SCHED_EVENTS_MAX) schedEvents.length = SCHED_EVENTS_MAX;
835
+ renderEventsTail();
836
+ }
837
+ renderEventsTail(); // Initial empty-state paint.
838
+
839
+ // Subscribe via EventSource. Browser handles auto-reconnect with backoff.
840
+ let evtSource = null;
841
+ function connectEventStream() {
842
+ try { if (evtSource) evtSource.close(); } catch (e) { /* ignore */ }
843
+ try {
844
+ evtSource = new EventSource('/api/schedules/events');
845
+ SCHED_EVENT_TYPES.forEach(t => {
846
+ evtSource.addEventListener(t, ev => {
847
+ try { pushSchedEvent(JSON.parse(ev.data)); } catch (e) { /* malformed frame */ }
848
+ });
849
+ });
850
+ } catch (e) {
851
+ console.warn('Event stream unavailable:', e);
852
+ }
853
+ }
854
+ connectEventStream();
855
+
687
856
  function renderSchedulesHistory(el, history, append) {
688
857
  const rows = history.map(h => {
689
858
  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) {