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.
@@ -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
- const existing = (_a = (0, watcher_store_1.getWatcherForProject)(projectRoot)) !== null && _a !== void 0 ? _a : (0, watcher_store_1.getWatcherCoveringPath)(projectRoot);
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)({ cmd: "watch", root: projectRoot });
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
- if (error === "ENOENT" || error === "ECONNREFUSED") {
71
- const daemonPid = yield (0, daemon_launcher_1.spawnDaemon)();
72
- if (daemonPid) {
73
- for (let i = 0; i < 25; i++) {
74
- yield new Promise((r) => setTimeout(r, 200));
75
- try {
76
- const retry = yield (0, daemon_client_1.sendDaemonCommand)({
77
- cmd: "watch",
78
- root: projectRoot,
79
- });
80
- if (retry.ok && typeof retry.pid === "number") {
81
- return { ok: true, pid: retry.pid, reused: false };
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) => {
@@ -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
- // Lazy spawn: if no idle worker and below max, spawn one
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,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.28",
3
+ "version": "0.26.30",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.28",
3
+ "version": "0.26.30",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",
@@ -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
- function readStdin() {
6
- return new Promise((resolve) => {
7
- let data = "";
8
- process.stdin.setEncoding("utf-8");
9
- process.stdin.on("data", (chunk) => (data += chunk));
10
- process.stdin.on("end", () => {
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
- function isProjectRegistered(dir) {
23
- try {
24
- const projectsPath = _path.join(
25
- require("node:os").homedir(),
26
- ".gmax",
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 (!isProjectRegistered(newCwd)) return;
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
- try {
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
- if (!isProjectRegistered()) return;
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
- // Intentionally a no-op.
1
+ // Releases this session's watch lease. It never stops the daemon.
2
2
  //
3
- // Previously this ran `gmax watch stop` on every Claude SessionEnd. With
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
- // The daemon's own 30-minute idle timeout handles cleanup when nothing is
9
- // using it, so SessionEnd has no work to do here.
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();