greprag 5.27.0 → 5.28.0

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,287 @@
1
+ "use strict";
2
+ /** Local watcher process registry + severed-ancestry orphan reaper.
3
+ *
4
+ * The architectural correction the whole monitor-resilience saga circled but
5
+ * never landed (see adr/monitor-resilience.md 2026-05-28 "severed-ancestry =
6
+ * the disarm signal" + the 2026-06-04 entry): a watcher's liveness lives at the
7
+ * LOCAL process layer, not in a cloud registry. The consumer of a watcher is
8
+ * the Claude Code Monitor task on THIS machine; the server can't see whether
9
+ * anyone is still consuming the stream, so a server lease counts an orphan
10
+ * (consumer dead, process still respawning) as "armed". Two local signals fix
11
+ * that:
12
+ *
13
+ * 1. **pidfile** (`~/.greprag/watchers/<short>.json`) — written by the
14
+ * supervisor on start, removed on terminal exit. `isLocallyArmed` reads it
15
+ * to gate re-arming with zero cloud dependency and zero ghost-lease lag.
16
+ * Paired with EPIPE-terminal in the watcher (a watcher whose consumer pipe
17
+ * breaks exits and removes its own pidfile), a live pidfile means a live,
18
+ * *consumed* watcher.
19
+ *
20
+ * 2. **severed-ancestry reap** — `reapOrphanWatchers` snapshots the process
21
+ * table and kills any `inbox watch` process whose ancestry no longer
22
+ * reaches a live `claude.exe`. Backstop for the case EPIPE can't catch (a
23
+ * consumer hard-killed without closing the pipe). SAFE BY CONSTRUCTION: the
24
+ * kill set is intersected with "cmdline contains `inbox watch`", so it can
25
+ * only ever terminate greprag watchers — never claude.exe, an editor, or an
26
+ * unrelated node server. A bug can at worst kill a watcher that then
27
+ * re-arms; it can never touch the operator's work.
28
+ *
29
+ * adr: adr/monitor-resilience.md */
30
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ var desc = Object.getOwnPropertyDescriptor(m, k);
33
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
34
+ desc = { enumerable: true, get: function() { return m[k]; } };
35
+ }
36
+ Object.defineProperty(o, k2, desc);
37
+ }) : (function(o, m, k, k2) {
38
+ if (k2 === undefined) k2 = k;
39
+ o[k2] = m[k];
40
+ }));
41
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
42
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
43
+ }) : function(o, v) {
44
+ o["default"] = v;
45
+ });
46
+ var __importStar = (this && this.__importStar) || (function () {
47
+ var ownKeys = function(o) {
48
+ ownKeys = Object.getOwnPropertyNames || function (o) {
49
+ var ar = [];
50
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
51
+ return ar;
52
+ };
53
+ return ownKeys(o);
54
+ };
55
+ return function (mod) {
56
+ if (mod && mod.__esModule) return mod;
57
+ var result = {};
58
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
59
+ __setModuleDefault(result, mod);
60
+ return result;
61
+ };
62
+ })();
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.writeWatcherPidfile = writeWatcherPidfile;
65
+ exports.removeWatcherPidfile = removeWatcherPidfile;
66
+ exports.isLocallyArmed = isLocallyArmed;
67
+ exports.reapOrphanWatchers = reapOrphanWatchers;
68
+ const fs = __importStar(require("fs"));
69
+ const path = __importStar(require("path"));
70
+ const child_process_1 = require("child_process");
71
+ const WATCHER_DIRNAME = 'watchers';
72
+ // Matches a greprag watcher's command line in every launch shape: the npm shim
73
+ // and the old bash while-loop wrapper (`greprag inbox watch …`), and the
74
+ // supervisor's CreateProcess re-invocation (`node …\dist\index.js inbox watch
75
+ // …`). Anchored on the INVOKED BINARY (`greprag` or `index.js`) immediately
76
+ // followed by the `inbox watch` args — NOT a bare `inbox watch` substring, which
77
+ // would also match an unrelated shell that merely mentions the phrase (a grep, a
78
+ // diagnostic, this very reaper). This is the SAFETY GUARD: only a process whose
79
+ // command line is an actual watcher invocation is ever a kill candidate, so the
80
+ // reaper cannot touch claude.exe, an editor, or an incidental shell.
81
+ const WATCHER_CMD_RE = /(?:index\.js|greprag)["'\s]+inbox\s+watch\b/i;
82
+ const CLAUDE_PROC_RE = /^claude(\.exe)?$/i;
83
+ function grepragHome() {
84
+ const home = process.env.HOME || process.env.USERPROFILE || '';
85
+ return home ? path.join(home, '.greprag') : null;
86
+ }
87
+ function watchersDir() {
88
+ const h = grepragHome();
89
+ return h ? path.join(h, WATCHER_DIRNAME) : null;
90
+ }
91
+ /** Write the watcher pidfile (supervisor PID), keyed by the 8-hex session short.
92
+ * Best-effort: a failed write only means `isLocallyArmed` falls back to
93
+ * re-arming, never a crash. */
94
+ function writeWatcherPidfile(short, pid) {
95
+ try {
96
+ const dir = watchersDir();
97
+ if (!dir)
98
+ return;
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const rec = { short, pid, startedAt: Date.now() };
101
+ fs.writeFileSync(path.join(dir, `${short}.json`), JSON.stringify(rec));
102
+ }
103
+ catch { /* best-effort */ }
104
+ }
105
+ function removeWatcherPidfile(short) {
106
+ try {
107
+ const dir = watchersDir();
108
+ if (!dir)
109
+ return;
110
+ fs.rmSync(path.join(dir, `${short}.json`), { force: true });
111
+ }
112
+ catch { /* best-effort */ }
113
+ }
114
+ function readWatcherPidfile(short) {
115
+ try {
116
+ const dir = watchersDir();
117
+ if (!dir)
118
+ return null;
119
+ const raw = fs.readFileSync(path.join(dir, `${short}.json`), 'utf-8');
120
+ const rec = JSON.parse(raw);
121
+ if (rec && typeof rec.pid === 'number' && rec.short === short)
122
+ return rec;
123
+ return null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /** True iff `pid` is a live process. `process.kill(pid, 0)` sends no signal —
130
+ * it only probes existence. EPERM means the process exists but is owned by
131
+ * another user (still alive); ESRCH means gone. */
132
+ function pidAlive(pid) {
133
+ if (!Number.isFinite(pid) || pid <= 0)
134
+ return false;
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ }
139
+ catch (e) {
140
+ return e?.code === 'EPERM';
141
+ }
142
+ }
143
+ /** Local-first arm check: is there a live watcher PROCESS for this session on
144
+ * THIS machine? This is ground truth for "armed" — the consumer (the Monitor
145
+ * task) is local, so a live local pidfile means a live, consumed watcher.
146
+ * Replaces the server `isSessionArmed` check, which counts orphans (consumer
147
+ * dead, socket still open) as armed and so both (a) suppresses re-arm when the
148
+ * real watcher is gone and (b) lets re-arm stack new watchers on undead
149
+ * orphans. A dead-PID pidfile is swept here so the next turn re-arms. */
150
+ function isLocallyArmed(short) {
151
+ const rec = readWatcherPidfile(short);
152
+ if (!rec)
153
+ return false;
154
+ if (!pidAlive(rec.pid)) {
155
+ removeWatcherPidfile(short);
156
+ return false;
157
+ }
158
+ return true;
159
+ }
160
+ /** Snapshot (pid, ppid, name, cmdline) for every process. Windows via one CIM
161
+ * call (a single PowerShell invocation — avoids the per-PID `wmic` calls that
162
+ * hung historically, see ADR 2026-05-26); POSIX via `ps`. Best-effort: any
163
+ * failure returns [] and the reaper becomes a no-op. */
164
+ function snapshotProcs() {
165
+ try {
166
+ if (process.platform === 'win32') {
167
+ const script = 'Get-CimInstance Win32_Process | ' +
168
+ 'Select-Object ProcessId,ParentProcessId,Name,CommandLine | ' +
169
+ 'ConvertTo-Json -Compress';
170
+ const out = (0, child_process_1.execFileSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 12_000, maxBuffer: 64 * 1024 * 1024, windowsHide: true }).toString();
171
+ const parsed = JSON.parse(out);
172
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
173
+ return arr
174
+ .map((p) => ({
175
+ pid: Number(p.ProcessId),
176
+ ppid: Number(p.ParentProcessId),
177
+ name: String(p.Name || ''),
178
+ cmd: String(p.CommandLine || ''),
179
+ }))
180
+ .filter(p => Number.isFinite(p.pid) && p.pid > 0);
181
+ }
182
+ // POSIX fallback.
183
+ const out = (0, child_process_1.execFileSync)('ps', ['-eo', 'pid=,ppid=,comm=,args='], {
184
+ timeout: 12_000, maxBuffer: 64 * 1024 * 1024,
185
+ }).toString();
186
+ const rows = [];
187
+ for (const line of out.split('\n')) {
188
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
189
+ if (!m)
190
+ continue;
191
+ rows.push({ pid: Number(m[1]), ppid: Number(m[2]), name: m[3], cmd: m[4] });
192
+ }
193
+ return rows;
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ }
199
+ /** Walk `start`'s parent chain in the snapshot; true iff a live `claude.exe`
200
+ * appears as an ancestor. A healthy watcher is launched (via the Monitor task)
201
+ * under the live claude.exe, so its chain reaches it; an orphan's owning
202
+ * claude.exe died on reload, so its chain is severed (a parent PID absent from
203
+ * the live snapshot) before any claude.exe. Depth- and cycle-guarded. */
204
+ function hasLiveClaudeAncestor(start, byPid) {
205
+ let cur = start;
206
+ const seen = new Set();
207
+ for (let depth = 0; cur && depth < 32; depth++) {
208
+ if (seen.has(cur.pid))
209
+ break; // cycle guard
210
+ seen.add(cur.pid);
211
+ if (CLAUDE_PROC_RE.test(cur.name))
212
+ return true; // start/ancestor IS claude
213
+ const parent = byPid.get(cur.ppid);
214
+ if (!parent)
215
+ return false; // severed — parent not in the live table
216
+ cur = parent;
217
+ }
218
+ return false;
219
+ }
220
+ /** Kill every `inbox watch` process whose ancestry no longer reaches a live
221
+ * claude.exe (severed = orphan), plus sweep dead-PID pidfiles. Returns a
222
+ * summary. Best-effort and bounded: a snapshot failure yields a zero result;
223
+ * the kill set is guaranteed (by `WATCHER_CMD_RE`) to contain only greprag
224
+ * watchers. Intended to run at SessionStart, before this session arms — at that
225
+ * moment every live watcher belongs to a PRIOR incarnation, so reaping is the
226
+ * common path, not the exception. */
227
+ function reapOrphanWatchers() {
228
+ sweepDeadPidfiles();
229
+ const procs = snapshotProcs();
230
+ if (procs.length === 0)
231
+ return { scanned: 0, orphans: 0, killed: [] };
232
+ const byPid = new Map();
233
+ for (const p of procs)
234
+ byPid.set(p.pid, p);
235
+ // Candidate set — the SAFETY GUARD. Only these are ever eligible to be killed.
236
+ const watchers = procs.filter(p => WATCHER_CMD_RE.test(p.cmd));
237
+ const severed = watchers.filter(p => !hasLiveClaudeAncestor(p, byPid));
238
+ const killed = [];
239
+ for (const p of severed) {
240
+ if (killProcessTree(p.pid))
241
+ killed.push(p.pid);
242
+ }
243
+ return { scanned: watchers.length, orphans: severed.length, killed };
244
+ }
245
+ /** Force-kill a process and its descendants. `taskkill /T` reaps the tree in one
246
+ * shot so a supervisor can't respawn its child in the race window. */
247
+ function killProcessTree(pid) {
248
+ try {
249
+ if (process.platform === 'win32') {
250
+ (0, child_process_1.execFileSync)('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { timeout: 5_000, stdio: 'ignore', windowsHide: true });
251
+ }
252
+ else {
253
+ try {
254
+ process.kill(pid, 'SIGKILL');
255
+ }
256
+ catch { /* already gone */ }
257
+ }
258
+ return true;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
264
+ /** Remove pidfiles whose recorded PID is dead. Keeps `isLocallyArmed` honest
265
+ * even when a watcher died without removing its own file (hard kill / crash). */
266
+ function sweepDeadPidfiles() {
267
+ try {
268
+ const dir = watchersDir();
269
+ if (!dir || !fs.existsSync(dir))
270
+ return;
271
+ for (const f of fs.readdirSync(dir)) {
272
+ if (!f.endsWith('.json'))
273
+ continue;
274
+ try {
275
+ const rec = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
276
+ if (!rec || typeof rec.pid !== 'number' || !pidAlive(rec.pid)) {
277
+ fs.rmSync(path.join(dir, f), { force: true });
278
+ }
279
+ }
280
+ catch {
281
+ fs.rmSync(path.join(dir, f), { force: true }); // unparseable → drop
282
+ }
283
+ }
284
+ }
285
+ catch { /* best-effort */ }
286
+ }
287
+ //# sourceMappingURL=watcher-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watcher-registry.js","sourceRoot":"","sources":["../../src/commands/watcher-registry.ts"],"names":[],"mappings":"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"}
package/dist/hook.js CHANGED
@@ -53,6 +53,7 @@ const turn_provenance_1 = require("./turn-provenance");
53
53
  const codex_steering_1 = require("./codex-steering");
54
54
  const front_desk_mail_1 = require("./front-desk-mail");
55
55
  const email_pull_1 = require("./email-pull");
56
+ const watcher_registry_1 = require("./commands/watcher-registry");
56
57
  const API_URL_DEFAULT = 'https://api.greprag.com';
57
58
  const MAX_FIELD_CHARS = 500_000; // safety cap per text field
58
59
  // ---------- Env + config ---------------------------------------------------
@@ -996,6 +997,20 @@ function writeRecapOutput(text, mode) {
996
997
  * Storage and display are both UTC. The agent can compute local time itself
997
998
  * if needed — a server-side UTC display avoids straddle-day confusion. */
998
999
  async function recap(input, mode = 'plain') {
1000
+ // SessionStart orphan reap (best-effort, side-effect only — never writes to
1001
+ // stdout, which carries this hook's additionalContext JSON). At session start
1002
+ // every live watcher belongs to a PRIOR incarnation; this kills any whose
1003
+ // consumer is gone (severed ancestry) and sweeps stale pidfiles, so the orphan
1004
+ // pile that OOM-crashed the desktop cannot accumulate across reloads. Bounded
1005
+ // (~one process snapshot) and guaranteed to touch only `inbox watch`
1006
+ // processes. adr: adr/monitor-resilience.md
1007
+ try {
1008
+ const reaped = (0, watcher_registry_1.reapOrphanWatchers)();
1009
+ if (reaped.killed.length > 0) {
1010
+ process.stderr.write(`[greprag] reaped ${reaped.killed.length} orphan watcher(s) [${reaped.killed.join(', ')}]\n`);
1011
+ }
1012
+ }
1013
+ catch { /* reaping is best-effort — never block session start */ }
999
1014
  const cwd = input.cwd || process.cwd();
1000
1015
  const cfg = getConfig(cwd);
1001
1016
  if (!cfg.enabled || !cfg.apiKey)
@@ -1124,27 +1139,17 @@ async function recap(input, mode = 'plain') {
1124
1139
  }
1125
1140
  writeRecapOutput(parts.join('\n') + '\n', mode);
1126
1141
  }
1127
- /** Self-scoped arm-state check: does THIS session have a live inbox watcher?
1128
- * Asks the server's watcher registry (GET /v1/inbox/watchers the same live-
1129
- * WebSocket state the Discord emergency router uses; a dead watcher drops its
1130
- * socket and falls off the list, so this is ground truth, not a stale PID),
1131
- * filtered to the caller's own 8-hex session id. Returns true/false when the
1132
- * server answers, or null when the check itself failed (network/HTTP error) so
1133
- * callers can tell "confirmed unarmed" apart from "couldn't tell". */
1134
- async function isSessionArmed(apiUrl, apiKey, short) {
1135
- try {
1136
- const res = await fetch(`${apiUrl.replace(/\/+$/, '')}/v1/inbox/watchers`, {
1137
- headers: { 'Authorization': `Bearer ${apiKey}` },
1138
- });
1139
- if (!res.ok)
1140
- return null;
1141
- const data = await res.json();
1142
- return (data.watchers ?? []).some(w => (0, session_id_1.truncateSessionId)(w.session_id) === short);
1143
- }
1144
- catch {
1145
- return null;
1146
- }
1147
- }
1142
+ /** Arm-state detection moved LOCAL (2026-06-04). The former `isSessionArmed`
1143
+ * asked the server's watcher registry "does this session have a live socket?"
1144
+ * and its docstring claimed that was "ground truth, not a stale PID". It was
1145
+ * the opposite: an ORPHAN (consumer dead, SSE socket still open and respawning)
1146
+ * keeps its socket, so it never falls off the registry it reads as armed.
1147
+ * That single false premise drove the whole failure: a fresh ghost lease
1148
+ * suppressed re-arm (the live session went unwatched "it keeps dying"), and
1149
+ * an expired one let re-arm stack a new watcher on the undead orphan (the
1150
+ * pile-up that OOM-crashed the desktop). Liveness is a LOCAL-process fact now —
1151
+ * see `isLocallyArmed` in commands/watcher-registry.ts, gated on a supervisor
1152
+ * pidfile this machine actually owns. adr: adr/monitor-resilience.md */
1148
1153
  /** UserPromptSubmit — fires on every user prompt. History:
1149
1154
  *
1150
1155
  * v1: surfaced a project-wide unread inbox COUNT. Removed in v5.6.1 — the count
@@ -1152,8 +1157,10 @@ async function isSessionArmed(apiUrl, apiKey, short) {
1152
1157
  * v2: turn-2 fallback arm injection — blind (fired on turn 2 regardless, with a
1153
1158
  * conditional "if already armed, ignore" hedge because it couldn't see state).
1154
1159
  *
1155
- * v3 (current): DETECTION-GATED arm injection. Every turn, ask the server
1156
- * whether THIS session has a live watcher (isSessionArmed):
1160
+ * v3: DETECTION-GATED arm injection, server-registry version (retired 2026-06-04
1161
+ * the registry counted orphans as armed; see isSessionArmed's gravestone).
1162
+ * v4 (current): DETECTION-GATED on LOCAL state. Every turn, check whether THIS
1163
+ * session has a live watcher PROCESS on this machine (isLocallyArmed, pidfile):
1157
1164
  * armed → silent (never nag once armed)
1158
1165
  * not armed → inject the LOUD arm directive (buildArmDirective). Unconditional:
1159
1166
  * we only reach here when the server CONFIRMS no watcher, so there
@@ -1199,9 +1206,17 @@ async function notify(input, source = 'claude-code') {
1199
1206
  writeAdditionalContext('UserPromptSubmit', context);
1200
1207
  return;
1201
1208
  }
1202
- const armed = await isSessionArmed(cfg.apiUrl, cfg.apiKey, short);
1203
- if (armed !== false)
1204
- return; // armed OR couldn't tell silent
1209
+ // Local-first arm detection. Ground truth is a live watcher PROCESS on THIS
1210
+ // machine — its consumer (the Monitor task) is local, so a live supervisor
1211
+ // pidfile means a live, consumed watcher. This replaces the server
1212
+ // `isSessionArmed` registry check, whose ghost-lease flaw made an orphan
1213
+ // (consumer dead, socket still open) read as "armed" — which both suppressed
1214
+ // re-arm when the real watcher was gone AND, once the lease expired, let
1215
+ // re-arm stack a new watcher on the undead orphan. With EPIPE-terminal + the
1216
+ // SessionStart reaper sweeping stale pidfiles, "no live pidfile" reliably means
1217
+ // "needs arming", lag-free and with no cloud round-trip. adr: adr/monitor-resilience.md
1218
+ if ((0, watcher_registry_1.isLocallyArmed)(short))
1219
+ return; // live local watcher → silent
1205
1220
  writeAdditionalContext('UserPromptSubmit', (0, session_id_1.buildArmDirective)(short, (0, session_id_1.readIdentityAlias)()));
1206
1221
  }
1207
1222
  /** "You've got mail" turn hook (Chip B) — wire as a UserPromptSubmit hook.
@@ -1259,9 +1274,9 @@ async function maybeAutoSaveAttachments(cwd, apiUrl, apiKey) {
1259
1274
  return (0, email_pull_1.buildAutoSaveSummary)(saved, dir);
1260
1275
  }
1261
1276
  /** UserPromptSubmit PROBE / manual diagnostic — prints a per-turn readout of
1262
- * whether THIS session has a live watcher (the same isSessionArmed signal
1263
- * `notify` gates on). Kept as a separate subcommand for visibility/debugging:
1264
- * wire it as a second UserPromptSubmit hook to see arm state on every turn.
1277
+ * whether THIS session has a live watcher (the same isLocallyArmed pidfile
1278
+ * signal `notify` gates on). Kept as a separate subcommand for visibility/
1279
+ * debugging: wire it as a second UserPromptSubmit hook to see arm state.
1265
1280
  * adr: adr/session-id-awareness.md */
1266
1281
  async function armCheck(input) {
1267
1282
  const cfg = getConfig(input.cwd || process.cwd());
@@ -1270,10 +1285,10 @@ async function armCheck(input) {
1270
1285
  const short = (0, session_id_1.truncateSessionId)(input.session_id);
1271
1286
  if (!short)
1272
1287
  return;
1273
- const armed = await isSessionArmed(cfg.apiUrl, cfg.apiKey, short);
1274
- const verdict = armed === null
1275
- ? `ARM-CHECK (probe): could not determine arm state (watcher registry unreachable).`
1276
- : `ARM-CHECK (probe): session ${short} ${armed ? 'IS armed — live watcher detected.' : 'is NOT armed — no live watcher detected.'}`;
1288
+ // Local ground truth — the same signal `notify` gates on (live supervisor
1289
+ // pidfile on this machine), not the ghost-prone cloud registry.
1290
+ const armed = (0, watcher_registry_1.isLocallyArmed)(short);
1291
+ const verdict = `ARM-CHECK (probe): session ${short} ${armed ? 'IS armed — live local watcher detected.' : 'is NOT armed — no live local watcher detected.'}`;
1277
1292
  process.stdout.write(JSON.stringify({
1278
1293
  hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: verdict },
1279
1294
  }) + '\n');