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.
- package/README.md +399 -423
- package/clawdbot-plugin/clawdbot.plugin.json +12 -1
- package/clawdbot-plugin/index.js +5 -5
- package/clawdbot-plugin/index.ts +5 -5
- package/dist/commands/context-hub.d.ts +4 -0
- package/dist/commands/context-hub.d.ts.map +1 -1
- package/dist/commands/context-hub.js +704 -83
- package/dist/commands/context-hub.js.map +1 -1
- package/dist/commands/digest.d.ts +6 -0
- package/dist/commands/digest.d.ts.map +1 -0
- package/dist/commands/digest.js +81 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/flows.d.ts +7 -0
- package/dist/commands/flows.d.ts.map +1 -0
- package/dist/commands/flows.js +264 -0
- package/dist/commands/flows.js.map +1 -0
- package/dist/commands/hooks.d.ts +11 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +303 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/improve.d.ts +11 -0
- package/dist/commands/improve.d.ts.map +1 -0
- package/dist/commands/improve.js +77 -0
- package/dist/commands/improve.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +42 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/peter.d.ts +15 -0
- package/dist/commands/peter.d.ts.map +1 -0
- package/dist/commands/peter.js +198 -0
- package/dist/commands/peter.js.map +1 -0
- package/dist/commands/ralph.d.ts +3 -1
- package/dist/commands/ralph.d.ts.map +1 -1
- package/dist/commands/ralph.js +40 -5
- package/dist/commands/ralph.js.map +1 -1
- package/dist/commands/scope.d.ts +7 -0
- package/dist/commands/scope.d.ts.map +1 -0
- package/dist/commands/scope.js +227 -0
- package/dist/commands/scope.js.map +1 -0
- package/dist/commands/service-validate.js +7 -1
- package/dist/commands/service-validate.js.map +1 -1
- package/dist/commands/session.d.ts +2 -1
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +519 -49
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +25 -6
- package/dist/commands/update.js.map +1 -1
- package/dist/dashboard/components.d.ts +7 -0
- package/dist/dashboard/components.d.ts.map +1 -0
- package/dist/dashboard/components.js +163 -0
- package/dist/dashboard/components.js.map +1 -0
- package/dist/dashboard/index.d.ts +12 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +132 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/pages.d.ts +7 -0
- package/dist/dashboard/pages.d.ts.map +1 -0
- package/dist/dashboard/pages.js +742 -0
- package/dist/dashboard/pages.js.map +1 -0
- package/dist/dashboard/styles.d.ts +7 -0
- package/dist/dashboard/styles.d.ts.map +1 -0
- package/dist/dashboard/styles.js +497 -0
- package/dist/dashboard/styles.js.map +1 -0
- package/dist/index.js +196 -8
- package/dist/index.js.map +1 -1
- package/dist/lib/flow-engine.d.ts +34 -0
- package/dist/lib/flow-engine.d.ts.map +1 -0
- package/dist/lib/flow-engine.js +321 -0
- package/dist/lib/flow-engine.js.map +1 -0
- package/dist/lib/hook-transformer.d.ts +11 -0
- package/dist/lib/hook-transformer.d.ts.map +1 -0
- package/dist/lib/hook-transformer.js +74 -0
- package/dist/lib/hook-transformer.js.map +1 -0
- package/dist/lib/map-event-bus.d.ts +50 -0
- package/dist/lib/map-event-bus.d.ts.map +1 -0
- package/dist/lib/map-event-bus.js +366 -0
- package/dist/lib/map-event-bus.js.map +1 -0
- package/dist/lib/memory-indexer.d.ts.map +1 -1
- package/dist/lib/memory-indexer.js +26 -2
- package/dist/lib/memory-indexer.js.map +1 -1
- package/dist/lib/model-pricing.d.ts +11 -0
- package/dist/lib/model-pricing.d.ts.map +1 -0
- package/dist/lib/model-pricing.js +27 -0
- package/dist/lib/model-pricing.js.map +1 -0
- package/dist/lib/peter-parker-bridge.d.ts +34 -0
- package/dist/lib/peter-parker-bridge.d.ts.map +1 -0
- package/dist/lib/peter-parker-bridge.js +145 -0
- package/dist/lib/peter-parker-bridge.js.map +1 -0
- package/dist/lib/peter-parker-config.d.ts +13 -0
- package/dist/lib/peter-parker-config.d.ts.map +1 -0
- package/dist/lib/peter-parker-config.js +86 -0
- package/dist/lib/peter-parker-config.js.map +1 -0
- package/dist/lib/service-gtm.d.ts +7 -0
- package/dist/lib/service-gtm.d.ts.map +1 -1
- package/dist/lib/service-gtm.js.map +1 -1
- package/dist/lib/service-utils.d.ts.map +1 -1
- package/dist/lib/service-utils.js +33 -17
- package/dist/lib/service-utils.js.map +1 -1
- package/dist/lib/stratus-client.d.ts +1 -0
- package/dist/lib/stratus-client.d.ts.map +1 -1
- package/dist/lib/stratus-client.js +33 -2
- package/dist/lib/stratus-client.js.map +1 -1
- package/dist/lib/stratus-rollout-test.d.ts +10 -0
- package/dist/lib/stratus-rollout-test.d.ts.map +1 -0
- package/dist/lib/stratus-rollout-test.js +412 -0
- package/dist/lib/stratus-rollout-test.js.map +1 -0
- package/dist/lib/telemetry-digest.d.ts +10 -0
- package/dist/lib/telemetry-digest.d.ts.map +1 -0
- package/dist/lib/telemetry-digest.js +359 -0
- package/dist/lib/telemetry-digest.js.map +1 -0
- package/dist/lib/telemetry.d.ts +35 -0
- package/dist/lib/telemetry.d.ts.map +1 -0
- package/dist/lib/telemetry.js +320 -0
- package/dist/lib/telemetry.js.map +1 -0
- package/dist/lib/training-tuples.d.ts +33 -0
- package/dist/lib/training-tuples.d.ts.map +1 -0
- package/dist/lib/training-tuples.js +273 -0
- package/dist/lib/training-tuples.js.map +1 -0
- package/dist/mcp/context-hub-mcp.js +139 -22
- package/dist/mcp/context-hub-mcp.js.map +1 -1
- package/dist/types/flows.d.ts +62 -0
- package/dist/types/flows.d.ts.map +1 -0
- package/dist/types/flows.js +10 -0
- package/dist/types/flows.js.map +1 -0
- package/dist/types/map.d.ts +42 -0
- package/dist/types/map.d.ts.map +1 -0
- package/dist/types/map.js +39 -0
- package/dist/types/map.js.map +1 -0
- package/dist/types/telemetry-digest.d.ts +73 -0
- package/dist/types/telemetry-digest.d.ts.map +1 -0
- package/dist/types/telemetry-digest.js +5 -0
- package/dist/types/telemetry-digest.js.map +1 -0
- package/dist/types/telemetry.d.ts +69 -0
- package/dist/types/telemetry.d.ts.map +1 -0
- package/dist/types/telemetry.js +5 -0
- package/dist/types/telemetry.js.map +1 -0
- package/dist/ui/event-dashboard.d.ts +12 -0
- package/dist/ui/event-dashboard.d.ts.map +1 -0
- package/dist/ui/event-dashboard.js +342 -0
- package/dist/ui/event-dashboard.js.map +1 -0
- package/dist/utils/jfl-paths.d.ts +1 -0
- package/dist/utils/jfl-paths.d.ts.map +1 -1
- package/dist/utils/jfl-paths.js +1 -0
- package/dist/utils/jfl-paths.js.map +1 -1
- package/dist/utils/settings-validator.d.ts +3 -2
- package/dist/utils/settings-validator.d.ts.map +1 -1
- package/dist/utils/settings-validator.js +25 -6
- package/dist/utils/settings-validator.js.map +1 -1
- package/package.json +3 -2
- package/scripts/session/session-end.sh +10 -0
- package/scripts/session/session-init.sh +16 -0
- 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 (
|
|
51
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
.
|
|
89
|
-
|
|
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
|
|
97
|
-
if (items.length >= limit)
|
|
98
|
-
break;
|
|
115
|
+
for (const line of lines) {
|
|
99
116
|
try {
|
|
100
117
|
const entry = JSON.parse(line);
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
851
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
994
|
-
console.log(" jfl context-hub stop
|
|
995
|
-
console.log(" jfl context-hub restart
|
|
996
|
-
console.log(" jfl context-hub status
|
|
997
|
-
console.log(" jfl context-hub
|
|
998
|
-
console.log(" jfl context-hub
|
|
999
|
-
console.log(" jfl context-hub
|
|
1000
|
-
console.log(" jfl context-hub
|
|
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
|
}
|