jfl 0.2.2 → 0.2.4

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 (153) hide show
  1. package/README.md +399 -423
  2. package/clawdbot-plugin/clawdbot.plugin.json +12 -1
  3. package/clawdbot-plugin/index.js +5 -5
  4. package/clawdbot-plugin/index.ts +5 -5
  5. package/dist/commands/context-hub.d.ts +4 -0
  6. package/dist/commands/context-hub.d.ts.map +1 -1
  7. package/dist/commands/context-hub.js +704 -83
  8. package/dist/commands/context-hub.js.map +1 -1
  9. package/dist/commands/digest.d.ts +6 -0
  10. package/dist/commands/digest.d.ts.map +1 -0
  11. package/dist/commands/digest.js +81 -0
  12. package/dist/commands/digest.js.map +1 -0
  13. package/dist/commands/flows.d.ts +7 -0
  14. package/dist/commands/flows.d.ts.map +1 -0
  15. package/dist/commands/flows.js +264 -0
  16. package/dist/commands/flows.js.map +1 -0
  17. package/dist/commands/hooks.d.ts +11 -0
  18. package/dist/commands/hooks.d.ts.map +1 -0
  19. package/dist/commands/hooks.js +303 -0
  20. package/dist/commands/hooks.js.map +1 -0
  21. package/dist/commands/improve.d.ts +11 -0
  22. package/dist/commands/improve.d.ts.map +1 -0
  23. package/dist/commands/improve.js +77 -0
  24. package/dist/commands/improve.js.map +1 -0
  25. package/dist/commands/init.d.ts.map +1 -1
  26. package/dist/commands/init.js +42 -11
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/peter.d.ts +15 -0
  29. package/dist/commands/peter.d.ts.map +1 -0
  30. package/dist/commands/peter.js +198 -0
  31. package/dist/commands/peter.js.map +1 -0
  32. package/dist/commands/ralph.d.ts +3 -1
  33. package/dist/commands/ralph.d.ts.map +1 -1
  34. package/dist/commands/ralph.js +40 -5
  35. package/dist/commands/ralph.js.map +1 -1
  36. package/dist/commands/scope.d.ts +7 -0
  37. package/dist/commands/scope.d.ts.map +1 -0
  38. package/dist/commands/scope.js +227 -0
  39. package/dist/commands/scope.js.map +1 -0
  40. package/dist/commands/service-validate.js +7 -1
  41. package/dist/commands/service-validate.js.map +1 -1
  42. package/dist/commands/session.d.ts +2 -1
  43. package/dist/commands/session.d.ts.map +1 -1
  44. package/dist/commands/session.js +519 -49
  45. package/dist/commands/session.js.map +1 -1
  46. package/dist/commands/update.d.ts.map +1 -1
  47. package/dist/commands/update.js +25 -6
  48. package/dist/commands/update.js.map +1 -1
  49. package/dist/dashboard/components.d.ts +7 -0
  50. package/dist/dashboard/components.d.ts.map +1 -0
  51. package/dist/dashboard/components.js +163 -0
  52. package/dist/dashboard/components.js.map +1 -0
  53. package/dist/dashboard/index.d.ts +12 -0
  54. package/dist/dashboard/index.d.ts.map +1 -0
  55. package/dist/dashboard/index.js +132 -0
  56. package/dist/dashboard/index.js.map +1 -0
  57. package/dist/dashboard/pages.d.ts +7 -0
  58. package/dist/dashboard/pages.d.ts.map +1 -0
  59. package/dist/dashboard/pages.js +742 -0
  60. package/dist/dashboard/pages.js.map +1 -0
  61. package/dist/dashboard/styles.d.ts +7 -0
  62. package/dist/dashboard/styles.d.ts.map +1 -0
  63. package/dist/dashboard/styles.js +497 -0
  64. package/dist/dashboard/styles.js.map +1 -0
  65. package/dist/index.js +196 -8
  66. package/dist/index.js.map +1 -1
  67. package/dist/lib/flow-engine.d.ts +34 -0
  68. package/dist/lib/flow-engine.d.ts.map +1 -0
  69. package/dist/lib/flow-engine.js +321 -0
  70. package/dist/lib/flow-engine.js.map +1 -0
  71. package/dist/lib/hook-transformer.d.ts +11 -0
  72. package/dist/lib/hook-transformer.d.ts.map +1 -0
  73. package/dist/lib/hook-transformer.js +74 -0
  74. package/dist/lib/hook-transformer.js.map +1 -0
  75. package/dist/lib/map-event-bus.d.ts +50 -0
  76. package/dist/lib/map-event-bus.d.ts.map +1 -0
  77. package/dist/lib/map-event-bus.js +366 -0
  78. package/dist/lib/map-event-bus.js.map +1 -0
  79. package/dist/lib/memory-indexer.d.ts.map +1 -1
  80. package/dist/lib/memory-indexer.js +26 -2
  81. package/dist/lib/memory-indexer.js.map +1 -1
  82. package/dist/lib/model-pricing.d.ts +11 -0
  83. package/dist/lib/model-pricing.d.ts.map +1 -0
  84. package/dist/lib/model-pricing.js +27 -0
  85. package/dist/lib/model-pricing.js.map +1 -0
  86. package/dist/lib/peter-parker-bridge.d.ts +34 -0
  87. package/dist/lib/peter-parker-bridge.d.ts.map +1 -0
  88. package/dist/lib/peter-parker-bridge.js +145 -0
  89. package/dist/lib/peter-parker-bridge.js.map +1 -0
  90. package/dist/lib/peter-parker-config.d.ts +13 -0
  91. package/dist/lib/peter-parker-config.d.ts.map +1 -0
  92. package/dist/lib/peter-parker-config.js +86 -0
  93. package/dist/lib/peter-parker-config.js.map +1 -0
  94. package/dist/lib/service-gtm.d.ts +7 -0
  95. package/dist/lib/service-gtm.d.ts.map +1 -1
  96. package/dist/lib/service-gtm.js.map +1 -1
  97. package/dist/lib/service-utils.d.ts.map +1 -1
  98. package/dist/lib/service-utils.js +33 -17
  99. package/dist/lib/service-utils.js.map +1 -1
  100. package/dist/lib/stratus-client.d.ts +1 -0
  101. package/dist/lib/stratus-client.d.ts.map +1 -1
  102. package/dist/lib/stratus-client.js +33 -2
  103. package/dist/lib/stratus-client.js.map +1 -1
  104. package/dist/lib/stratus-rollout-test.d.ts +10 -0
  105. package/dist/lib/stratus-rollout-test.d.ts.map +1 -0
  106. package/dist/lib/stratus-rollout-test.js +412 -0
  107. package/dist/lib/stratus-rollout-test.js.map +1 -0
  108. package/dist/lib/telemetry-digest.d.ts +10 -0
  109. package/dist/lib/telemetry-digest.d.ts.map +1 -0
  110. package/dist/lib/telemetry-digest.js +359 -0
  111. package/dist/lib/telemetry-digest.js.map +1 -0
  112. package/dist/lib/telemetry.d.ts +35 -0
  113. package/dist/lib/telemetry.d.ts.map +1 -0
  114. package/dist/lib/telemetry.js +320 -0
  115. package/dist/lib/telemetry.js.map +1 -0
  116. package/dist/lib/training-tuples.d.ts +33 -0
  117. package/dist/lib/training-tuples.d.ts.map +1 -0
  118. package/dist/lib/training-tuples.js +273 -0
  119. package/dist/lib/training-tuples.js.map +1 -0
  120. package/dist/mcp/context-hub-mcp.js +139 -22
  121. package/dist/mcp/context-hub-mcp.js.map +1 -1
  122. package/dist/types/flows.d.ts +62 -0
  123. package/dist/types/flows.d.ts.map +1 -0
  124. package/dist/types/flows.js +10 -0
  125. package/dist/types/flows.js.map +1 -0
  126. package/dist/types/map.d.ts +42 -0
  127. package/dist/types/map.d.ts.map +1 -0
  128. package/dist/types/map.js +39 -0
  129. package/dist/types/map.js.map +1 -0
  130. package/dist/types/telemetry-digest.d.ts +73 -0
  131. package/dist/types/telemetry-digest.d.ts.map +1 -0
  132. package/dist/types/telemetry-digest.js +5 -0
  133. package/dist/types/telemetry-digest.js.map +1 -0
  134. package/dist/types/telemetry.d.ts +69 -0
  135. package/dist/types/telemetry.d.ts.map +1 -0
  136. package/dist/types/telemetry.js +5 -0
  137. package/dist/types/telemetry.js.map +1 -0
  138. package/dist/ui/event-dashboard.d.ts +12 -0
  139. package/dist/ui/event-dashboard.d.ts.map +1 -0
  140. package/dist/ui/event-dashboard.js +342 -0
  141. package/dist/ui/event-dashboard.js.map +1 -0
  142. package/dist/utils/jfl-paths.d.ts +1 -0
  143. package/dist/utils/jfl-paths.d.ts.map +1 -1
  144. package/dist/utils/jfl-paths.js +1 -0
  145. package/dist/utils/jfl-paths.js.map +1 -1
  146. package/dist/utils/settings-validator.d.ts +3 -2
  147. package/dist/utils/settings-validator.d.ts.map +1 -1
  148. package/dist/utils/settings-validator.js +25 -6
  149. package/dist/utils/settings-validator.js.map +1 -1
  150. package/package.json +3 -2
  151. package/scripts/session/session-end.sh +10 -0
  152. package/scripts/session/session-init.sh +16 -0
  153. package/scripts/test-map-eventbus.sh +357 -0
@@ -18,6 +18,13 @@ 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 { FlowEngine } from "../lib/flow-engine.js";
25
+ import { WebSocketServer } from "ws";
26
+ import { telemetry } from "../lib/telemetry.js";
27
+ import { transformHookPayload } from "../lib/hook-transformer.js";
21
28
  const PID_FILE = ".jfl/context-hub.pid";
22
29
  const LOG_FILE = ".jfl/logs/context-hub.log";
23
30
  const TOKEN_FILE = ".jfl/context-hub.token";
@@ -39,22 +46,29 @@ function getOrCreateToken(projectRoot) {
39
46
  fs.writeFileSync(tokenFile, token, { mode: 0o600 }); // Owner read/write only
40
47
  return token;
41
48
  }
42
- function validateAuth(req, projectRoot) {
49
+ function validateAuth(req, projectRoot, url) {
43
50
  const tokenFile = getTokenFile(projectRoot);
44
51
  // If no token file exists, allow access (backwards compatibility during migration)
45
52
  if (!fs.existsSync(tokenFile)) {
46
53
  return true;
47
54
  }
48
55
  const expectedToken = fs.readFileSync(tokenFile, 'utf-8').trim();
56
+ // Check Authorization header first
49
57
  const authHeader = req.headers['authorization'];
50
- if (!authHeader) {
51
- return false;
58
+ if (authHeader) {
59
+ const providedToken = authHeader.startsWith('Bearer ')
60
+ ? authHeader.slice(7)
61
+ : authHeader;
62
+ if (providedToken === expectedToken)
63
+ return true;
64
+ }
65
+ // Fall back to ?token= query param (needed for SSE/EventSource which can't set headers)
66
+ if (url) {
67
+ const queryToken = url.searchParams.get('token');
68
+ if (queryToken && queryToken === expectedToken)
69
+ return true;
52
70
  }
53
- // Support "Bearer <token>" format
54
- const providedToken = authHeader.startsWith('Bearer ')
55
- ? authHeader.slice(7)
56
- : authHeader;
57
- return providedToken === expectedToken;
71
+ return false;
58
72
  }
59
73
  function isPortInUse(port) {
60
74
  return new Promise((resolve) => {
@@ -77,28 +91,31 @@ function isPortInUse(port) {
77
91
  // ============================================================================
78
92
  // Journal Reader
79
93
  // ============================================================================
80
- function readJournalEntries(projectRoot, limit = 20) {
94
+ function readJournalEntries(projectRoot, limit = 50) {
81
95
  const journalDir = path.join(projectRoot, ".jfl", "journal");
82
96
  const items = [];
83
97
  if (!fs.existsSync(journalDir)) {
84
98
  return items;
85
99
  }
100
+ // Sort by modification time (newest first) so recent entries aren't buried
86
101
  const files = fs.readdirSync(journalDir)
87
102
  .filter(f => f.endsWith(".jsonl"))
88
- .sort()
89
- .reverse();
103
+ .map(f => ({
104
+ name: f,
105
+ mtime: fs.statSync(path.join(journalDir, f)).mtimeMs
106
+ }))
107
+ .sort((a, b) => b.mtime - a.mtime)
108
+ .map(f => f.name);
109
+ // Read all entries from all files, then sort globally by timestamp
110
+ const allEntries = [];
90
111
  for (const file of files) {
91
- if (items.length >= limit)
92
- break;
93
112
  const filePath = path.join(journalDir, file);
94
113
  const content = fs.readFileSync(filePath, "utf-8");
95
114
  const lines = content.trim().split("\n").filter(l => l.trim());
96
- for (const line of lines.reverse()) {
97
- if (items.length >= limit)
98
- break;
115
+ for (const line of lines) {
99
116
  try {
100
117
  const entry = JSON.parse(line);
101
- items.push({
118
+ allEntries.push({
102
119
  source: "journal",
103
120
  type: entry.type || "entry",
104
121
  title: entry.title || "Untitled",
@@ -112,7 +129,13 @@ function readJournalEntries(projectRoot, limit = 20) {
112
129
  }
113
130
  }
114
131
  }
115
- return items;
132
+ // Sort all entries by timestamp descending, then take the limit
133
+ allEntries.sort((a, b) => {
134
+ const ta = a.timestamp || "";
135
+ const tb = b.timestamp || "";
136
+ return tb.localeCompare(ta);
137
+ });
138
+ return allEntries.slice(0, limit);
116
139
  }
117
140
  // ============================================================================
118
141
  // Knowledge Reader
@@ -395,8 +418,34 @@ function getUnifiedContext(projectRoot, query, taskType) {
395
418
  // ============================================================================
396
419
  // HTTP Server
397
420
  // ============================================================================
398
- function createServer(projectRoot, port) {
421
+ function createServer(projectRoot, port, eventBus) {
399
422
  const server = http.createServer((req, res) => {
423
+ const requestStart = Date.now();
424
+ const pathname = new URL(req.url || "/", `http://localhost:${port}`).pathname;
425
+ // Intercept writeHead to capture status code for telemetry
426
+ let capturedStatus = 200;
427
+ const originalWriteHead = res.writeHead.bind(res);
428
+ res.writeHead = function (statusCode, ...args) {
429
+ capturedStatus = statusCode;
430
+ return originalWriteHead(statusCode, ...args);
431
+ };
432
+ // Track request on response finish (skip health/OPTIONS/dashboard)
433
+ const shouldTrack = req.method !== "OPTIONS"
434
+ && pathname !== "/health"
435
+ && !pathname.startsWith("/dashboard");
436
+ if (shouldTrack) {
437
+ res.on('finish', () => {
438
+ telemetry.track({
439
+ category: 'context_hub',
440
+ event: 'context_hub:request',
441
+ endpoint: pathname,
442
+ method: req.method,
443
+ status_code: capturedStatus,
444
+ duration_ms: Date.now() - requestStart,
445
+ hub_port: port,
446
+ });
447
+ });
448
+ }
400
449
  // CORS - include Authorization header
401
450
  res.setHeader("Access-Control-Allow-Origin", "*");
402
451
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -413,8 +462,69 @@ function createServer(projectRoot, port) {
413
462
  res.end(JSON.stringify({ status: "ok", port }));
414
463
  return;
415
464
  }
465
+ // Dashboard - served without API auth (has its own token flow in JS)
466
+ if (url.pathname.startsWith("/dashboard")) {
467
+ import("../dashboard/index.js").then(({ handleDashboardRoutes }) => {
468
+ if (!handleDashboardRoutes(req, res, projectRoot, port)) {
469
+ res.writeHead(404, { "Content-Type": "application/json" });
470
+ res.end(JSON.stringify({ error: "Not found" }));
471
+ }
472
+ }).catch(() => {
473
+ res.writeHead(500, { "Content-Type": "application/json" });
474
+ res.end(JSON.stringify({ error: "Dashboard module failed to load" }));
475
+ });
476
+ return;
477
+ }
478
+ // Hook ingestion (Claude Code HTTP hooks)
479
+ // No auth required — localhost-only. Always returns 200.
480
+ if (url.pathname === "/api/hooks" && req.method === "POST") {
481
+ const remoteAddr = req.socket.remoteAddress || "";
482
+ const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
483
+ if (!isLocalhost) {
484
+ res.writeHead(403, { "Content-Type": "application/json" });
485
+ res.end(JSON.stringify({ error: "localhost only" }));
486
+ return;
487
+ }
488
+ let body = "";
489
+ let bodySize = 0;
490
+ const MAX_BODY = 64 * 1024;
491
+ req.on("data", (chunk) => {
492
+ bodySize += typeof chunk === "string" ? chunk.length : chunk.byteLength;
493
+ if (bodySize <= MAX_BODY) {
494
+ body += chunk;
495
+ }
496
+ });
497
+ req.on("end", () => {
498
+ try {
499
+ if (bodySize > MAX_BODY) {
500
+ console.warn(`[hooks] Dropped oversized payload: ${bodySize} bytes`);
501
+ res.writeHead(200, { "Content-Type": "application/json" });
502
+ res.end(JSON.stringify({ ok: true, dropped: true }));
503
+ return;
504
+ }
505
+ const payload = JSON.parse(body);
506
+ const partial = transformHookPayload(payload);
507
+ if (eventBus) {
508
+ eventBus.emit(partial);
509
+ }
510
+ res.writeHead(200, { "Content-Type": "application/json" });
511
+ res.end(JSON.stringify({ ok: true }));
512
+ }
513
+ catch (err) {
514
+ console.warn(`[hooks] Parse error: ${err.message}`);
515
+ res.writeHead(200, { "Content-Type": "application/json" });
516
+ res.end(JSON.stringify({ ok: true, parse_error: true }));
517
+ }
518
+ });
519
+ return;
520
+ }
416
521
  // All other endpoints require auth
417
- if (!validateAuth(req, projectRoot)) {
522
+ if (!validateAuth(req, projectRoot, url)) {
523
+ telemetry.track({
524
+ category: 'context_hub',
525
+ event: 'context_hub:auth_failed',
526
+ endpoint: pathname,
527
+ });
418
528
  res.writeHead(401, { "Content-Type": "application/json" });
419
529
  res.end(JSON.stringify({
420
530
  error: "Unauthorized",
@@ -446,6 +556,15 @@ function createServer(projectRoot, port) {
446
556
  if (maxItems && context.items.length > maxItems) {
447
557
  context.items = context.items.slice(0, maxItems);
448
558
  }
559
+ telemetry.track({
560
+ category: 'context_hub',
561
+ event: 'context_hub:context_loaded',
562
+ item_count: context.items.length,
563
+ journal_count: context.items.filter(i => i.source === 'journal').length,
564
+ knowledge_count: context.items.filter(i => i.source === 'knowledge').length,
565
+ code_count: context.items.filter(i => i.source === 'code').length,
566
+ query_length: query ? query.length : 0,
567
+ });
449
568
  res.writeHead(200, { "Content-Type": "application/json" });
450
569
  res.end(JSON.stringify(context));
451
570
  }
@@ -468,10 +587,19 @@ function createServer(projectRoot, port) {
468
587
  res.end(JSON.stringify({ error: "query required" }));
469
588
  return;
470
589
  }
590
+ const searchStart = Date.now();
471
591
  const context = getUnifiedContext(projectRoot, query);
472
592
  context.items = context.items
473
593
  .filter(item => item.relevance && item.relevance > 0)
474
594
  .slice(0, maxItems);
595
+ telemetry.track({
596
+ category: 'context_hub',
597
+ event: 'context_hub:search',
598
+ result_count: context.items.length,
599
+ duration_ms: Date.now() - searchStart,
600
+ has_query: true,
601
+ query_length: query.length,
602
+ });
475
603
  res.writeHead(200, { "Content-Type": "application/json" });
476
604
  res.end(JSON.stringify(context));
477
605
  }
@@ -579,10 +707,158 @@ function createServer(projectRoot, port) {
579
707
  });
580
708
  return;
581
709
  }
710
+ // Cross-project health
711
+ if (url.pathname === "/api/projects" && req.method === "GET") {
712
+ const tracked = getTrackedProjects();
713
+ Promise.all(tracked.map(async (p) => {
714
+ // Self-check: if this is our own port, we know we're OK
715
+ if (p.port === port) {
716
+ return {
717
+ name: p.path.split("/").pop() || p.path,
718
+ path: p.path,
719
+ port: p.port,
720
+ status: "OK",
721
+ pid: process.pid,
722
+ message: "This instance",
723
+ };
724
+ }
725
+ const result = await diagnoseProject(p.path, p.port);
726
+ return {
727
+ name: p.path.split("/").pop() || p.path,
728
+ path: p.path,
729
+ port: p.port,
730
+ status: result.status,
731
+ pid: result.pid,
732
+ message: result.message,
733
+ };
734
+ }))
735
+ .then((results) => {
736
+ res.writeHead(200, { "Content-Type": "application/json" });
737
+ res.end(JSON.stringify(results));
738
+ })
739
+ .catch((err) => {
740
+ res.writeHead(500, { "Content-Type": "application/json" });
741
+ res.end(JSON.stringify({ error: err.message }));
742
+ });
743
+ return;
744
+ }
745
+ // Publish event
746
+ if (url.pathname === "/api/events" && req.method === "POST") {
747
+ if (!eventBus) {
748
+ res.writeHead(503, { "Content-Type": "application/json" });
749
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
750
+ return;
751
+ }
752
+ let body = "";
753
+ req.on("data", chunk => body += chunk);
754
+ req.on("end", () => {
755
+ try {
756
+ const { type, source, target, session, data, ttl } = JSON.parse(body || "{}");
757
+ if (!type || !source) {
758
+ res.writeHead(400, { "Content-Type": "application/json" });
759
+ res.end(JSON.stringify({ error: "type and source required" }));
760
+ return;
761
+ }
762
+ const event = eventBus.emit({
763
+ type: type,
764
+ source,
765
+ target,
766
+ session,
767
+ data: data || {},
768
+ ttl,
769
+ });
770
+ res.writeHead(201, { "Content-Type": "application/json" });
771
+ res.end(JSON.stringify(event));
772
+ }
773
+ catch (err) {
774
+ res.writeHead(400, { "Content-Type": "application/json" });
775
+ res.end(JSON.stringify({ error: err.message }));
776
+ }
777
+ });
778
+ return;
779
+ }
780
+ // Get recent events
781
+ if (url.pathname === "/api/events" && req.method === "GET") {
782
+ if (!eventBus) {
783
+ res.writeHead(503, { "Content-Type": "application/json" });
784
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
785
+ return;
786
+ }
787
+ const since = url.searchParams.get("since") || undefined;
788
+ const pattern = url.searchParams.get("pattern") || undefined;
789
+ const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit"), 10) : 50;
790
+ const events = eventBus.getEvents({ since, pattern, limit });
791
+ res.writeHead(200, { "Content-Type": "application/json" });
792
+ res.end(JSON.stringify({ events, count: events.length }));
793
+ return;
794
+ }
795
+ // SSE event stream
796
+ if (url.pathname === "/api/events/stream" && req.method === "GET") {
797
+ if (!eventBus) {
798
+ res.writeHead(503, { "Content-Type": "application/json" });
799
+ res.end(JSON.stringify({ error: "Event bus not initialized" }));
800
+ return;
801
+ }
802
+ const patterns = (url.searchParams.get("patterns") || "*").split(",");
803
+ res.writeHead(200, {
804
+ "Content-Type": "text/event-stream",
805
+ "Cache-Control": "no-cache",
806
+ Connection: "keep-alive",
807
+ "Access-Control-Allow-Origin": "*",
808
+ });
809
+ res.write("retry: 3000\n\n");
810
+ const sub = eventBus.subscribe({
811
+ clientId: `sse-${Date.now()}`,
812
+ patterns,
813
+ transport: "sse",
814
+ callback: (event) => {
815
+ res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
816
+ },
817
+ });
818
+ req.on("close", () => {
819
+ eventBus.unsubscribe(sub.id);
820
+ });
821
+ return;
822
+ }
582
823
  // 404
583
824
  res.writeHead(404, { "Content-Type": "application/json" });
584
825
  res.end(JSON.stringify({ error: "Not found" }));
585
826
  });
827
+ // WebSocket upgrade for event streaming
828
+ if (eventBus) {
829
+ const wss = new WebSocketServer({ noServer: true });
830
+ server.on("upgrade", (request, socket, head) => {
831
+ const reqUrl = new URL(request.url || "/", `http://localhost:${port}`);
832
+ if (reqUrl.pathname !== "/ws/events") {
833
+ socket.destroy();
834
+ return;
835
+ }
836
+ if (!validateAuth(request, projectRoot, reqUrl)) {
837
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
838
+ socket.destroy();
839
+ return;
840
+ }
841
+ wss.handleUpgrade(request, socket, head, (ws) => {
842
+ const patterns = (reqUrl.searchParams.get("patterns") || "*").split(",");
843
+ const sub = eventBus.subscribe({
844
+ clientId: `ws-${Date.now()}`,
845
+ patterns,
846
+ transport: "websocket",
847
+ callback: (event) => {
848
+ if (ws.readyState === ws.OPEN) {
849
+ ws.send(JSON.stringify(event));
850
+ }
851
+ },
852
+ });
853
+ ws.on("close", () => {
854
+ eventBus.unsubscribe(sub.id);
855
+ });
856
+ ws.on("error", () => {
857
+ eventBus.unsubscribe(sub.id);
858
+ });
859
+ });
860
+ });
861
+ }
586
862
  return server;
587
863
  }
588
864
  // ============================================================================
@@ -615,6 +891,89 @@ export function isRunning(projectRoot) {
615
891
  return { running: false };
616
892
  }
617
893
  }
894
+ // ============================================================================
895
+ // Cross-Project Helpers
896
+ // ============================================================================
897
+ function getTrackedProjects() {
898
+ // Read from both config sources (Conf library + XDG config)
899
+ const confStore = new Conf({ projectName: "jfl" });
900
+ const confProjects = confStore.get("projects") || [];
901
+ const xdgProjects = getConfigValue("projects") || [];
902
+ // Deduplicate
903
+ const allPaths = [...new Set([...confProjects, ...xdgProjects])];
904
+ return allPaths
905
+ .filter(p => fs.existsSync(path.join(p, ".jfl")))
906
+ .map(p => ({ path: p, port: getProjectPort(p) }));
907
+ }
908
+ async function ensureForProject(projectRoot, port, quiet = false) {
909
+ const status = isRunning(projectRoot);
910
+ if (status.running) {
911
+ try {
912
+ const response = await fetch(`http://localhost:${port}/health`, {
913
+ signal: AbortSignal.timeout(2000)
914
+ });
915
+ if (response.ok) {
916
+ return { status: "running", message: `Already running (PID: ${status.pid})` };
917
+ }
918
+ }
919
+ catch {
920
+ // Process exists but not responding, fall through
921
+ }
922
+ }
923
+ const portInUse = await isPortInUse(port);
924
+ if (portInUse) {
925
+ try {
926
+ const response = await fetch(`http://localhost:${port}/health`, {
927
+ signal: AbortSignal.timeout(2000)
928
+ });
929
+ if (response.ok) {
930
+ return { status: "running", message: "Running (PID file missing but healthy)" };
931
+ }
932
+ }
933
+ catch {
934
+ // Not responding
935
+ }
936
+ try {
937
+ const lsofOutput = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
938
+ if (lsofOutput) {
939
+ const orphanedPid = parseInt(lsofOutput.split('\n')[0], 10);
940
+ if (!status.pid || orphanedPid !== status.pid) {
941
+ process.kill(orphanedPid, 'SIGTERM');
942
+ await new Promise(resolve => setTimeout(resolve, 500));
943
+ }
944
+ }
945
+ }
946
+ catch {
947
+ // lsof failed or process already gone
948
+ }
949
+ }
950
+ const result = await startDaemon(projectRoot, port);
951
+ if (result.success) {
952
+ return { status: "started", message: result.message };
953
+ }
954
+ return { status: "failed", message: result.message };
955
+ }
956
+ async function diagnoseProject(projectPath, port) {
957
+ if (!fs.existsSync(projectPath)) {
958
+ return { path: projectPath, port, status: "STALE", message: "Directory does not exist" };
959
+ }
960
+ const pidStatus = isRunning(projectPath);
961
+ if (!pidStatus.running) {
962
+ return { path: projectPath, port, status: "DOWN", pid: undefined };
963
+ }
964
+ try {
965
+ const response = await fetch(`http://localhost:${port}/health`, {
966
+ signal: AbortSignal.timeout(2000)
967
+ });
968
+ if (response.ok) {
969
+ return { path: projectPath, port, status: "OK", pid: pidStatus.pid };
970
+ }
971
+ }
972
+ catch {
973
+ // Not responding
974
+ }
975
+ return { path: projectPath, port, status: "ZOMBIE", pid: pidStatus.pid, message: "PID exists but not responding" };
976
+ }
618
977
  async function startDaemon(projectRoot, port) {
619
978
  const status = isRunning(projectRoot);
620
979
  if (status.running) {
@@ -639,12 +998,13 @@ async function startDaemon(projectRoot, port) {
639
998
  // Fall back to current process
640
999
  jflCmd = process.argv[1];
641
1000
  }
642
- // Start as detached process
1001
+ // Start as detached process with CONTEXT_HUB_DAEMON=1 so the serve
1002
+ // action knows to ignore SIGTERM during its startup grace period
643
1003
  const child = spawn(jflCmd, ["context-hub", "serve", "--port", String(port)], {
644
1004
  cwd: projectRoot,
645
1005
  detached: true,
646
1006
  stdio: ["ignore", fs.openSync(logFile, "a"), fs.openSync(logFile, "a")],
647
- env: { ...process.env, NODE_ENV: "production" }
1007
+ env: { ...process.env, NODE_ENV: "production", CONTEXT_HUB_DAEMON: "1" }
648
1008
  });
649
1009
  child.unref();
650
1010
  // Wait a moment to ensure process started
@@ -674,7 +1034,6 @@ async function stopDaemon(projectRoot) {
674
1034
  return { success: true, message: "Context Hub is not running" };
675
1035
  }
676
1036
  const pidFile = getPidFile(projectRoot);
677
- const tokenFile = getTokenFile(projectRoot);
678
1037
  try {
679
1038
  // Send SIGTERM first (graceful)
680
1039
  process.kill(status.pid, "SIGTERM");
@@ -700,13 +1059,10 @@ async function stopDaemon(projectRoot) {
700
1059
  catch {
701
1060
  // Process is gone, that's fine
702
1061
  }
703
- // Clean up PID and token files
1062
+ // Clean up PID file (preserve token for seamless restart)
704
1063
  if (fs.existsSync(pidFile)) {
705
1064
  fs.unlinkSync(pidFile);
706
1065
  }
707
- if (fs.existsSync(tokenFile)) {
708
- fs.unlinkSync(tokenFile);
709
- }
710
1066
  return { success: true, message: "Context Hub stopped" };
711
1067
  }
712
1068
  catch (err) {
@@ -714,6 +1070,117 @@ async function stopDaemon(projectRoot) {
714
1070
  }
715
1071
  }
716
1072
  // ============================================================================
1073
+ // Auto-Install Daemon
1074
+ // ============================================================================
1075
+ export async function ensureDaemonInstalled(opts) {
1076
+ const quiet = opts?.quiet ?? false;
1077
+ if (process.platform !== "darwin") {
1078
+ return false;
1079
+ }
1080
+ const plistLabel = "com.jfl.context-hub";
1081
+ const plistDir = path.join(homedir(), "Library", "LaunchAgents");
1082
+ const plistPath = path.join(plistDir, `${plistLabel}.plist`);
1083
+ const logPath = path.join(homedir(), ".config", "jfl", "context-hub-agent.log");
1084
+ // If plist exists, check if loaded
1085
+ if (fs.existsSync(plistPath)) {
1086
+ try {
1087
+ const output = execSync("launchctl list", { encoding: "utf-8" });
1088
+ if (output.includes(plistLabel)) {
1089
+ return true;
1090
+ }
1091
+ }
1092
+ catch {
1093
+ // launchctl failed, try to reload
1094
+ }
1095
+ // Exists but not loaded — reload it
1096
+ try {
1097
+ execSync(`launchctl load "${plistPath}"`, { stdio: "ignore" });
1098
+ if (!quiet) {
1099
+ console.log(chalk.green(`\n Daemon reloaded.`));
1100
+ console.log(chalk.gray(` Plist: ${plistPath}\n`));
1101
+ }
1102
+ return true;
1103
+ }
1104
+ catch {
1105
+ // Fall through to full install
1106
+ }
1107
+ }
1108
+ // Full install
1109
+ let jflPath = "";
1110
+ try {
1111
+ jflPath = execSync("which jfl", { encoding: "utf-8" }).trim();
1112
+ }
1113
+ catch {
1114
+ jflPath = process.argv[1] || "jfl";
1115
+ }
1116
+ const logDir = path.dirname(logPath);
1117
+ if (!fs.existsSync(logDir)) {
1118
+ fs.mkdirSync(logDir, { recursive: true });
1119
+ }
1120
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1121
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1122
+ <plist version="1.0">
1123
+ <dict>
1124
+ <key>Label</key>
1125
+ <string>${plistLabel}</string>
1126
+ <key>ProgramArguments</key>
1127
+ <array>
1128
+ <string>${jflPath}</string>
1129
+ <string>context-hub</string>
1130
+ <string>ensure-all</string>
1131
+ <string>--quiet</string>
1132
+ </array>
1133
+ <key>RunAtLoad</key>
1134
+ <true/>
1135
+ <key>StartInterval</key>
1136
+ <integer>300</integer>
1137
+ <key>ProcessType</key>
1138
+ <string>Background</string>
1139
+ <key>LowPriorityIO</key>
1140
+ <true/>
1141
+ <key>Nice</key>
1142
+ <integer>10</integer>
1143
+ <key>EnvironmentVariables</key>
1144
+ <dict>
1145
+ <key>PATH</key>
1146
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
1147
+ </dict>
1148
+ <key>StandardOutPath</key>
1149
+ <string>${logPath}</string>
1150
+ <key>StandardErrorPath</key>
1151
+ <string>${logPath}</string>
1152
+ </dict>
1153
+ </plist>
1154
+ `;
1155
+ if (!fs.existsSync(plistDir)) {
1156
+ fs.mkdirSync(plistDir, { recursive: true });
1157
+ }
1158
+ // Unload if partially loaded
1159
+ try {
1160
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
1161
+ }
1162
+ catch {
1163
+ // Not loaded, fine
1164
+ }
1165
+ fs.writeFileSync(plistPath, plistContent);
1166
+ try {
1167
+ execSync(`launchctl load "${plistPath}"`);
1168
+ if (!quiet) {
1169
+ console.log(chalk.green(`\n Daemon installed and loaded.`));
1170
+ console.log(chalk.gray(` Plist: ${plistPath}`));
1171
+ console.log(chalk.gray(` Log: ${logPath}`));
1172
+ console.log(chalk.gray(` Runs ensure-all every 5 minutes + on login.\n`));
1173
+ }
1174
+ return true;
1175
+ }
1176
+ catch {
1177
+ if (!quiet) {
1178
+ console.log(chalk.red(`\n Failed to load daemon.\n`));
1179
+ }
1180
+ return false;
1181
+ }
1182
+ }
1183
+ // ============================================================================
717
1184
  // CLI Command
718
1185
  // ============================================================================
719
1186
  export async function contextHubCommand(action, options = {}) {
@@ -797,69 +1264,164 @@ export async function contextHubCommand(action, options = {}) {
797
1264
  break;
798
1265
  }
799
1266
  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
1267
+ await ensureForProject(projectRoot, port, true);
1268
+ break;
1269
+ }
1270
+ case "ensure-all": {
1271
+ const tracked = getTrackedProjects();
1272
+ if (tracked.length === 0) {
1273
+ if (!options.quiet) {
1274
+ console.log(chalk.yellow("\n No tracked projects found.\n"));
814
1275
  }
1276
+ break;
815
1277
  }
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
- }
1278
+ const results = [];
1279
+ for (const project of tracked) {
1280
+ const name = path.basename(project.path);
1281
+ const result = await ensureForProject(project.path, project.port, true);
1282
+ results.push({ name, result });
1283
+ }
1284
+ if (!options.quiet) {
1285
+ console.log(chalk.bold("\n Context Hub - ensure-all\n"));
1286
+ for (const { name, result } of results) {
1287
+ const icon = result.status === "failed" ? chalk.red("✗") : chalk.green("✓");
1288
+ const label = result.status === "started" ? chalk.cyan("started") :
1289
+ result.status === "running" ? chalk.green("running") :
1290
+ chalk.red("failed");
1291
+ console.log(` ${icon} ${chalk.bold(name)} — ${label}`);
829
1292
  }
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
1293
+ const ok = results.filter(r => r.result.status !== "failed").length;
1294
+ const fail = results.filter(r => r.result.status === "failed").length;
1295
+ console.log(chalk.gray(`\n ${ok} running, ${fail} failed\n`));
1296
+ }
1297
+ break;
1298
+ }
1299
+ case "doctor": {
1300
+ const confStoreDoctor = new Conf({ projectName: "jfl" });
1301
+ const confProjectsDoctor = confStoreDoctor.get("projects") || [];
1302
+ const xdgProjectsDoctor = getConfigValue("projects") || [];
1303
+ const allProjects = [...new Set([...confProjectsDoctor, ...xdgProjectsDoctor])];
1304
+ if (allProjects.length === 0) {
1305
+ console.log(chalk.yellow("\n No tracked projects found.\n"));
1306
+ break;
1307
+ }
1308
+ const cleanMode = process.argv.includes("--clean");
1309
+ if (cleanMode) {
1310
+ const before = allProjects.length;
1311
+ const valid = allProjects.filter(p => fs.existsSync(p));
1312
+ // Write cleaned list back to both stores
1313
+ confStoreDoctor.set("projects", valid.filter(p => confProjectsDoctor.includes(p)));
1314
+ setConfig("projects", valid.filter(p => xdgProjectsDoctor.includes(p)));
1315
+ const removed = before - valid.length;
1316
+ if (removed > 0) {
1317
+ console.log(chalk.green(`\n Removed ${removed} stale project${removed > 1 ? "s" : ""} from tracker.\n`));
833
1318
  }
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
- }
1319
+ else {
1320
+ console.log(chalk.green("\n No stale projects found.\n"));
845
1321
  }
846
- catch {
847
- // lsof failed or process already gone
1322
+ break;
1323
+ }
1324
+ console.log(chalk.bold("\n Context Hub - doctor\n"));
1325
+ let staleCount = 0;
1326
+ let downCount = 0;
1327
+ let zombieCount = 0;
1328
+ let okCount = 0;
1329
+ for (const projectPath of allProjects) {
1330
+ const projectPort = getProjectPort(projectPath);
1331
+ const result = await diagnoseProject(projectPath, projectPort);
1332
+ const name = path.basename(result.path);
1333
+ switch (result.status) {
1334
+ case "OK":
1335
+ console.log(` ${chalk.green("OK")} ${chalk.bold(name)} — PID ${result.pid}, port ${result.port}`);
1336
+ okCount++;
1337
+ break;
1338
+ case "ZOMBIE":
1339
+ console.log(` ${chalk.red("ZOMBIE")} ${chalk.bold(name)} — PID ${result.pid} not responding on port ${result.port}`);
1340
+ zombieCount++;
1341
+ break;
1342
+ case "DOWN":
1343
+ console.log(` ${chalk.red("DOWN")} ${chalk.bold(name)} — not running (port ${result.port})`);
1344
+ downCount++;
1345
+ break;
1346
+ case "STALE":
1347
+ console.log(` ${chalk.yellow("STALE")} ${chalk.gray(result.path)} — directory missing`);
1348
+ staleCount++;
1349
+ break;
848
1350
  }
849
1351
  }
850
- // Start silently
851
- await startDaemon(projectRoot, port);
1352
+ console.log();
1353
+ if (downCount > 0 || zombieCount > 0) {
1354
+ console.log(chalk.gray(` Hint: run ${chalk.cyan("jfl context-hub ensure-all")} to start all hubs`));
1355
+ }
1356
+ if (staleCount > 0) {
1357
+ console.log(chalk.gray(` Hint: run ${chalk.cyan("jfl context-hub doctor --clean")} to remove stale entries`));
1358
+ }
1359
+ if (okCount === allProjects.length) {
1360
+ console.log(chalk.green(" All projects healthy."));
1361
+ }
1362
+ console.log();
1363
+ break;
1364
+ }
1365
+ case "install-daemon": {
1366
+ const result = await ensureDaemonInstalled({ quiet: false });
1367
+ if (!result) {
1368
+ console.log(chalk.yellow("\n Daemon install skipped (non-macOS or failed).\n"));
1369
+ }
1370
+ break;
1371
+ }
1372
+ case "uninstall-daemon": {
1373
+ const plistLabel = "com.jfl.context-hub";
1374
+ const plistPath = path.join(homedir(), "Library", "LaunchAgents", `${plistLabel}.plist`);
1375
+ if (!fs.existsSync(plistPath)) {
1376
+ console.log(chalk.yellow("\n Daemon not installed.\n"));
1377
+ break;
1378
+ }
1379
+ try {
1380
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
1381
+ }
1382
+ catch {
1383
+ // Already unloaded
1384
+ }
1385
+ fs.unlinkSync(plistPath);
1386
+ console.log(chalk.green("\n Daemon uninstalled.\n"));
852
1387
  break;
853
1388
  }
854
1389
  case "serve": {
855
1390
  // Run server in foreground (used by daemon)
856
- const server = createServer(projectRoot, port);
1391
+ const serviceEventsPath = path.join(projectRoot, ".jfl", "service-events.jsonl");
1392
+ const mapPersistPath = path.join(projectRoot, ".jfl", "map-events.jsonl");
1393
+ const journalDir = path.join(projectRoot, ".jfl", "journal");
1394
+ const eventBus = new MAPEventBus({
1395
+ maxSize: 1000,
1396
+ persistPath: mapPersistPath,
1397
+ serviceEventsPath,
1398
+ journalDir: fs.existsSync(journalDir) ? journalDir : null,
1399
+ });
1400
+ const server = createServer(projectRoot, port, eventBus);
857
1401
  let isListening = false;
1402
+ // When spawned as daemon, ignore SIGTERM during startup grace period.
1403
+ // The parent process (hook runner) may exit and send SIGTERM to the
1404
+ // process group before we're fully detached. After grace period,
1405
+ // re-enable normal shutdown handling.
1406
+ const isDaemon = process.env.CONTEXT_HUB_DAEMON === "1";
1407
+ let startupGrace = isDaemon;
1408
+ if (isDaemon) {
1409
+ setTimeout(() => {
1410
+ startupGrace = false;
1411
+ }, 5000);
1412
+ }
858
1413
  // Error handling - keep process alive
859
1414
  process.on("uncaughtException", (err) => {
860
1415
  console.error(`Uncaught exception: ${err.message}`);
861
1416
  console.error(err.stack);
862
- // Don't exit - log and continue
1417
+ telemetry.track({
1418
+ category: 'error',
1419
+ event: 'error:hub_crash',
1420
+ error_type: err.constructor.name,
1421
+ error_code: err.code || undefined,
1422
+ hub_port: port,
1423
+ hub_uptime_s: isListening ? Math.floor((Date.now() - hubStartTime) / 1000) : 0,
1424
+ });
863
1425
  });
864
1426
  process.on("unhandledRejection", (reason, promise) => {
865
1427
  console.error(`Unhandled rejection at ${promise}: ${reason}`);
@@ -867,17 +1429,30 @@ export async function contextHubCommand(action, options = {}) {
867
1429
  });
868
1430
  server.on("error", (err) => {
869
1431
  console.error(`Server error: ${err.message}`);
1432
+ telemetry.track({
1433
+ category: 'error',
1434
+ event: 'error:hub_server',
1435
+ error_type: err.constructor.name,
1436
+ error_code: err.code || undefined,
1437
+ hub_port: port,
1438
+ });
870
1439
  if (err.code === "EADDRINUSE") {
871
1440
  console.error(`Port ${port} is already in use. Exiting.`);
872
1441
  process.exit(1);
873
1442
  }
874
- // For other errors, don't exit
875
1443
  });
1444
+ const hubStartTime = Date.now();
876
1445
  server.listen(port, async () => {
877
1446
  isListening = true;
878
1447
  const timestamp = new Date().toISOString();
879
1448
  console.log(`[${timestamp}] Context Hub listening on port ${port}`);
880
1449
  console.log(`[${timestamp}] PID: ${process.pid}`);
1450
+ telemetry.track({
1451
+ category: 'context_hub',
1452
+ event: 'context_hub:started',
1453
+ hub_port: port,
1454
+ duration_ms: Date.now() - hubStartTime,
1455
+ });
881
1456
  // Initialize memory system
882
1457
  try {
883
1458
  await initializeDatabase();
@@ -895,10 +1470,30 @@ export async function contextHubCommand(action, options = {}) {
895
1470
  console.error(`[${timestamp}] Failed to initialize memory system:`, err.message);
896
1471
  // Don't exit - memory is optional
897
1472
  }
1473
+ // Start flow engine
1474
+ try {
1475
+ const flowEngine = new FlowEngine(eventBus, projectRoot);
1476
+ const flowCount = await flowEngine.start();
1477
+ if (flowCount > 0) {
1478
+ console.log(`[${timestamp}] Flow engine started with ${flowCount} active flow(s)`);
1479
+ }
1480
+ }
1481
+ catch (err) {
1482
+ console.error(`[${timestamp}] Failed to start flow engine:`, err.message);
1483
+ }
1484
+ console.log(`[${timestamp}] MAP event bus initialized (buffer: 1000, subscribers: ${eventBus.getSubscriberCount()})`);
898
1485
  console.log(`[${timestamp}] Ready to serve requests`);
899
1486
  });
900
1487
  // Handle shutdown gracefully
901
1488
  const shutdown = (signal) => {
1489
+ // During startup grace period (daemon mode), ignore SIGTERM from
1490
+ // parent process cleanup. This prevents the hook runner from
1491
+ // killing the hub before it's fully detached.
1492
+ if (startupGrace && signal === "SIGTERM") {
1493
+ const ts = new Date().toISOString();
1494
+ console.log(`[${ts}] Ignoring ${signal} during startup grace period (PID: ${process.pid}, Parent: ${process.ppid})`);
1495
+ return;
1496
+ }
902
1497
  if (!isListening) {
903
1498
  // Server never started, just exit
904
1499
  process.exit(0);
@@ -909,7 +1504,14 @@ export async function contextHubCommand(action, options = {}) {
909
1504
  console.log(`[${timestamp}] Received ${signal}`);
910
1505
  console.log(`[${timestamp}] PID: ${process.pid}, Parent PID: ${process.ppid}`);
911
1506
  console.log(`[${timestamp}] Shutting down...`);
1507
+ telemetry.track({
1508
+ category: 'context_hub',
1509
+ event: 'context_hub:stopped',
1510
+ hub_port: port,
1511
+ hub_uptime_s: Math.floor((Date.now() - hubStartTime) / 1000),
1512
+ });
912
1513
  server.close(() => {
1514
+ eventBus.destroy();
913
1515
  console.log(`[${new Date().toISOString()}] Server closed`);
914
1516
  process.exit(0);
915
1517
  });
@@ -964,6 +1566,18 @@ export async function contextHubCommand(action, options = {}) {
964
1566
  });
965
1567
  break;
966
1568
  }
1569
+ case "dashboard": {
1570
+ const token = getOrCreateToken(projectRoot);
1571
+ const dashUrl = `http://localhost:${port}/dashboard?token=${token}`;
1572
+ try {
1573
+ execSync(`open "${dashUrl}"`);
1574
+ }
1575
+ catch {
1576
+ // open not available — print URL instead
1577
+ }
1578
+ console.log(chalk.gray(` Opening ${dashUrl}`));
1579
+ break;
1580
+ }
967
1581
  case "clear-logs": {
968
1582
  // Clear both global and local log files
969
1583
  const { JFL_FILES } = await import("../utils/jfl-paths.js");
@@ -990,17 +1604,24 @@ export async function contextHubCommand(action, options = {}) {
990
1604
  default: {
991
1605
  console.log(chalk.bold("\n Context Hub - Unified context for AI agents\n"));
992
1606
  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");
1607
+ console.log(" jfl context-hub start Start the daemon");
1608
+ console.log(" jfl context-hub stop Stop the daemon");
1609
+ console.log(" jfl context-hub restart Restart the daemon");
1610
+ console.log(" jfl context-hub status Check if running");
1611
+ console.log(" jfl context-hub ensure Start if not running (for hooks)");
1612
+ console.log(" jfl context-hub ensure-all Ensure all tracked projects are running");
1613
+ console.log(" jfl context-hub doctor Diagnose all tracked projects");
1614
+ console.log(" jfl context-hub doctor --clean Remove stale project entries");
1615
+ console.log(" jfl context-hub dashboard Open web dashboard in browser");
1616
+ console.log(" jfl context-hub install-daemon Install macOS launchd keepalive");
1617
+ console.log(" jfl context-hub uninstall-daemon Remove macOS launchd keepalive");
1618
+ console.log(" jfl context-hub logs Show real-time logs (TUI)");
1619
+ console.log(" jfl context-hub clear-logs Clear log file");
1620
+ console.log(" jfl context-hub query Quick context query");
1001
1621
  console.log();
1002
1622
  console.log(chalk.gray(" Options:"));
1003
1623
  console.log(" --port <port> Port to run on (default: per-project)");
1624
+ console.log(" --quiet Suppress output (for daemon use)");
1004
1625
  console.log();
1005
1626
  }
1006
1627
  }