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