grepmax 0.26.28 → 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/dist/commands/mcp.js +17 -1
- 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 +131 -13
- package/dist/lib/daemon/graph-handler.js +12 -6
- package/dist/lib/daemon/ipc-handler.js +18 -3
- package/dist/lib/daemon/read-verbs.js +52 -1
- package/dist/lib/daemon/search-handler.js +7 -4
- 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/process-footprint.js +51 -0
- package/dist/lib/utils/watcher-launcher.js +36 -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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Counting semaphore with abortable waits, plus a bounded-concurrency map.
|
|
4
|
+
*
|
|
5
|
+
* The daemon serves every MCP session over one socket, and some read verbs are
|
|
6
|
+
* full-table scans whose Arrow buffers live in native memory. Without a cap,
|
|
7
|
+
* N sessions asking at once means N scans resident at once — the footprint that
|
|
8
|
+
* froze a 48 GB machine at 9.6 GB. The semaphore bounds how many run; a waiter
|
|
9
|
+
* whose client disconnects leaves the queue instead of running for nobody.
|
|
10
|
+
*/
|
|
11
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
12
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
13
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
14
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
15
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
16
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
17
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.AsyncSemaphore = void 0;
|
|
22
|
+
exports.mapWithConcurrency = mapWithConcurrency;
|
|
23
|
+
function abortError(reason) {
|
|
24
|
+
if (reason instanceof Error)
|
|
25
|
+
return reason;
|
|
26
|
+
const error = new Error("Aborted");
|
|
27
|
+
error.name = "AbortError";
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
class AsyncSemaphore {
|
|
31
|
+
constructor(capacity) {
|
|
32
|
+
this.capacity = capacity;
|
|
33
|
+
this.queue = [];
|
|
34
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
35
|
+
throw new RangeError(`semaphore capacity must be >= 1, got ${capacity}`);
|
|
36
|
+
}
|
|
37
|
+
this.available = capacity;
|
|
38
|
+
}
|
|
39
|
+
/** Slots currently held. */
|
|
40
|
+
get active() {
|
|
41
|
+
return this.capacity - this.available;
|
|
42
|
+
}
|
|
43
|
+
/** Callers waiting for a slot. */
|
|
44
|
+
get waiting() {
|
|
45
|
+
return this.queue.length;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve with a release function once a slot is free. Rejects with an
|
|
49
|
+
* AbortError if `signal` fires first; the waiter is removed from the queue,
|
|
50
|
+
* so an abandoned request never takes a slot.
|
|
51
|
+
*/
|
|
52
|
+
acquire(signal) {
|
|
53
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted)
|
|
54
|
+
return Promise.reject(abortError(signal.reason));
|
|
55
|
+
if (this.available > 0) {
|
|
56
|
+
this.available--;
|
|
57
|
+
return Promise.resolve(this.releaser());
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const waiter = {
|
|
61
|
+
resolve: () => resolve(this.releaser()),
|
|
62
|
+
reject,
|
|
63
|
+
signal,
|
|
64
|
+
};
|
|
65
|
+
if (signal) {
|
|
66
|
+
waiter.onAbort = () => {
|
|
67
|
+
const index = this.queue.indexOf(waiter);
|
|
68
|
+
if (index !== -1)
|
|
69
|
+
this.queue.splice(index, 1);
|
|
70
|
+
reject(abortError(signal.reason));
|
|
71
|
+
};
|
|
72
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
73
|
+
}
|
|
74
|
+
this.queue.push(waiter);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Run `fn` while holding a slot; the slot is released however `fn` ends. */
|
|
78
|
+
run(signal, fn) {
|
|
79
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
80
|
+
const release = yield this.acquire(signal);
|
|
81
|
+
try {
|
|
82
|
+
return yield fn();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
release();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
releaser() {
|
|
90
|
+
let released = false;
|
|
91
|
+
return () => {
|
|
92
|
+
var _a;
|
|
93
|
+
if (released)
|
|
94
|
+
return;
|
|
95
|
+
released = true;
|
|
96
|
+
const next = this.queue.shift();
|
|
97
|
+
if (next) {
|
|
98
|
+
// Hand the slot straight to the next waiter; `available` is unchanged.
|
|
99
|
+
if (next.onAbort) {
|
|
100
|
+
(_a = next.signal) === null || _a === void 0 ? void 0 : _a.removeEventListener("abort", next.onAbort);
|
|
101
|
+
}
|
|
102
|
+
next.resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.available++;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.AsyncSemaphore = AsyncSemaphore;
|
|
110
|
+
/**
|
|
111
|
+
* `Promise.all(items.map(fn))` with at most `limit` calls in flight, results in
|
|
112
|
+
* input order. Once `signal` aborts no new item starts, and the call rejects
|
|
113
|
+
* with the abort after the in-flight ones settle.
|
|
114
|
+
*/
|
|
115
|
+
function mapWithConcurrency(items, limit, fn, signal) {
|
|
116
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
const results = new Array(items.length);
|
|
118
|
+
let next = 0;
|
|
119
|
+
let failure = null;
|
|
120
|
+
const worker = () => __awaiter(this, void 0, void 0, function* () {
|
|
121
|
+
while (!failure && next < items.length) {
|
|
122
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
|
|
123
|
+
failure = { error: abortError(signal.reason) };
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const index = next++;
|
|
127
|
+
try {
|
|
128
|
+
results[index] = yield fn(items[index], index);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
failure !== null && failure !== void 0 ? failure : (failure = { error });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const width = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
|
|
136
|
+
yield Promise.all(Array.from({ length: width }, worker));
|
|
137
|
+
if (failure)
|
|
138
|
+
throw failure.error;
|
|
139
|
+
return results;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readFootprintMb = readFootprintMb;
|
|
4
|
+
exports.parseFootprintMb = parseFootprintMb;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
/**
|
|
7
|
+
* Physical footprint of a process, in MB — the number Activity Monitor and jetsam
|
|
8
|
+
* use, and the one the daemon's recycle watermark has to read.
|
|
9
|
+
*
|
|
10
|
+
* `process.memoryUsage().rss` is not that number on macOS. RSS only counts pages
|
|
11
|
+
* currently resident, so once the compressor or swap takes a page it drops out:
|
|
12
|
+
* the daemon read 469 MB of RSS while `footprint` reported 4 GB (9.6 GB peak), and
|
|
13
|
+
* the RSS watermark never fired. `footprint -p` counts compressed and swapped dirty
|
|
14
|
+
* memory too, and costs ~50 ms.
|
|
15
|
+
*
|
|
16
|
+
* Off macOS, or when the sample cannot be parsed, falls back to RSS — an
|
|
17
|
+
* underestimate, which only ever delays a recycle, never causes a spurious one.
|
|
18
|
+
*/
|
|
19
|
+
function readFootprintMb(pid = process.pid) {
|
|
20
|
+
if (process.platform === "darwin") {
|
|
21
|
+
try {
|
|
22
|
+
const output = (0, node_child_process_1.execFileSync)("footprint", ["-p", String(pid)], {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
timeout: 5000,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
});
|
|
27
|
+
const parsed = parseFootprintMb(output);
|
|
28
|
+
if (parsed != null)
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch (_a) { }
|
|
32
|
+
}
|
|
33
|
+
return pid === process.pid ? process.memoryUsage().rss / (1024 * 1024) : 0;
|
|
34
|
+
}
|
|
35
|
+
const UNIT_TO_MB = {
|
|
36
|
+
B: 1 / (1024 * 1024),
|
|
37
|
+
KB: 1 / 1024,
|
|
38
|
+
MB: 1,
|
|
39
|
+
GB: 1024,
|
|
40
|
+
};
|
|
41
|
+
/** Parse the `Footprint: 4232 MB` header line of `footprint -p` output. */
|
|
42
|
+
function parseFootprintMb(output) {
|
|
43
|
+
const match = /Footprint:\s*([\d.]+)\s*(B|KB|MB|GB)\b/.exec(output);
|
|
44
|
+
if (!match)
|
|
45
|
+
return null;
|
|
46
|
+
const value = Number(match[1]);
|
|
47
|
+
const scale = UNIT_TO_MB[match[2]];
|
|
48
|
+
if (!Number.isFinite(value) || scale == null)
|
|
49
|
+
return null;
|
|
50
|
+
return value * scale;
|
|
51
|
+
}
|
|
@@ -22,7 +22,7 @@ const daemon_launcher_1 = require("./daemon-launcher");
|
|
|
22
22
|
const project_registry_1 = require("./project-registry");
|
|
23
23
|
const store_access_1 = require("./store-access");
|
|
24
24
|
const watcher_store_1 = require("./watcher-store");
|
|
25
|
-
function launchWatcher(projectRoot) {
|
|
25
|
+
function launchWatcher(projectRoot, lease) {
|
|
26
26
|
return __awaiter(this, void 0, void 0, function* () {
|
|
27
27
|
var _a;
|
|
28
28
|
// 1. Project must be registered
|
|
@@ -34,15 +34,19 @@ function launchWatcher(projectRoot) {
|
|
|
34
34
|
message: `Project not registered. Run: gmax add ${projectRoot}`,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
-
// 2. Check if watcher already running (daemon registers per-project entries)
|
|
38
|
-
|
|
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));
|
|
39
42
|
if (existing && (0, watcher_store_1.isProcessRunning)(existing.pid)) {
|
|
40
43
|
return { ok: true, pid: existing.pid, reused: true };
|
|
41
44
|
}
|
|
45
|
+
const watchCmd = Object.assign({ cmd: "watch", root: projectRoot }, lease);
|
|
42
46
|
// 3. Try daemon IPC
|
|
43
47
|
let resp;
|
|
44
48
|
try {
|
|
45
|
-
resp = yield (0, daemon_client_1.sendDaemonCommand)(
|
|
49
|
+
resp = yield (0, daemon_client_1.sendDaemonCommand)(watchCmd);
|
|
46
50
|
}
|
|
47
51
|
catch (_b) {
|
|
48
52
|
resp = { ok: false, error: "request-failed" };
|
|
@@ -67,25 +71,38 @@ function launchWatcher(projectRoot) {
|
|
|
67
71
|
return { ok: false, reason: "sandboxed", message: sandboxed };
|
|
68
72
|
}
|
|
69
73
|
const error = resp.error;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 };
|
|
83
93
|
}
|
|
84
|
-
catch (_c) { }
|
|
85
94
|
}
|
|
95
|
+
catch (_c) { }
|
|
86
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
|
+
};
|
|
87
104
|
}
|
|
88
|
-
// 5. Fall back to per-project spawn
|
|
105
|
+
// 5. Fall back to per-project spawn (the daemon could not be started)
|
|
89
106
|
try {
|
|
90
107
|
const child = (0, node_child_process_1.spawn)(process.argv[0], [process.argv[1], "watch", "--path", projectRoot, "-b"], { detached: true, stdio: "ignore" });
|
|
91
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();
|