grepmax 0.26.27 → 0.26.30
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +40 -0
- package/dist/commands/audit.js +28 -260
- package/dist/commands/context.js +42 -26
- package/dist/commands/dead.js +37 -35
- package/dist/commands/doctor.js +280 -10
- package/dist/commands/extract.js +79 -49
- package/dist/commands/impact.js +74 -20
- package/dist/commands/log.js +25 -25
- package/dist/commands/mcp.js +639 -475
- package/dist/commands/peek.js +49 -47
- package/dist/commands/project.js +29 -107
- package/dist/commands/related.js +43 -52
- package/dist/commands/search-run.js +23 -8
- package/dist/commands/search.js +7 -2
- package/dist/commands/similar.js +50 -76
- package/dist/commands/skeleton.js +137 -83
- package/dist/commands/status.js +90 -25
- package/dist/commands/surprises.js +73 -16
- package/dist/commands/symbols.js +58 -77
- package/dist/commands/test-find.js +51 -17
- package/dist/commands/trace.js +26 -11
- package/dist/commands/watch.js +1 -1
- package/dist/config.js +8 -2
- package/dist/lib/analysis/surprising-connections.js +135 -15
- package/dist/lib/daemon/daemon.js +157 -13
- package/dist/lib/daemon/graph-handler.js +859 -0
- package/dist/lib/daemon/ipc-handler.js +36 -4
- package/dist/lib/daemon/read-verbs.js +156 -0
- package/dist/lib/daemon/rows-handler.js +665 -0
- package/dist/lib/daemon/search-handler.js +7 -4
- package/dist/lib/daemon/vector-handler.js +199 -0
- package/dist/lib/daemon/watch-leases.js +179 -0
- package/dist/lib/index/batch-processor.js +7 -1
- package/dist/lib/index/watcher.js +4 -0
- package/dist/lib/search/pagerank.js +80 -19
- package/dist/lib/store/vector-db.js +35 -1
- package/dist/lib/utils/async-semaphore.js +141 -0
- package/dist/lib/utils/daemon-client.js +11 -0
- package/dist/lib/utils/process-footprint.js +51 -0
- package/dist/lib/utils/store-access.js +381 -0
- package/dist/lib/utils/tests-footer.js +19 -2
- package/dist/lib/utils/watcher-launcher.js +46 -19
- package/dist/lib/workers/pool.js +72 -2
- package/package.json +1 -1
- package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
- package/plugins/grepmax/hooks/cwd-changed.js +19 -36
- package/plugins/grepmax/hooks/start.js +15 -19
- package/plugins/grepmax/hooks/stop.js +13 -4
- package/plugins/grepmax/hooks/watch-lease.js +163 -0
|
@@ -20,8 +20,9 @@ const autostart_1 = require("./autostart");
|
|
|
20
20
|
const daemon_client_1 = require("./daemon-client");
|
|
21
21
|
const daemon_launcher_1 = require("./daemon-launcher");
|
|
22
22
|
const project_registry_1 = require("./project-registry");
|
|
23
|
+
const store_access_1 = require("./store-access");
|
|
23
24
|
const watcher_store_1 = require("./watcher-store");
|
|
24
|
-
function launchWatcher(projectRoot) {
|
|
25
|
+
function launchWatcher(projectRoot, lease) {
|
|
25
26
|
return __awaiter(this, void 0, void 0, function* () {
|
|
26
27
|
var _a;
|
|
27
28
|
// 1. Project must be registered
|
|
@@ -33,15 +34,19 @@ function launchWatcher(projectRoot) {
|
|
|
33
34
|
message: `Project not registered. Run: gmax add ${projectRoot}`,
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
|
-
// 2. Check if watcher already running (daemon registers per-project entries)
|
|
37
|
-
|
|
37
|
+
// 2. Check if watcher already running (daemon registers per-project entries).
|
|
38
|
+
// A lease holder must reach the daemon every time: the renewal is the point.
|
|
39
|
+
const existing = lease
|
|
40
|
+
? undefined
|
|
41
|
+
: ((_a = (0, watcher_store_1.getWatcherForProject)(projectRoot)) !== null && _a !== void 0 ? _a : (0, watcher_store_1.getWatcherCoveringPath)(projectRoot));
|
|
38
42
|
if (existing && (0, watcher_store_1.isProcessRunning)(existing.pid)) {
|
|
39
43
|
return { ok: true, pid: existing.pid, reused: true };
|
|
40
44
|
}
|
|
45
|
+
const watchCmd = Object.assign({ cmd: "watch", root: projectRoot }, lease);
|
|
41
46
|
// 3. Try daemon IPC
|
|
42
47
|
let resp;
|
|
43
48
|
try {
|
|
44
|
-
resp = yield (0, daemon_client_1.sendDaemonCommand)(
|
|
49
|
+
resp = yield (0, daemon_client_1.sendDaemonCommand)(watchCmd);
|
|
45
50
|
}
|
|
46
51
|
catch (_b) {
|
|
47
52
|
resp = { ok: false, error: "request-failed" };
|
|
@@ -56,26 +61,48 @@ function launchWatcher(projectRoot) {
|
|
|
56
61
|
if (notice) {
|
|
57
62
|
return { ok: false, reason: "autostart-disabled", message: notice };
|
|
58
63
|
}
|
|
64
|
+
// A spawned daemon (or per-project watcher) inherits this process's sandbox
|
|
65
|
+
// for its whole life. If ~/.gmax is read-only here it will be read-only
|
|
66
|
+
// there, and the child dies on its own lock mkdir — so probe once and say so
|
|
67
|
+
// instead of leaving a crash in daemon.log.
|
|
68
|
+
const sandboxed = (0, store_access_1.storeWriteDeniedNotice)();
|
|
69
|
+
if (sandboxed) {
|
|
70
|
+
(0, store_access_1.warnStoreWriteDeniedOnce)(sandboxed);
|
|
71
|
+
return { ok: false, reason: "sandboxed", message: sandboxed };
|
|
72
|
+
}
|
|
59
73
|
const error = resp.error;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
// Anything but "nothing is listening" is a live daemon that said no or was
|
|
75
|
+
// slow (initializing, busy, timeout). A per-project watcher next to it would
|
|
76
|
+
// be a second writer on the store, and the MCP lease renewal would spawn one
|
|
77
|
+
// every few minutes — so report instead of falling through.
|
|
78
|
+
if (error !== "ENOENT" && error !== "ECONNREFUSED") {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: "daemon-refused",
|
|
82
|
+
message: `Daemon did not accept the watch: ${error !== null && error !== void 0 ? error : "unknown error"}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const daemonPid = yield (0, daemon_launcher_1.spawnDaemon)();
|
|
86
|
+
if (daemonPid) {
|
|
87
|
+
for (let i = 0; i < 25; i++) {
|
|
88
|
+
yield new Promise((r) => setTimeout(r, 200));
|
|
89
|
+
try {
|
|
90
|
+
const retry = yield (0, daemon_client_1.sendDaemonCommand)(watchCmd);
|
|
91
|
+
if (retry.ok && typeof retry.pid === "number") {
|
|
92
|
+
return { ok: true, pid: retry.pid, reused: false };
|
|
73
93
|
}
|
|
74
|
-
catch (_c) { }
|
|
75
94
|
}
|
|
95
|
+
catch (_c) { }
|
|
76
96
|
}
|
|
97
|
+
// Still opening its stores. The daemon only kills per-project watchers at
|
|
98
|
+
// startup, so one spawned now would outlive that sweep and run beside it.
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "daemon-refused",
|
|
102
|
+
message: `Daemon (PID ${daemonPid}) is still starting; the watch will be retried`,
|
|
103
|
+
};
|
|
77
104
|
}
|
|
78
|
-
// 5. Fall back to per-project spawn
|
|
105
|
+
// 5. Fall back to per-project spawn (the daemon could not be started)
|
|
79
106
|
try {
|
|
80
107
|
const child = (0, node_child_process_1.spawn)(process.argv[0], [process.argv[1], "watch", "--path", projectRoot, "-b"], { detached: true, stdio: "ignore" });
|
|
81
108
|
yield new Promise((resolve, reject) => {
|
package/dist/lib/workers/pool.js
CHANGED
|
@@ -182,6 +182,24 @@ const WORKER_RSS_RECYCLE_MB = (() => {
|
|
|
182
182
|
// a transient high-water mark that the next task's GC releases; the second
|
|
183
183
|
// reading confirms the memory is actually sticky.
|
|
184
184
|
const BLOAT_STREAK_TO_RECYCLE = 2;
|
|
185
|
+
// Scale-up policy. A new worker costs a cold start (~10-15s to boot and load
|
|
186
|
+
// models, ~1 GB peak) and is reaped after 60s idle, so spawning one whenever a
|
|
187
|
+
// task finds no idle worker turned every small watcher batch into 3-4 spawns
|
|
188
|
+
// (~300 cold spawns/day measured on a live host). An extra worker is spawned
|
|
189
|
+
// only when one of these holds:
|
|
190
|
+
// - the pool has no workers at all;
|
|
191
|
+
// - a priority task (search) is waiting — search never queues behind indexing;
|
|
192
|
+
// - unassigned tasks exceed SCALE_UP_BACKLOG_PER_WORKER per live worker;
|
|
193
|
+
// - the oldest unassigned task has waited SCALE_UP_WAIT_MS. A timer
|
|
194
|
+
// re-checks this, so a queued task is never starved by a quiet pool.
|
|
195
|
+
const SCALE_UP_BACKLOG_PER_WORKER = 4;
|
|
196
|
+
const SCALE_UP_WAIT_MS = (() => {
|
|
197
|
+
var _a;
|
|
198
|
+
const fromEnv = Number.parseInt((_a = process.env.GMAX_WORKER_SCALE_UP_WAIT_MS) !== null && _a !== void 0 ? _a : "", 10);
|
|
199
|
+
if (Number.isFinite(fromEnv) && fromEnv >= 0)
|
|
200
|
+
return fromEnv;
|
|
201
|
+
return 2000;
|
|
202
|
+
})();
|
|
185
203
|
// Methods that must skip the indexing backlog. encodeQuery is the search hot
|
|
186
204
|
// path: a single query is ~17ms but waits behind every queued processFile.
|
|
187
205
|
// rerank is similarly small and latency-sensitive.
|
|
@@ -204,6 +222,8 @@ class WorkerPool {
|
|
|
204
222
|
this.consecutiveRespawns = 0;
|
|
205
223
|
this.respawnLimitReached = false;
|
|
206
224
|
this.idleReapInterval = null;
|
|
225
|
+
// Re-runs dispatch once the oldest unassigned task reaches SCALE_UP_WAIT_MS.
|
|
226
|
+
this.scaleUpTimer = null;
|
|
207
227
|
const resolved = resolveProcessWorker();
|
|
208
228
|
this.modulePath = resolved.filename;
|
|
209
229
|
this.execArgv = resolved.execArgv;
|
|
@@ -418,6 +438,7 @@ class WorkerPool {
|
|
|
418
438
|
payload,
|
|
419
439
|
resolve: safeResolve,
|
|
420
440
|
reject: safeReject,
|
|
441
|
+
queuedAt: Date.now(),
|
|
421
442
|
signal,
|
|
422
443
|
};
|
|
423
444
|
if (signal) {
|
|
@@ -485,9 +506,12 @@ class WorkerPool {
|
|
|
485
506
|
return t && !t.worker;
|
|
486
507
|
});
|
|
487
508
|
const nextTaskId = (_a = findUnassigned(this.priorityQueue)) !== null && _a !== void 0 ? _a : findUnassigned(this.taskQueue);
|
|
488
|
-
if (nextTaskId === undefined)
|
|
509
|
+
if (nextTaskId === undefined) {
|
|
510
|
+
this.clearScaleUpTimer();
|
|
489
511
|
return;
|
|
490
|
-
|
|
512
|
+
}
|
|
513
|
+
// Lazy spawn: if no idle worker and below max, spawn one when the backlog
|
|
514
|
+
// warrants it (see SCALE_UP_WAIT_MS); otherwise wait for a busy worker.
|
|
491
515
|
if (!idle && this.workers.length < this.maxWorkers) {
|
|
492
516
|
if (this.respawnLimitReached) {
|
|
493
517
|
if (this.workers.length === 0) {
|
|
@@ -495,6 +519,11 @@ class WorkerPool {
|
|
|
495
519
|
}
|
|
496
520
|
return;
|
|
497
521
|
}
|
|
522
|
+
const waitMs = this.scaleUpDelayMs();
|
|
523
|
+
if (waitMs > 0) {
|
|
524
|
+
this.armScaleUpTimer(waitMs);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
498
527
|
this.spawnWorker();
|
|
499
528
|
idle = this.workers[this.workers.length - 1];
|
|
500
529
|
}
|
|
@@ -534,6 +563,46 @@ class WorkerPool {
|
|
|
534
563
|
}
|
|
535
564
|
this.dispatch();
|
|
536
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* How long to hold off spawning another worker for the queued work: 0 means
|
|
568
|
+
* spawn now. Called only when no worker is idle and the pool is below max.
|
|
569
|
+
*/
|
|
570
|
+
scaleUpDelayMs() {
|
|
571
|
+
if (this.workers.length === 0)
|
|
572
|
+
return 0;
|
|
573
|
+
const unassigned = (queue) => queue.flatMap((id) => {
|
|
574
|
+
const t = this.tasks.get(id);
|
|
575
|
+
return t && !t.worker ? [t] : [];
|
|
576
|
+
});
|
|
577
|
+
if (unassigned(this.priorityQueue).length > 0)
|
|
578
|
+
return 0;
|
|
579
|
+
const waiting = unassigned(this.taskQueue);
|
|
580
|
+
if (waiting.length === 0)
|
|
581
|
+
return 0;
|
|
582
|
+
if (waiting.length > this.workers.length * SCALE_UP_BACKLOG_PER_WORKER) {
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
const oldest = Math.min(...waiting.map((t) => t.queuedAt));
|
|
586
|
+
return Math.max(0, oldest + SCALE_UP_WAIT_MS - Date.now());
|
|
587
|
+
}
|
|
588
|
+
armScaleUpTimer(delayMs) {
|
|
589
|
+
var _a, _b;
|
|
590
|
+
// An armed timer belongs to a task at least as old as this one, so it
|
|
591
|
+
// fires no later than this one would. Firing early only re-evaluates.
|
|
592
|
+
if (this.scaleUpTimer)
|
|
593
|
+
return;
|
|
594
|
+
this.scaleUpTimer = setTimeout(() => {
|
|
595
|
+
this.scaleUpTimer = null;
|
|
596
|
+
this.dispatch();
|
|
597
|
+
}, delayMs);
|
|
598
|
+
(_b = (_a = this.scaleUpTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
599
|
+
}
|
|
600
|
+
clearScaleUpTimer() {
|
|
601
|
+
if (this.scaleUpTimer) {
|
|
602
|
+
clearTimeout(this.scaleUpTimer);
|
|
603
|
+
this.scaleUpTimer = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
537
606
|
processFile(input, signal) {
|
|
538
607
|
return this.enqueue("processFile", input, signal);
|
|
539
608
|
}
|
|
@@ -715,6 +784,7 @@ class WorkerPool {
|
|
|
715
784
|
clearInterval(this.idleReapInterval);
|
|
716
785
|
this.idleReapInterval = null;
|
|
717
786
|
}
|
|
787
|
+
this.clearScaleUpTimer();
|
|
718
788
|
for (const task of this.tasks.values()) {
|
|
719
789
|
task.reject(new Error("Worker pool destroyed"));
|
|
720
790
|
this.completeTask(task, null);
|
package/package.json
CHANGED
|
@@ -1,44 +1,26 @@
|
|
|
1
|
-
const fs = require("node:fs");
|
|
2
|
-
const _path = require("node:path");
|
|
3
1
|
const { execFileSync } = require("node:child_process");
|
|
2
|
+
const {
|
|
3
|
+
acquireSessionLease,
|
|
4
|
+
readHookInput,
|
|
5
|
+
registeredRootFor,
|
|
6
|
+
releaseSessionLeases,
|
|
7
|
+
} = require("./watch-lease");
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
resolve(JSON.parse(data));
|
|
13
|
-
} catch {
|
|
14
|
-
resolve({});
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
// If no stdin arrives within 1s, proceed with empty input
|
|
18
|
-
setTimeout(() => resolve({}), 1000);
|
|
19
|
-
});
|
|
20
|
-
}
|
|
9
|
+
// Keep the daemon watching the project this session is in now, and let go of
|
|
10
|
+
// the one it left. See watch-lease.js.
|
|
11
|
+
async function main() {
|
|
12
|
+
const input = await readHookInput();
|
|
13
|
+
const newCwd = input.new_cwd || input.cwd || process.cwd();
|
|
14
|
+
const oldCwd = input.old_cwd;
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"projects.json",
|
|
28
|
-
);
|
|
29
|
-
if (!fs.existsSync(projectsPath)) return false;
|
|
30
|
-
const projects = JSON.parse(fs.readFileSync(projectsPath, "utf-8"));
|
|
31
|
-
return projects.some((p) => dir.startsWith(p.root));
|
|
32
|
-
} catch {
|
|
33
|
-
return false;
|
|
16
|
+
if (oldCwd) {
|
|
17
|
+
const oldRoot = registeredRootFor(oldCwd);
|
|
18
|
+
if (oldRoot && oldRoot !== registeredRootFor(newCwd)) {
|
|
19
|
+
await releaseSessionLeases(input, oldCwd);
|
|
20
|
+
}
|
|
34
21
|
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function main() {
|
|
38
|
-
const input = await readStdin();
|
|
39
|
-
const newCwd = input.new_cwd || process.cwd();
|
|
40
22
|
|
|
41
|
-
if (!
|
|
23
|
+
if (!registeredRootFor(newCwd)) return;
|
|
42
24
|
|
|
43
25
|
try {
|
|
44
26
|
execFileSync("gmax", ["watch", "--daemon", "-b"], {
|
|
@@ -50,6 +32,7 @@ async function main() {
|
|
|
50
32
|
execFileSync("gmax", ["watch", "-b"], { timeout: 5000, stdio: "ignore" });
|
|
51
33
|
} catch {}
|
|
52
34
|
}
|
|
35
|
+
await acquireSessionLease(input, newCwd);
|
|
53
36
|
}
|
|
54
37
|
|
|
55
38
|
main();
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const _path = require("node:path");
|
|
3
3
|
const { execFileSync } = require("node:child_process");
|
|
4
|
+
const {
|
|
5
|
+
acquireSessionLease,
|
|
6
|
+
readHookInput,
|
|
7
|
+
registeredRootFor,
|
|
8
|
+
} = require("./watch-lease");
|
|
4
9
|
|
|
5
10
|
// Inline fallback so SessionStart context is never empty if the installed
|
|
6
11
|
// gmax package can't be resolved (e.g. gmax not yet on PATH). The canonical
|
|
@@ -61,21 +66,8 @@ function getSessionStartHint() {
|
|
|
61
66
|
return FALLBACK_SESSION_START_HINT;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
function isProjectRegistered() {
|
|
65
|
-
|
|
66
|
-
const projectsPath = _path.join(
|
|
67
|
-
require("node:os").homedir(),
|
|
68
|
-
".gmax",
|
|
69
|
-
"projects.json",
|
|
70
|
-
);
|
|
71
|
-
const projects = JSON.parse(
|
|
72
|
-
require("node:fs").readFileSync(projectsPath, "utf-8"),
|
|
73
|
-
);
|
|
74
|
-
const cwd = process.cwd();
|
|
75
|
-
return projects.some((p) => cwd.startsWith(p.root));
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
69
|
+
function isProjectRegistered(dir) {
|
|
70
|
+
return registeredRootFor(dir) !== null;
|
|
79
71
|
}
|
|
80
72
|
|
|
81
73
|
// A kill switch for the auto-start. Create ~/.gmax/autostart-disabled (or export
|
|
@@ -96,9 +88,10 @@ function isAutostartDisabled() {
|
|
|
96
88
|
}
|
|
97
89
|
}
|
|
98
90
|
|
|
99
|
-
function startWatcher() {
|
|
91
|
+
async function startWatcher(input) {
|
|
100
92
|
if (isAutostartDisabled()) return;
|
|
101
|
-
|
|
93
|
+
const dir = input.cwd || process.cwd();
|
|
94
|
+
if (!isProjectRegistered(dir)) return;
|
|
102
95
|
try {
|
|
103
96
|
execFileSync("gmax", ["watch", "--daemon", "-b"], {
|
|
104
97
|
timeout: 5000,
|
|
@@ -112,11 +105,14 @@ function startWatcher() {
|
|
|
112
105
|
// Watcher may already be running or gmax not in PATH — ignore
|
|
113
106
|
}
|
|
114
107
|
}
|
|
108
|
+
// The daemon only watches projects a session holds a lease on.
|
|
109
|
+
await acquireSessionLease(input, dir);
|
|
115
110
|
}
|
|
116
111
|
|
|
117
|
-
function main() {
|
|
112
|
+
async function main() {
|
|
113
|
+
const input = await readHookInput();
|
|
118
114
|
// The daemon owns embed-server configuration and lifecycle.
|
|
119
|
-
startWatcher();
|
|
115
|
+
await startWatcher(input);
|
|
120
116
|
|
|
121
117
|
const response = {
|
|
122
118
|
hookSpecificOutput: {
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Releases this session's watch lease. It never stops the daemon.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// This hook used to run `gmax watch stop` on every Claude SessionEnd. With
|
|
4
4
|
// multiple concurrent Claude sessions sharing one daemon, that meant *any*
|
|
5
5
|
// session ending killed the daemon for every *other* session — silently
|
|
6
6
|
// breaking their search/index and forcing repeated daemon restarts.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// Now it only drops the lease this session took at SessionStart. The daemon
|
|
9
|
+
// unwatches the project once no other session (or MCP server) holds it, and its
|
|
10
|
+
// own idle timeout handles shutdown when nothing is using it.
|
|
11
|
+
const { readHookInput, releaseSessionLeases } = require("./watch-lease");
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const input = await readHookInput();
|
|
15
|
+
await releaseSessionLeases(input, input.cwd || process.cwd());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Session watch leases for the hooks.
|
|
2
|
+
//
|
|
3
|
+
// The daemon only watches projects that some session holds a lease on (see
|
|
4
|
+
// src/lib/daemon/watch-leases.ts). The MCP server holds the long-lived one; the
|
|
5
|
+
// hooks take a session lease so a project is watched from the first moment of a
|
|
6
|
+
// session, and drop it at SessionEnd. A hook cannot import from dist, so this
|
|
7
|
+
// speaks the daemon's one-line JSON protocol directly.
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const net = require("node:net");
|
|
10
|
+
const os = require("node:os");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
|
|
13
|
+
const GMAX_DIR = path.join(os.homedir(), ".gmax");
|
|
14
|
+
const SOCKET = path.join(GMAX_DIR, "daemon.sock");
|
|
15
|
+
|
|
16
|
+
// Long enough to cover a session without an MCP server; SessionEnd releases it
|
|
17
|
+
// early, and the daemon caps any lease at 12h.
|
|
18
|
+
const SESSION_LEASE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
function readHookInput(timeoutMs = 1000) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let data = "";
|
|
23
|
+
const done = () => {
|
|
24
|
+
// An open stdin would otherwise keep the hook alive until its timeout.
|
|
25
|
+
process.stdin.destroy();
|
|
26
|
+
try {
|
|
27
|
+
resolve(data ? JSON.parse(data) : {});
|
|
28
|
+
} catch {
|
|
29
|
+
resolve({});
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(done, timeoutMs);
|
|
33
|
+
process.stdin.setEncoding("utf-8");
|
|
34
|
+
process.stdin.on("data", (chunk) => {
|
|
35
|
+
data += chunk;
|
|
36
|
+
});
|
|
37
|
+
process.stdin.on("end", () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
done();
|
|
40
|
+
});
|
|
41
|
+
process.stdin.on("error", () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
done();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isWithin(root, dir) {
|
|
49
|
+
const rel = path.relative(root, dir);
|
|
50
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The most specific registered project containing `dir`, or null. */
|
|
54
|
+
function registeredRootFor(dir) {
|
|
55
|
+
try {
|
|
56
|
+
const projects = JSON.parse(
|
|
57
|
+
fs.readFileSync(path.join(GMAX_DIR, "projects.json"), "utf-8"),
|
|
58
|
+
);
|
|
59
|
+
const resolved = path.resolve(dir);
|
|
60
|
+
const matches = projects
|
|
61
|
+
.filter((p) => p && typeof p.root === "string")
|
|
62
|
+
.filter((p) => isWithin(path.resolve(p.root), resolved))
|
|
63
|
+
.sort((a, b) => b.root.length - a.root.length);
|
|
64
|
+
return matches.length > 0 ? matches[0].root : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sendOnce(cmd, timeoutMs) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const socket = net.connect(SOCKET);
|
|
73
|
+
let settled = false;
|
|
74
|
+
const finish = (result) => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
socket.destroy();
|
|
79
|
+
resolve(result);
|
|
80
|
+
};
|
|
81
|
+
const timer = setTimeout(
|
|
82
|
+
() => finish({ ok: false, retry: false }),
|
|
83
|
+
timeoutMs,
|
|
84
|
+
);
|
|
85
|
+
let buf = "";
|
|
86
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(cmd)}\n`));
|
|
87
|
+
socket.on("data", (chunk) => {
|
|
88
|
+
buf += chunk;
|
|
89
|
+
const newline = buf.indexOf("\n");
|
|
90
|
+
if (newline === -1) return;
|
|
91
|
+
let resp = {};
|
|
92
|
+
try {
|
|
93
|
+
resp = JSON.parse(buf.slice(0, newline));
|
|
94
|
+
} catch {}
|
|
95
|
+
finish({
|
|
96
|
+
ok: resp.ok === true,
|
|
97
|
+
resp,
|
|
98
|
+
// A daemon the hook just spawned answers before its stores are open.
|
|
99
|
+
retry: resp.error === "daemon initializing",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
socket.on("error", (err) =>
|
|
103
|
+
finish({
|
|
104
|
+
ok: false,
|
|
105
|
+
retry: err.code === "ENOENT" || err.code === "ECONNREFUSED",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send one command, retrying while the daemon is still coming up (the hook may
|
|
113
|
+
* have just spawned it). Never throws — a hook must not fail the session.
|
|
114
|
+
*/
|
|
115
|
+
async function sendDaemonLine(cmd, deadlineMs = 3000) {
|
|
116
|
+
const deadline = Date.now() + deadlineMs;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const remaining = deadline - Date.now();
|
|
119
|
+
if (remaining <= 0) return null;
|
|
120
|
+
const result = await sendOnce(cmd, Math.min(remaining, 2000));
|
|
121
|
+
if (result.ok) return result.resp;
|
|
122
|
+
if (!result.retry) return null;
|
|
123
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sessionHolder(input) {
|
|
128
|
+
const id =
|
|
129
|
+
input && typeof input.session_id === "string" ? input.session_id : "";
|
|
130
|
+
return id ? `session:${id}` : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function acquireSessionLease(input, dir) {
|
|
134
|
+
const holder = sessionHolder(input);
|
|
135
|
+
const root = registeredRootFor(dir);
|
|
136
|
+
if (!holder || !root) return false;
|
|
137
|
+
const resp = await sendDaemonLine({
|
|
138
|
+
cmd: "watch",
|
|
139
|
+
root,
|
|
140
|
+
holder,
|
|
141
|
+
ttlMs: SESSION_LEASE_TTL_MS,
|
|
142
|
+
});
|
|
143
|
+
return resp !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function releaseSessionLeases(input, dir) {
|
|
147
|
+
const holder = sessionHolder(input);
|
|
148
|
+
const root = registeredRootFor(dir);
|
|
149
|
+
if (!holder || !root) return false;
|
|
150
|
+
// Only release against a live daemon that understands leases: an older one
|
|
151
|
+
// reads `unwatch` as "stop watching for everyone".
|
|
152
|
+
const ping = await sendDaemonLine({ cmd: "ping" }, 1000);
|
|
153
|
+
if (!ping?.capabilities?.watchLeases) return false;
|
|
154
|
+
const resp = await sendDaemonLine({ cmd: "unwatch", root, holder }, 1000);
|
|
155
|
+
return resp !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
acquireSessionLease,
|
|
160
|
+
readHookInput,
|
|
161
|
+
registeredRootFor,
|
|
162
|
+
releaseSessionLeases,
|
|
163
|
+
};
|