jfl 0.2.2 → 0.2.3

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 (70) hide show
  1. package/clawdbot-plugin/clawdbot.plugin.json +12 -1
  2. package/clawdbot-plugin/index.js +5 -5
  3. package/clawdbot-plugin/index.ts +5 -5
  4. package/dist/commands/context-hub.d.ts +4 -0
  5. package/dist/commands/context-hub.d.ts.map +1 -1
  6. package/dist/commands/context-hub.js +570 -81
  7. package/dist/commands/context-hub.js.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +42 -11
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/peter.d.ts +15 -0
  12. package/dist/commands/peter.d.ts.map +1 -0
  13. package/dist/commands/peter.js +198 -0
  14. package/dist/commands/peter.js.map +1 -0
  15. package/dist/commands/ralph.d.ts +3 -1
  16. package/dist/commands/ralph.d.ts.map +1 -1
  17. package/dist/commands/ralph.js +40 -5
  18. package/dist/commands/ralph.js.map +1 -1
  19. package/dist/commands/session.d.ts +2 -1
  20. package/dist/commands/session.d.ts.map +1 -1
  21. package/dist/commands/session.js +496 -49
  22. package/dist/commands/session.js.map +1 -1
  23. package/dist/commands/update.d.ts.map +1 -1
  24. package/dist/commands/update.js +25 -6
  25. package/dist/commands/update.js.map +1 -1
  26. package/dist/dashboard/components.d.ts +7 -0
  27. package/dist/dashboard/components.d.ts.map +1 -0
  28. package/dist/dashboard/components.js +163 -0
  29. package/dist/dashboard/components.js.map +1 -0
  30. package/dist/dashboard/index.d.ts +12 -0
  31. package/dist/dashboard/index.d.ts.map +1 -0
  32. package/dist/dashboard/index.js +132 -0
  33. package/dist/dashboard/index.js.map +1 -0
  34. package/dist/dashboard/pages.d.ts +7 -0
  35. package/dist/dashboard/pages.d.ts.map +1 -0
  36. package/dist/dashboard/pages.js +742 -0
  37. package/dist/dashboard/pages.js.map +1 -0
  38. package/dist/dashboard/styles.d.ts +7 -0
  39. package/dist/dashboard/styles.d.ts.map +1 -0
  40. package/dist/dashboard/styles.js +497 -0
  41. package/dist/dashboard/styles.js.map +1 -0
  42. package/dist/index.js +30 -3
  43. package/dist/index.js.map +1 -1
  44. package/dist/lib/map-event-bus.d.ts +48 -0
  45. package/dist/lib/map-event-bus.d.ts.map +1 -0
  46. package/dist/lib/map-event-bus.js +326 -0
  47. package/dist/lib/map-event-bus.js.map +1 -0
  48. package/dist/lib/peter-parker-bridge.d.ts +33 -0
  49. package/dist/lib/peter-parker-bridge.d.ts.map +1 -0
  50. package/dist/lib/peter-parker-bridge.js +116 -0
  51. package/dist/lib/peter-parker-bridge.js.map +1 -0
  52. package/dist/lib/peter-parker-config.d.ts +13 -0
  53. package/dist/lib/peter-parker-config.d.ts.map +1 -0
  54. package/dist/lib/peter-parker-config.js +86 -0
  55. package/dist/lib/peter-parker-config.js.map +1 -0
  56. package/dist/lib/service-utils.d.ts.map +1 -1
  57. package/dist/lib/service-utils.js +33 -17
  58. package/dist/lib/service-utils.js.map +1 -1
  59. package/dist/mcp/context-hub-mcp.js +122 -22
  60. package/dist/mcp/context-hub-mcp.js.map +1 -1
  61. package/dist/types/map.d.ts +33 -0
  62. package/dist/types/map.d.ts.map +1 -0
  63. package/dist/types/map.js +39 -0
  64. package/dist/types/map.js.map +1 -0
  65. package/dist/ui/event-dashboard.d.ts +12 -0
  66. package/dist/ui/event-dashboard.d.ts.map +1 -0
  67. package/dist/ui/event-dashboard.js +342 -0
  68. package/dist/ui/event-dashboard.js.map +1 -0
  69. package/package.json +1 -1
  70. package/scripts/test-map-eventbus.sh +357 -0
@@ -18,6 +18,10 @@ import { initializeDatabase, getMemoryStats, insertMemory } from "../lib/memory-
18
18
  import { searchMemories } from "../lib/memory-search.js";
19
19
  import { indexJournalEntries, startPeriodicIndexing } from "../lib/memory-indexer.js";
20
20
  import { getProjectPort } from "../utils/context-hub-port.js";
21
+ import { getConfigValue, setConfig } from "../utils/jfl-config.js";
22
+ import Conf from "conf";
23
+ import { MAPEventBus } from "../lib/map-event-bus.js";
24
+ import { WebSocketServer } from "ws";
21
25
  const PID_FILE = ".jfl/context-hub.pid";
22
26
  const LOG_FILE = ".jfl/logs/context-hub.log";
23
27
  const TOKEN_FILE = ".jfl/context-hub.token";
@@ -39,22 +43,29 @@ function getOrCreateToken(projectRoot) {
39
43
  fs.writeFileSync(tokenFile, token, { mode: 0o600 }); // Owner read/write only
40
44
  return token;
41
45
  }
42
- function validateAuth(req, projectRoot) {
46
+ function validateAuth(req, projectRoot, url) {
43
47
  const tokenFile = getTokenFile(projectRoot);
44
48
  // If no token file exists, allow access (backwards compatibility during migration)
45
49
  if (!fs.existsSync(tokenFile)) {
46
50
  return true;
47
51
  }
48
52
  const expectedToken = fs.readFileSync(tokenFile, 'utf-8').trim();
53
+ // Check Authorization header first
49
54
  const authHeader = req.headers['authorization'];
50
- if (!authHeader) {
51
- return false;
55
+ if (authHeader) {
56
+ const providedToken = authHeader.startsWith('Bearer ')
57
+ ? authHeader.slice(7)
58
+ : authHeader;
59
+ if (providedToken === expectedToken)
60
+ return true;
61
+ }
62
+ // Fall back to ?token= query param (needed for SSE/EventSource which can't set headers)
63
+ if (url) {
64
+ const queryToken = url.searchParams.get('token');
65
+ if (queryToken && queryToken === expectedToken)
66
+ return true;
52
67
  }
53
- // Support "Bearer <token>" format
54
- const providedToken = authHeader.startsWith('Bearer ')
55
- ? authHeader.slice(7)
56
- : authHeader;
57
- return providedToken === expectedToken;
68
+ return false;
58
69
  }
59
70
  function isPortInUse(port) {
60
71
  return new Promise((resolve) => {
@@ -77,28 +88,31 @@ function isPortInUse(port) {
77
88
  // ============================================================================
78
89
  // Journal Reader
79
90
  // ============================================================================
80
- function readJournalEntries(projectRoot, limit = 20) {
91
+ function readJournalEntries(projectRoot, limit = 50) {
81
92
  const journalDir = path.join(projectRoot, ".jfl", "journal");
82
93
  const items = [];
83
94
  if (!fs.existsSync(journalDir)) {
84
95
  return items;
85
96
  }
97
+ // Sort by modification time (newest first) so recent entries aren't buried
86
98
  const files = fs.readdirSync(journalDir)
87
99
  .filter(f => f.endsWith(".jsonl"))
88
- .sort()
89
- .reverse();
100
+ .map(f => ({
101
+ name: f,
102
+ mtime: fs.statSync(path.join(journalDir, f)).mtimeMs
103
+ }))
104
+ .sort((a, b) => b.mtime - a.mtime)
105
+ .map(f => f.name);
106
+ // Read all entries from all files, then sort globally by timestamp
107
+ const allEntries = [];
90
108
  for (const file of files) {
91
- if (items.length >= limit)
92
- break;
93
109
  const filePath = path.join(journalDir, file);
94
110
  const content = fs.readFileSync(filePath, "utf-8");
95
111
  const lines = content.trim().split("\n").filter(l => l.trim());
96
- for (const line of lines.reverse()) {
97
- if (items.length >= limit)
98
- break;
112
+ for (const line of lines) {
99
113
  try {
100
114
  const entry = JSON.parse(line);
101
- items.push({
115
+ allEntries.push({
102
116
  source: "journal",
103
117
  type: entry.type || "entry",
104
118
  title: entry.title || "Untitled",
@@ -112,7 +126,13 @@ function readJournalEntries(projectRoot, limit = 20) {
112
126
  }
113
127
  }
114
128
  }
115
- return items;
129
+ // Sort all entries by timestamp descending, then take the limit
130
+ allEntries.sort((a, b) => {
131
+ const ta = a.timestamp || "";
132
+ const tb = b.timestamp || "";
133
+ return tb.localeCompare(ta);
134
+ });
135
+ return allEntries.slice(0, limit);
116
136
  }
117
137
  // ============================================================================
118
138
  // Knowledge Reader
@@ -395,7 +415,7 @@ function getUnifiedContext(projectRoot, query, taskType) {
395
415
  // ============================================================================
396
416
  // HTTP Server
397
417
  // ============================================================================
398
- function createServer(projectRoot, port) {
418
+ function createServer(projectRoot, port, eventBus) {
399
419
  const server = http.createServer((req, res) => {
400
420
  // CORS - include Authorization header
401
421
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -413,8 +433,21 @@ function createServer(projectRoot, port) {
413
433
  res.end(JSON.stringify({ status: "ok", port }));
414
434
  return;
415
435
  }
436
+ // Dashboard - served without API auth (has its own token flow in JS)
437
+ if (url.pathname.startsWith("/dashboard")) {
438
+ import("../dashboard/index.js").then(({ handleDashboardRoutes }) => {
439
+ if (!handleDashboardRoutes(req, res, projectRoot, port)) {
440
+ res.writeHead(404, { "Content-Type": "application/json" });
441
+ res.end(JSON.stringify({ error: "Not found" }));
442
+ }
443
+ }).catch(() => {
444
+ res.writeHead(500, { "Content-Type": "application/json" });
445
+ res.end(JSON.stringify({ error: "Dashboard module failed to load" }));
446
+ });
447
+ return;
448
+ }
416
449
  // All other endpoints require auth
417
- if (!validateAuth(req, projectRoot)) {
450
+ if (!validateAuth(req, projectRoot, url)) {
418
451
  res.writeHead(401, { "Content-Type": "application/json" });
419
452
  res.end(JSON.stringify({
420
453
  error: "Unauthorized",
@@ -579,10 +612,158 @@ function createServer(projectRoot, port) {
579
612
  });
580
613
  return;
581
614
  }
615
+ // Cross-project health
616
+ if (url.pathname === "/api/projects" && req.method === "GET") {
617
+ const tracked = getTrackedProjects();
618
+ Promise.all(tracked.map(async (p) => {
619
+ // Self-check: if this is our own port, we know we're OK
620
+ if (p.port === port) {
621
+ return {
622
+ name: p.path.split("/").pop() || p.path,
623
+ path: p.path,
624
+ port: p.port,
625
+ status: "OK",
626
+ pid: process.pid,
627
+ message: "This instance",
628
+ };
629
+ }
630
+ const result = await diagnoseProject(p.path, p.port);
631
+ return {
632
+ name: p.path.split("/").pop() || p.path,
633
+ path: p.path,
634
+ port: p.port,
635
+ status: result.status,
636
+ pid: result.pid,
637
+ message: result.message,
638
+ };
639
+ }))
640
+ .then((results) => {
641
+ res.writeHead(200, { "Content-Type": "application/json" });
642
+ res.end(JSON.stringify(results));
643
+ })
644
+ .catch((err) => {
645
+ res.writeHead(500, { "Content-Type": "application/json" });
646
+ res.end(JSON.stringify({ error: err.message }));
647
+ });
648
+ return;
649
+ }
650
+ // Publish event
651
+ if (url.pathname === "/api/events" && req.method === "POST") {
652
+ if (!eventBus) {
653
+ res.writeHead(503, { "Content-Type": "application/json" });
654
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
655
+ return;
656
+ }
657
+ let body = "";
658
+ req.on("data", chunk => body += chunk);
659
+ req.on("end", () => {
660
+ try {
661
+ const { type, source, target, session, data, ttl } = JSON.parse(body || "{}");
662
+ if (!type || !source) {
663
+ res.writeHead(400, { "Content-Type": "application/json" });
664
+ res.end(JSON.stringify({ error: "type and source required" }));
665
+ return;
666
+ }
667
+ const event = eventBus.emit({
668
+ type: type,
669
+ source,
670
+ target,
671
+ session,
672
+ data: data || {},
673
+ ttl,
674
+ });
675
+ res.writeHead(201, { "Content-Type": "application/json" });
676
+ res.end(JSON.stringify(event));
677
+ }
678
+ catch (err) {
679
+ res.writeHead(400, { "Content-Type": "application/json" });
680
+ res.end(JSON.stringify({ error: err.message }));
681
+ }
682
+ });
683
+ return;
684
+ }
685
+ // Get recent events
686
+ if (url.pathname === "/api/events" && req.method === "GET") {
687
+ if (!eventBus) {
688
+ res.writeHead(503, { "Content-Type": "application/json" });
689
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
690
+ return;
691
+ }
692
+ const since = url.searchParams.get("since") || undefined;
693
+ const pattern = url.searchParams.get("pattern") || undefined;
694
+ const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit"), 10) : 50;
695
+ const events = eventBus.getEvents({ since, pattern, limit });
696
+ res.writeHead(200, { "Content-Type": "application/json" });
697
+ res.end(JSON.stringify({ events, count: events.length }));
698
+ return;
699
+ }
700
+ // SSE event stream
701
+ if (url.pathname === "/api/events/stream" && req.method === "GET") {
702
+ if (!eventBus) {
703
+ res.writeHead(503, { "Content-Type": "application/json" });
704
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
705
+ return;
706
+ }
707
+ const patterns = (url.searchParams.get("patterns") || "*").split(",");
708
+ res.writeHead(200, {
709
+ "Content-Type": "text/event-stream",
710
+ "Cache-Control": "no-cache",
711
+ Connection: "keep-alive",
712
+ "Access-Control-Allow-Origin": "*",
713
+ });
714
+ res.write("retry: 3000\n\n");
715
+ const sub = eventBus.subscribe({
716
+ clientId: `sse-${Date.now()}`,
717
+ patterns,
718
+ transport: "sse",
719
+ callback: (event) => {
720
+ res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
721
+ },
722
+ });
723
+ req.on("close", () => {
724
+ eventBus.unsubscribe(sub.id);
725
+ });
726
+ return;
727
+ }
582
728
  // 404
583
729
  res.writeHead(404, { "Content-Type": "application/json" });
584
730
  res.end(JSON.stringify({ error: "Not found" }));
585
731
  });
732
+ // WebSocket upgrade for event streaming
733
+ if (eventBus) {
734
+ const wss = new WebSocketServer({ noServer: true });
735
+ server.on("upgrade", (request, socket, head) => {
736
+ const reqUrl = new URL(request.url || "/", `http://localhost:${port}`);
737
+ if (reqUrl.pathname !== "/ws/events") {
738
+ socket.destroy();
739
+ return;
740
+ }
741
+ if (!validateAuth(request, projectRoot, reqUrl)) {
742
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
743
+ socket.destroy();
744
+ return;
745
+ }
746
+ wss.handleUpgrade(request, socket, head, (ws) => {
747
+ const patterns = (reqUrl.searchParams.get("patterns") || "*").split(",");
748
+ const sub = eventBus.subscribe({
749
+ clientId: `ws-${Date.now()}`,
750
+ patterns,
751
+ transport: "websocket",
752
+ callback: (event) => {
753
+ if (ws.readyState === ws.OPEN) {
754
+ ws.send(JSON.stringify(event));
755
+ }
756
+ },
757
+ });
758
+ ws.on("close", () => {
759
+ eventBus.unsubscribe(sub.id);
760
+ });
761
+ ws.on("error", () => {
762
+ eventBus.unsubscribe(sub.id);
763
+ });
764
+ });
765
+ });
766
+ }
586
767
  return server;
587
768
  }
588
769
  // ============================================================================
@@ -615,6 +796,89 @@ export function isRunning(projectRoot) {
615
796
  return { running: false };
616
797
  }
617
798
  }
799
+ // ============================================================================
800
+ // Cross-Project Helpers
801
+ // ============================================================================
802
+ function getTrackedProjects() {
803
+ // Read from both config sources (Conf library + XDG config)
804
+ const confStore = new Conf({ projectName: "jfl" });
805
+ const confProjects = confStore.get("projects") || [];
806
+ const xdgProjects = getConfigValue("projects") || [];
807
+ // Deduplicate
808
+ const allPaths = [...new Set([...confProjects, ...xdgProjects])];
809
+ return allPaths
810
+ .filter(p => fs.existsSync(path.join(p, ".jfl")))
811
+ .map(p => ({ path: p, port: getProjectPort(p) }));
812
+ }
813
+ async function ensureForProject(projectRoot, port, quiet = false) {
814
+ const status = isRunning(projectRoot);
815
+ if (status.running) {
816
+ try {
817
+ const response = await fetch(`http://localhost:${port}/health`, {
818
+ signal: AbortSignal.timeout(2000)
819
+ });
820
+ if (response.ok) {
821
+ return { status: "running", message: `Already running (PID: ${status.pid})` };
822
+ }
823
+ }
824
+ catch {
825
+ // Process exists but not responding, fall through
826
+ }
827
+ }
828
+ const portInUse = await isPortInUse(port);
829
+ if (portInUse) {
830
+ try {
831
+ const response = await fetch(`http://localhost:${port}/health`, {
832
+ signal: AbortSignal.timeout(2000)
833
+ });
834
+ if (response.ok) {
835
+ return { status: "running", message: "Running (PID file missing but healthy)" };
836
+ }
837
+ }
838
+ catch {
839
+ // Not responding
840
+ }
841
+ try {
842
+ const lsofOutput = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
843
+ if (lsofOutput) {
844
+ const orphanedPid = parseInt(lsofOutput.split('\n')[0], 10);
845
+ if (!status.pid || orphanedPid !== status.pid) {
846
+ process.kill(orphanedPid, 'SIGTERM');
847
+ await new Promise(resolve => setTimeout(resolve, 500));
848
+ }
849
+ }
850
+ }
851
+ catch {
852
+ // lsof failed or process already gone
853
+ }
854
+ }
855
+ const result = await startDaemon(projectRoot, port);
856
+ if (result.success) {
857
+ return { status: "started", message: result.message };
858
+ }
859
+ return { status: "failed", message: result.message };
860
+ }
861
+ async function diagnoseProject(projectPath, port) {
862
+ if (!fs.existsSync(projectPath)) {
863
+ return { path: projectPath, port, status: "STALE", message: "Directory does not exist" };
864
+ }
865
+ const pidStatus = isRunning(projectPath);
866
+ if (!pidStatus.running) {
867
+ return { path: projectPath, port, status: "DOWN", pid: undefined };
868
+ }
869
+ try {
870
+ const response = await fetch(`http://localhost:${port}/health`, {
871
+ signal: AbortSignal.timeout(2000)
872
+ });
873
+ if (response.ok) {
874
+ return { path: projectPath, port, status: "OK", pid: pidStatus.pid };
875
+ }
876
+ }
877
+ catch {
878
+ // Not responding
879
+ }
880
+ return { path: projectPath, port, status: "ZOMBIE", pid: pidStatus.pid, message: "PID exists but not responding" };
881
+ }
618
882
  async function startDaemon(projectRoot, port) {
619
883
  const status = isRunning(projectRoot);
620
884
  if (status.running) {
@@ -639,12 +903,13 @@ async function startDaemon(projectRoot, port) {
639
903
  // Fall back to current process
640
904
  jflCmd = process.argv[1];
641
905
  }
642
- // Start as detached process
906
+ // Start as detached process with CONTEXT_HUB_DAEMON=1 so the serve
907
+ // action knows to ignore SIGTERM during its startup grace period
643
908
  const child = spawn(jflCmd, ["context-hub", "serve", "--port", String(port)], {
644
909
  cwd: projectRoot,
645
910
  detached: true,
646
911
  stdio: ["ignore", fs.openSync(logFile, "a"), fs.openSync(logFile, "a")],
647
- env: { ...process.env, NODE_ENV: "production" }
912
+ env: { ...process.env, NODE_ENV: "production", CONTEXT_HUB_DAEMON: "1" }
648
913
  });
649
914
  child.unref();
650
915
  // Wait a moment to ensure process started
@@ -674,7 +939,6 @@ async function stopDaemon(projectRoot) {
674
939
  return { success: true, message: "Context Hub is not running" };
675
940
  }
676
941
  const pidFile = getPidFile(projectRoot);
677
- const tokenFile = getTokenFile(projectRoot);
678
942
  try {
679
943
  // Send SIGTERM first (graceful)
680
944
  process.kill(status.pid, "SIGTERM");
@@ -700,13 +964,10 @@ async function stopDaemon(projectRoot) {
700
964
  catch {
701
965
  // Process is gone, that's fine
702
966
  }
703
- // Clean up PID and token files
967
+ // Clean up PID file (preserve token for seamless restart)
704
968
  if (fs.existsSync(pidFile)) {
705
969
  fs.unlinkSync(pidFile);
706
970
  }
707
- if (fs.existsSync(tokenFile)) {
708
- fs.unlinkSync(tokenFile);
709
- }
710
971
  return { success: true, message: "Context Hub stopped" };
711
972
  }
712
973
  catch (err) {
@@ -714,6 +975,117 @@ async function stopDaemon(projectRoot) {
714
975
  }
715
976
  }
716
977
  // ============================================================================
978
+ // Auto-Install Daemon
979
+ // ============================================================================
980
+ export async function ensureDaemonInstalled(opts) {
981
+ const quiet = opts?.quiet ?? false;
982
+ if (process.platform !== "darwin") {
983
+ return false;
984
+ }
985
+ const plistLabel = "com.jfl.context-hub";
986
+ const plistDir = path.join(homedir(), "Library", "LaunchAgents");
987
+ const plistPath = path.join(plistDir, `${plistLabel}.plist`);
988
+ const logPath = path.join(homedir(), ".config", "jfl", "context-hub-agent.log");
989
+ // If plist exists, check if loaded
990
+ if (fs.existsSync(plistPath)) {
991
+ try {
992
+ const output = execSync("launchctl list", { encoding: "utf-8" });
993
+ if (output.includes(plistLabel)) {
994
+ return true;
995
+ }
996
+ }
997
+ catch {
998
+ // launchctl failed, try to reload
999
+ }
1000
+ // Exists but not loaded — reload it
1001
+ try {
1002
+ execSync(`launchctl load "${plistPath}"`, { stdio: "ignore" });
1003
+ if (!quiet) {
1004
+ console.log(chalk.green(`\n Daemon reloaded.`));
1005
+ console.log(chalk.gray(` Plist: ${plistPath}\n`));
1006
+ }
1007
+ return true;
1008
+ }
1009
+ catch {
1010
+ // Fall through to full install
1011
+ }
1012
+ }
1013
+ // Full install
1014
+ let jflPath = "";
1015
+ try {
1016
+ jflPath = execSync("which jfl", { encoding: "utf-8" }).trim();
1017
+ }
1018
+ catch {
1019
+ jflPath = process.argv[1] || "jfl";
1020
+ }
1021
+ const logDir = path.dirname(logPath);
1022
+ if (!fs.existsSync(logDir)) {
1023
+ fs.mkdirSync(logDir, { recursive: true });
1024
+ }
1025
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1026
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1027
+ <plist version="1.0">
1028
+ <dict>
1029
+ <key>Label</key>
1030
+ <string>${plistLabel}</string>
1031
+ <key>ProgramArguments</key>
1032
+ <array>
1033
+ <string>${jflPath}</string>
1034
+ <string>context-hub</string>
1035
+ <string>ensure-all</string>
1036
+ <string>--quiet</string>
1037
+ </array>
1038
+ <key>RunAtLoad</key>
1039
+ <true/>
1040
+ <key>StartInterval</key>
1041
+ <integer>300</integer>
1042
+ <key>ProcessType</key>
1043
+ <string>Background</string>
1044
+ <key>LowPriorityIO</key>
1045
+ <true/>
1046
+ <key>Nice</key>
1047
+ <integer>10</integer>
1048
+ <key>EnvironmentVariables</key>
1049
+ <dict>
1050
+ <key>PATH</key>
1051
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
1052
+ </dict>
1053
+ <key>StandardOutPath</key>
1054
+ <string>${logPath}</string>
1055
+ <key>StandardErrorPath</key>
1056
+ <string>${logPath}</string>
1057
+ </dict>
1058
+ </plist>
1059
+ `;
1060
+ if (!fs.existsSync(plistDir)) {
1061
+ fs.mkdirSync(plistDir, { recursive: true });
1062
+ }
1063
+ // Unload if partially loaded
1064
+ try {
1065
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
1066
+ }
1067
+ catch {
1068
+ // Not loaded, fine
1069
+ }
1070
+ fs.writeFileSync(plistPath, plistContent);
1071
+ try {
1072
+ execSync(`launchctl load "${plistPath}"`);
1073
+ if (!quiet) {
1074
+ console.log(chalk.green(`\n Daemon installed and loaded.`));
1075
+ console.log(chalk.gray(` Plist: ${plistPath}`));
1076
+ console.log(chalk.gray(` Log: ${logPath}`));
1077
+ console.log(chalk.gray(` Runs ensure-all every 5 minutes + on login.\n`));
1078
+ }
1079
+ return true;
1080
+ }
1081
+ catch {
1082
+ if (!quiet) {
1083
+ console.log(chalk.red(`\n Failed to load daemon.\n`));
1084
+ }
1085
+ return false;
1086
+ }
1087
+ }
1088
+ // ============================================================================
717
1089
  // CLI Command
718
1090
  // ============================================================================
719
1091
  export async function contextHubCommand(action, options = {}) {
@@ -797,64 +1169,152 @@ export async function contextHubCommand(action, options = {}) {
797
1169
  break;
798
1170
  }
799
1171
  case "ensure": {
800
- const status = isRunning(projectRoot);
801
- if (status.running) {
802
- // Already running, verify it's healthy
803
- try {
804
- const response = await fetch(`http://localhost:${port}/health`, {
805
- signal: AbortSignal.timeout(2000)
806
- });
807
- if (response.ok) {
808
- // Healthy and running, nothing to do
809
- return;
810
- }
811
- }
812
- catch {
813
- // Process exists but not responding, fall through to cleanup
1172
+ await ensureForProject(projectRoot, port, true);
1173
+ break;
1174
+ }
1175
+ case "ensure-all": {
1176
+ const tracked = getTrackedProjects();
1177
+ if (tracked.length === 0) {
1178
+ if (!options.quiet) {
1179
+ console.log(chalk.yellow("\n No tracked projects found.\n"));
814
1180
  }
1181
+ break;
815
1182
  }
816
- // Check if port is blocked by orphaned process
817
- const portInUse = await isPortInUse(port);
818
- if (portInUse) {
819
- // Port is in use - check if it's actually Context Hub
820
- try {
821
- const response = await fetch(`http://localhost:${port}/health`, {
822
- signal: AbortSignal.timeout(2000)
823
- });
824
- if (response.ok) {
825
- // It's a healthy Context Hub but PID file is missing/wrong
826
- // Don't kill it - just return
827
- return;
828
- }
1183
+ const results = [];
1184
+ for (const project of tracked) {
1185
+ const name = path.basename(project.path);
1186
+ const result = await ensureForProject(project.path, project.port, true);
1187
+ results.push({ name, result });
1188
+ }
1189
+ if (!options.quiet) {
1190
+ console.log(chalk.bold("\n Context Hub - ensure-all\n"));
1191
+ for (const { name, result } of results) {
1192
+ const icon = result.status === "failed" ? chalk.red("✗") : chalk.green("✓");
1193
+ const label = result.status === "started" ? chalk.cyan("started") :
1194
+ result.status === "running" ? chalk.green("running") :
1195
+ chalk.red("failed");
1196
+ console.log(` ${icon} ${chalk.bold(name)} — ${label}`);
829
1197
  }
830
- catch {
831
- // Process on port is not responding to health check
832
- // Only kill if we're confident it's not a Context Hub
1198
+ const ok = results.filter(r => r.result.status !== "failed").length;
1199
+ const fail = results.filter(r => r.result.status === "failed").length;
1200
+ console.log(chalk.gray(`\n ${ok} running, ${fail} failed\n`));
1201
+ }
1202
+ break;
1203
+ }
1204
+ case "doctor": {
1205
+ const confStoreDoctor = new Conf({ projectName: "jfl" });
1206
+ const confProjectsDoctor = confStoreDoctor.get("projects") || [];
1207
+ const xdgProjectsDoctor = getConfigValue("projects") || [];
1208
+ const allProjects = [...new Set([...confProjectsDoctor, ...xdgProjectsDoctor])];
1209
+ if (allProjects.length === 0) {
1210
+ console.log(chalk.yellow("\n No tracked projects found.\n"));
1211
+ break;
1212
+ }
1213
+ const cleanMode = process.argv.includes("--clean");
1214
+ if (cleanMode) {
1215
+ const before = allProjects.length;
1216
+ const valid = allProjects.filter(p => fs.existsSync(p));
1217
+ // Write cleaned list back to both stores
1218
+ confStoreDoctor.set("projects", valid.filter(p => confProjectsDoctor.includes(p)));
1219
+ setConfig("projects", valid.filter(p => xdgProjectsDoctor.includes(p)));
1220
+ const removed = before - valid.length;
1221
+ if (removed > 0) {
1222
+ console.log(chalk.green(`\n Removed ${removed} stale project${removed > 1 ? "s" : ""} from tracker.\n`));
833
1223
  }
834
- // Port in use but not responding - try to clean up
835
- try {
836
- const lsofOutput = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
837
- if (lsofOutput) {
838
- const orphanedPid = parseInt(lsofOutput.split('\n')[0], 10);
839
- // Only kill if it's different from our tracked PID
840
- if (!status.pid || orphanedPid !== status.pid) {
841
- process.kill(orphanedPid, 'SIGTERM');
842
- await new Promise(resolve => setTimeout(resolve, 500)); // Wait for cleanup
843
- }
844
- }
1224
+ else {
1225
+ console.log(chalk.green("\n No stale projects found.\n"));
845
1226
  }
846
- catch {
847
- // lsof failed or process already gone
1227
+ break;
1228
+ }
1229
+ console.log(chalk.bold("\n Context Hub - doctor\n"));
1230
+ let staleCount = 0;
1231
+ let downCount = 0;
1232
+ let zombieCount = 0;
1233
+ let okCount = 0;
1234
+ for (const projectPath of allProjects) {
1235
+ const projectPort = getProjectPort(projectPath);
1236
+ const result = await diagnoseProject(projectPath, projectPort);
1237
+ const name = path.basename(result.path);
1238
+ switch (result.status) {
1239
+ case "OK":
1240
+ console.log(` ${chalk.green("OK")} ${chalk.bold(name)} — PID ${result.pid}, port ${result.port}`);
1241
+ okCount++;
1242
+ break;
1243
+ case "ZOMBIE":
1244
+ console.log(` ${chalk.red("ZOMBIE")} ${chalk.bold(name)} — PID ${result.pid} not responding on port ${result.port}`);
1245
+ zombieCount++;
1246
+ break;
1247
+ case "DOWN":
1248
+ console.log(` ${chalk.red("DOWN")} ${chalk.bold(name)} — not running (port ${result.port})`);
1249
+ downCount++;
1250
+ break;
1251
+ case "STALE":
1252
+ console.log(` ${chalk.yellow("STALE")} ${chalk.gray(result.path)} — directory missing`);
1253
+ staleCount++;
1254
+ break;
848
1255
  }
849
1256
  }
850
- // Start silently
851
- await startDaemon(projectRoot, port);
1257
+ console.log();
1258
+ if (downCount > 0 || zombieCount > 0) {
1259
+ console.log(chalk.gray(` Hint: run ${chalk.cyan("jfl context-hub ensure-all")} to start all hubs`));
1260
+ }
1261
+ if (staleCount > 0) {
1262
+ console.log(chalk.gray(` Hint: run ${chalk.cyan("jfl context-hub doctor --clean")} to remove stale entries`));
1263
+ }
1264
+ if (okCount === allProjects.length) {
1265
+ console.log(chalk.green(" All projects healthy."));
1266
+ }
1267
+ console.log();
1268
+ break;
1269
+ }
1270
+ case "install-daemon": {
1271
+ const result = await ensureDaemonInstalled({ quiet: false });
1272
+ if (!result) {
1273
+ console.log(chalk.yellow("\n Daemon install skipped (non-macOS or failed).\n"));
1274
+ }
1275
+ break;
1276
+ }
1277
+ case "uninstall-daemon": {
1278
+ const plistLabel = "com.jfl.context-hub";
1279
+ const plistPath = path.join(homedir(), "Library", "LaunchAgents", `${plistLabel}.plist`);
1280
+ if (!fs.existsSync(plistPath)) {
1281
+ console.log(chalk.yellow("\n Daemon not installed.\n"));
1282
+ break;
1283
+ }
1284
+ try {
1285
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
1286
+ }
1287
+ catch {
1288
+ // Already unloaded
1289
+ }
1290
+ fs.unlinkSync(plistPath);
1291
+ console.log(chalk.green("\n Daemon uninstalled.\n"));
852
1292
  break;
853
1293
  }
854
1294
  case "serve": {
855
1295
  // Run server in foreground (used by daemon)
856
- const server = createServer(projectRoot, port);
1296
+ const serviceEventsPath = path.join(projectRoot, ".jfl", "service-events.jsonl");
1297
+ const mapPersistPath = path.join(projectRoot, ".jfl", "map-events.jsonl");
1298
+ const journalDir = path.join(projectRoot, ".jfl", "journal");
1299
+ const eventBus = new MAPEventBus({
1300
+ maxSize: 1000,
1301
+ persistPath: mapPersistPath,
1302
+ serviceEventsPath,
1303
+ journalDir: fs.existsSync(journalDir) ? journalDir : null,
1304
+ });
1305
+ const server = createServer(projectRoot, port, eventBus);
857
1306
  let isListening = false;
1307
+ // When spawned as daemon, ignore SIGTERM during startup grace period.
1308
+ // The parent process (hook runner) may exit and send SIGTERM to the
1309
+ // process group before we're fully detached. After grace period,
1310
+ // re-enable normal shutdown handling.
1311
+ const isDaemon = process.env.CONTEXT_HUB_DAEMON === "1";
1312
+ let startupGrace = isDaemon;
1313
+ if (isDaemon) {
1314
+ setTimeout(() => {
1315
+ startupGrace = false;
1316
+ }, 5000);
1317
+ }
858
1318
  // Error handling - keep process alive
859
1319
  process.on("uncaughtException", (err) => {
860
1320
  console.error(`Uncaught exception: ${err.message}`);
@@ -895,10 +1355,19 @@ export async function contextHubCommand(action, options = {}) {
895
1355
  console.error(`[${timestamp}] Failed to initialize memory system:`, err.message);
896
1356
  // Don't exit - memory is optional
897
1357
  }
1358
+ console.log(`[${timestamp}] MAP event bus initialized (buffer: 1000, subscribers: ${eventBus.getSubscriberCount()})`);
898
1359
  console.log(`[${timestamp}] Ready to serve requests`);
899
1360
  });
900
1361
  // Handle shutdown gracefully
901
1362
  const shutdown = (signal) => {
1363
+ // During startup grace period (daemon mode), ignore SIGTERM from
1364
+ // parent process cleanup. This prevents the hook runner from
1365
+ // killing the hub before it's fully detached.
1366
+ if (startupGrace && signal === "SIGTERM") {
1367
+ const ts = new Date().toISOString();
1368
+ console.log(`[${ts}] Ignoring ${signal} during startup grace period (PID: ${process.pid}, Parent: ${process.ppid})`);
1369
+ return;
1370
+ }
902
1371
  if (!isListening) {
903
1372
  // Server never started, just exit
904
1373
  process.exit(0);
@@ -910,6 +1379,7 @@ export async function contextHubCommand(action, options = {}) {
910
1379
  console.log(`[${timestamp}] PID: ${process.pid}, Parent PID: ${process.ppid}`);
911
1380
  console.log(`[${timestamp}] Shutting down...`);
912
1381
  server.close(() => {
1382
+ eventBus.destroy();
913
1383
  console.log(`[${new Date().toISOString()}] Server closed`);
914
1384
  process.exit(0);
915
1385
  });
@@ -964,6 +1434,18 @@ export async function contextHubCommand(action, options = {}) {
964
1434
  });
965
1435
  break;
966
1436
  }
1437
+ case "dashboard": {
1438
+ const token = getOrCreateToken(projectRoot);
1439
+ const dashUrl = `http://localhost:${port}/dashboard?token=${token}`;
1440
+ try {
1441
+ execSync(`open "${dashUrl}"`);
1442
+ }
1443
+ catch {
1444
+ // open not available — print URL instead
1445
+ }
1446
+ console.log(chalk.gray(` Opening ${dashUrl}`));
1447
+ break;
1448
+ }
967
1449
  case "clear-logs": {
968
1450
  // Clear both global and local log files
969
1451
  const { JFL_FILES } = await import("../utils/jfl-paths.js");
@@ -990,17 +1472,24 @@ export async function contextHubCommand(action, options = {}) {
990
1472
  default: {
991
1473
  console.log(chalk.bold("\n Context Hub - Unified context for AI agents\n"));
992
1474
  console.log(chalk.gray(" Commands:"));
993
- console.log(" jfl context-hub start Start the daemon");
994
- console.log(" jfl context-hub stop Stop the daemon");
995
- console.log(" jfl context-hub restart Restart the daemon");
996
- console.log(" jfl context-hub status Check if running");
997
- console.log(" jfl context-hub logs Show real-time logs (TUI)");
998
- console.log(" jfl context-hub clear-logs Clear log file");
999
- console.log(" jfl context-hub ensure Start if not running (for hooks)");
1000
- console.log(" jfl context-hub query Quick context query");
1475
+ console.log(" jfl context-hub start Start the daemon");
1476
+ console.log(" jfl context-hub stop Stop the daemon");
1477
+ console.log(" jfl context-hub restart Restart the daemon");
1478
+ console.log(" jfl context-hub status Check if running");
1479
+ console.log(" jfl context-hub ensure Start if not running (for hooks)");
1480
+ console.log(" jfl context-hub ensure-all Ensure all tracked projects are running");
1481
+ console.log(" jfl context-hub doctor Diagnose all tracked projects");
1482
+ console.log(" jfl context-hub doctor --clean Remove stale project entries");
1483
+ console.log(" jfl context-hub dashboard Open web dashboard in browser");
1484
+ console.log(" jfl context-hub install-daemon Install macOS launchd keepalive");
1485
+ console.log(" jfl context-hub uninstall-daemon Remove macOS launchd keepalive");
1486
+ console.log(" jfl context-hub logs Show real-time logs (TUI)");
1487
+ console.log(" jfl context-hub clear-logs Clear log file");
1488
+ console.log(" jfl context-hub query Quick context query");
1001
1489
  console.log();
1002
1490
  console.log(chalk.gray(" Options:"));
1003
1491
  console.log(" --port <port> Port to run on (default: per-project)");
1492
+ console.log(" --quiet Suppress output (for daemon use)");
1004
1493
  console.log();
1005
1494
  }
1006
1495
  }