@indigoai-us/hq-cli 5.345.54 → 5.345.56

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +143 -2
  6. package/dist/command-catalog.generated.js +175 -2
  7. package/dist/command-registration-plan.d.ts +6 -1
  8. package/dist/command-registration-plan.js +2 -1
  9. package/dist/commands/cloud.d.ts +7 -0
  10. package/dist/commands/cloud.js +92 -16
  11. package/dist/commands/core-delegate-w1b.js +18 -5
  12. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  13. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  14. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  15. package/dist/commands/daemon.js +16 -0
  16. package/dist/commands/doctor.d.ts +1 -0
  17. package/dist/commands/doctor.js +15 -2
  18. package/dist/commands/import-scan.d.ts +24 -0
  19. package/dist/commands/import-scan.js +75 -0
  20. package/dist/commands/lanes-checks.d.ts +3 -0
  21. package/dist/commands/lanes-checks.js +34 -0
  22. package/dist/commands/lanes-session-start.d.ts +17 -0
  23. package/dist/commands/lanes-session-start.js +195 -0
  24. package/dist/commands/lanes-shared.d.ts +2 -1
  25. package/dist/commands/lanes-watch.d.ts +2 -0
  26. package/dist/commands/lanes-watch.js +2 -1
  27. package/dist/commands/lanes.d.ts +3 -1
  28. package/dist/commands/lanes.js +121 -18
  29. package/dist/commands/members.d.ts +4 -0
  30. package/dist/commands/members.js +23 -2
  31. package/dist/lanes-drain-hook.js +6 -2
  32. package/dist/lib/daemon/conflict-notices.d.ts +4 -0
  33. package/dist/lib/daemon/conflict-notices.js +80 -0
  34. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  35. package/dist/lib/doctor/checks/sync-health.js +47 -23
  36. package/dist/lib/import-scan/events.d.ts +135 -0
  37. package/dist/lib/import-scan/events.js +71 -0
  38. package/dist/lib/import-scan/run.d.ts +84 -0
  39. package/dist/lib/import-scan/run.js +308 -0
  40. package/dist/lib/import-scan/scan-event.schema.json +127 -0
  41. package/dist/lib/import-scan/validate.d.ts +31 -0
  42. package/dist/lib/import-scan/validate.js +111 -0
  43. package/dist/lib/lanes/dropbox.d.ts +4 -2
  44. package/dist/lib/lanes/dropbox.js +54 -9
  45. package/dist/lib/lanes/flag-keys.d.ts +10 -0
  46. package/dist/lib/lanes/flag-keys.js +10 -0
  47. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  48. package/dist/lib/lanes/hooks-install.js +70 -50
  49. package/dist/lib/lanes/monitor.js +1 -1
  50. package/dist/lib/lanes/reaper.d.ts +58 -0
  51. package/dist/lib/lanes/reaper.js +403 -0
  52. package/dist/lib/lanes/registry-flag.d.ts +2 -0
  53. package/dist/lib/lanes/registry-flag.js +1 -1
  54. package/dist/lib/lanes/spawn.d.ts +2 -0
  55. package/dist/lib/lanes/spawn.js +32 -2
  56. package/dist/lib/lanes/status-fields.d.ts +12 -0
  57. package/dist/lib/lanes/status-fields.js +189 -0
  58. package/dist/lib/lanes/types.js +1 -1
  59. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  60. package/dist/lib/lanes/worker-continuity.js +81 -0
  61. package/dist/lib/lanes/worker-process.d.ts +4 -0
  62. package/dist/lib/lanes/worker-process.js +77 -0
  63. package/dist/main.js +9 -3
  64. package/dist/utils/cli-telemetry.d.ts +4 -1
  65. package/dist/utils/cli-telemetry.js +8 -0
  66. package/dist/utils/jq-windows-compat.d.ts +13 -0
  67. package/dist/utils/jq-windows-compat.js +48 -0
  68. package/dist/utils/run-bundled-script.d.ts +3 -0
  69. package/dist/utils/run-bundled-script.js +9 -80
  70. package/dist/utils/windows-bash.d.ts +5 -0
  71. package/dist/utils/windows-bash.js +75 -0
  72. package/package.json +1 -1
@@ -0,0 +1,403 @@
1
+ /** Session-start orphan lane sweep. Every destructive action is opt-in and gated. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { laneRunsOnHost } from "./admission.js";
7
+ import { laneRunDir, isPidRecordAlive, readPidRecord, readProcStarttime } from "./spawn.js";
8
+ import { readAllLanes, readLane } from "./store.js";
9
+ import { LanesError } from "./errors.js";
10
+ export const DEFAULT_REAP_MIN_AGE_MINUTES = 120;
11
+ export const REAP_SINGLE_FLIGHT_LOCK_STALE_MS = 60 * 60 * 1000;
12
+ export const REAP_RECENT_SWEEP_MS = 30 * 60 * 1000;
13
+ const SESSION_COMMANDS = new Set(["claude", "codex", "codex-code-mode-host", "grok", "grok-cli"]);
14
+ const staleMarkerSnapshots = new WeakMap();
15
+ function safeHostComponent(hostname) {
16
+ return hostname.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
17
+ }
18
+ function reaperStateDirectory() {
19
+ const stateRoot = process.env.HQ_LANES_REAPER_STATE_HOME?.trim() ||
20
+ process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state");
21
+ return path.join(stateRoot, "hq-cli", "lanes-reaper");
22
+ }
23
+ export function parseReapMinAge(value) {
24
+ if (value === undefined)
25
+ return DEFAULT_REAP_MIN_AGE_MINUTES;
26
+ const invalid = (message) => {
27
+ throw new LanesError("lane_reap_min_age_invalid", message, { value });
28
+ };
29
+ if (!/^(0|[1-9]\d*)$/.test(value))
30
+ invalid("--min-age must be a non-negative whole number of minutes");
31
+ const minutes = Number(value);
32
+ if (!Number.isSafeInteger(minutes) || minutes > Math.floor(Number.MAX_SAFE_INTEGER / 60_000)) {
33
+ invalid("--min-age is outside the supported range");
34
+ }
35
+ return minutes;
36
+ }
37
+ function readProcesses() {
38
+ const result = spawnSync("ps", ["-ww", "-axo", "pid=,ppid=,args="], { encoding: "utf8", windowsHide: true });
39
+ if (result.status !== 0)
40
+ throw new Error(`ps failed: ${result.stderr.trim()}`);
41
+ return result.stdout.split("\n").flatMap((line) => {
42
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
43
+ if (!match)
44
+ return [];
45
+ return [{ pid: Number(match[1]), ppid: Number(match[2]), args: match[3] ?? "" }];
46
+ });
47
+ }
48
+ export function isProcessRunning(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ }
52
+ catch (err) {
53
+ const code = err.code;
54
+ if (code === "ESRCH")
55
+ return false;
56
+ if (code !== "EPERM")
57
+ throw err;
58
+ }
59
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8", windowsHide: true });
60
+ return result.status === 0 && !/^\s*Z/.test(result.stdout);
61
+ }
62
+ function sessionCommand(args) {
63
+ const argv = args.trim().split(/\s+/);
64
+ const base = (value) => value?.split(/[\\/]/).at(-1);
65
+ const executable = base(argv[0]);
66
+ const isSessionName = (value) => {
67
+ const name = value?.toLowerCase() ?? "";
68
+ return SESSION_COMMANDS.has(name) || /^(codex|grok|claude)(?:[-.]|$)/.test(name);
69
+ };
70
+ if (isSessionName(executable))
71
+ return true;
72
+ if (["bash", "sh", "zsh", "dash", "ksh", "node"].includes(executable ?? "") && isSessionName(base(argv[1])))
73
+ return true;
74
+ if (executable === "env" && isSessionName(base(argv[1])))
75
+ return true;
76
+ return false;
77
+ }
78
+ function ancestryReachesSession(pid, processes) {
79
+ const byPid = new Map(processes.map((proc) => [proc.pid, proc]));
80
+ const worker = byPid.get(pid);
81
+ let current = worker ? byPid.get(worker.ppid) : undefined;
82
+ const seen = new Set();
83
+ for (let hops = 0; current && current.pid > 1 && hops < 64; hops += 1) {
84
+ if (seen.has(current.pid))
85
+ return true;
86
+ seen.add(current.pid);
87
+ if (sessionCommand(current.args))
88
+ return true;
89
+ current = byPid.get(current.ppid);
90
+ }
91
+ return false;
92
+ }
93
+ function descendantPids(rootPid, processes) {
94
+ const children = new Map();
95
+ for (const proc of processes)
96
+ children.set(proc.ppid, [...(children.get(proc.ppid) ?? []), proc.pid]);
97
+ const output = [];
98
+ const visit = (pid, seen) => {
99
+ for (const child of children.get(pid) ?? []) {
100
+ if (seen.has(child))
101
+ continue;
102
+ seen.add(child);
103
+ visit(child, seen);
104
+ output.push(child);
105
+ }
106
+ };
107
+ visit(rootPid, new Set([rootPid]));
108
+ return output;
109
+ }
110
+ function processGroup(pid) {
111
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "pgid="], { encoding: "utf8", windowsHide: true });
112
+ if (result.status !== 0)
113
+ return null;
114
+ const value = Number(result.stdout.trim());
115
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
116
+ }
117
+ function processGroupPids(groupId) {
118
+ const result = spawnSync("ps", ["-ww", "-e", "-o", "pid=,pgid="], { encoding: "utf8", windowsHide: true });
119
+ if (result.status !== 0)
120
+ return null;
121
+ return result.stdout.split("\n").flatMap((line) => {
122
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
123
+ return match && Number(match[2]) === groupId ? [Number(match[1])] : [];
124
+ });
125
+ }
126
+ function stopTree(rootPid, rootStarttime, processes, deps) {
127
+ const targets = [...descendantPids(rootPid, processes), rootPid];
128
+ const readStart = deps.readStarttime ?? readProcStarttime;
129
+ const expectedStarttimes = new Map(targets.map((pid) => [pid, pid === rootPid ? rootStarttime : readStart(pid)]));
130
+ const identityMatches = (pid) => {
131
+ const expected = expectedStarttimes.get(pid);
132
+ return expected !== undefined && expected !== null && readStart(pid) === expected;
133
+ };
134
+ const signal = (pid, value) => {
135
+ if (!identityMatches(pid))
136
+ return;
137
+ try {
138
+ process.kill(pid, value);
139
+ }
140
+ catch (err) {
141
+ const code = err.code;
142
+ if (code !== "ESRCH" && code !== "EPERM")
143
+ throw err;
144
+ }
145
+ };
146
+ const sleep = deps.sleep ?? ((ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); });
147
+ const groupId = processGroup(rootPid);
148
+ const groupMembersAtStart = groupId === rootPid ? processGroupPids(groupId) : [];
149
+ const groupStarttimes = new Map((groupMembersAtStart ?? []).map((pid) => [pid, pid === rootPid ? rootStarttime : readStart(pid)]));
150
+ const sameGroupRemains = () => {
151
+ if (groupId !== rootPid)
152
+ return false;
153
+ const current = processGroupPids(groupId);
154
+ if (current === null)
155
+ return false;
156
+ return current.some((pid) => {
157
+ const expected = groupStarttimes.get(pid);
158
+ return expected !== undefined && expected !== null && readStart(pid) === expected;
159
+ });
160
+ };
161
+ const signalGroup = (value, requireRootIdentity = true) => {
162
+ if (groupId !== rootPid || (requireRootIdentity && !identityMatches(rootPid)))
163
+ return;
164
+ try {
165
+ process.kill(-groupId, value);
166
+ }
167
+ catch (err) {
168
+ const code = err.code;
169
+ if (code !== "ESRCH" && code !== "EPERM" && code !== "EINVAL")
170
+ throw err;
171
+ }
172
+ };
173
+ // A detached worker group includes grandchildren that a PPID snapshot can miss.
174
+ signalGroup("SIGTERM");
175
+ for (const pid of targets)
176
+ signal(pid, "SIGTERM");
177
+ sleep(500);
178
+ const remainingGroup = groupId === rootPid ? processGroupPids(groupId) : [];
179
+ if (remainingGroup !== null && remainingGroup.length > 0 && sameGroupRemains())
180
+ signalGroup("SIGKILL", false);
181
+ for (const pid of targets)
182
+ signal(pid, "SIGKILL");
183
+ const isAlive = deps.isAlive ?? isProcessRunning;
184
+ const liveGroupMembers = () => {
185
+ if (groupId !== rootPid)
186
+ return [];
187
+ const members = processGroupPids(groupId);
188
+ return members === null ? [rootPid] : members.filter(isAlive);
189
+ };
190
+ const hasLiveTargets = () => targets.some(isAlive) || liveGroupMembers().length > 0;
191
+ for (let attempt = 0; attempt < 50 && hasLiveTargets(); attempt += 1) {
192
+ sleep(20);
193
+ }
194
+ return { targets, remaining: [...new Set([...targets.filter(isAlive), ...liveGroupMembers()])] };
195
+ }
196
+ function localRun(lane, hostname) {
197
+ if (!laneRunsOnHost(lane, { hostname, outpostId: null }))
198
+ return false;
199
+ return true;
200
+ }
201
+ export async function classifyLocalLanes(root, now, minAgeMinutes, deps = {}) {
202
+ const hostname = deps.hostname?.() ?? os.hostname();
203
+ const processes = (deps.processes ?? readProcesses)();
204
+ const alive = deps.isAlive ?? isProcessRunning;
205
+ const readStart = deps.readStarttime ?? readProcStarttime;
206
+ const result = [];
207
+ for (const lane of readAllLanes(root)) {
208
+ if (!localRun(lane, hostname))
209
+ continue;
210
+ const ageMs = now.getTime() - Date.parse(lane.created_at);
211
+ const age = Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 60_000)) : 0;
212
+ const finding = { lane_id: lane.lane_id, orphaned: false, reason: "not_a_candidate", age_minutes: age };
213
+ const laneRunDirectory = laneRunDir(root, lane.lane_id);
214
+ const workerRecord = readPidRecord(path.join(laneRunDirectory, "worker.pid"));
215
+ const watcherRecord = readPidRecord(path.join(laneRunDirectory, "watcher.pid"));
216
+ const markerRecords = new Map();
217
+ const staleState = [];
218
+ for (const [marker, record] of [["worker.pid", workerRecord], ["watcher.pid", watcherRecord]]) {
219
+ if (!record || isPidRecordAlive(record, alive, readStart))
220
+ continue;
221
+ staleState.push(marker);
222
+ markerRecords.set(marker, record);
223
+ }
224
+ if (staleState.length)
225
+ finding.stale_state = staleState;
226
+ staleMarkerSnapshots.set(finding, markerRecords);
227
+ if (lane.senior.kind !== "session" || !lane.senior.id)
228
+ finding.reason = "senior_is_not_a_session";
229
+ else {
230
+ finding.session_id = lane.senior.id;
231
+ const sessionState = deps.sessionState?.(root, lane.senior.id, now) ?? null;
232
+ if (sessionState !== "ended" && sessionState !== "lost")
233
+ finding.reason = sessionState === "live" ? "senior_session_alive" : "senior_session_not_proven_dead";
234
+ else if (lane.state !== "running")
235
+ finding.reason = "lane_not_running";
236
+ else if (lane.keep_alive === true || lane.keep_alive_standing === true)
237
+ finding.reason = "keep_alive";
238
+ else if (lane.senior_history.some((entry) => entry.reason === "reparented"))
239
+ finding.reason = "lane_adopted_or_reparented";
240
+ else if (age < minAgeMinutes)
241
+ finding.reason = "under_minimum_age";
242
+ else {
243
+ const worker = workerRecord;
244
+ if (!worker) {
245
+ finding.reason = "no_worker_pid";
246
+ }
247
+ else if (worker.starttime === undefined) {
248
+ finding.pid = worker.pid;
249
+ finding.reason = "worker_process_starttime_missing";
250
+ }
251
+ else {
252
+ const starttime = worker.starttime ?? null;
253
+ finding.pid = worker.pid;
254
+ finding.process_starttime = starttime;
255
+ const workerAlive = isPidRecordAlive(worker, alive, readStart);
256
+ if (!workerAlive) {
257
+ finding.reason = "worker_process_gone_or_pid_reused";
258
+ finding.stale_state = staleState;
259
+ }
260
+ else if (ancestryReachesSession(worker.pid, processes))
261
+ finding.reason = "process_has_live_session_ancestor";
262
+ else {
263
+ finding.orphaned = true;
264
+ finding.reason = `senior_session_${sessionState};worker_process_has_no_session_ancestor`;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ result.push(finding);
270
+ }
271
+ return result.sort((a, b) => b.age_minutes - a.age_minutes || a.lane_id.localeCompare(b.lane_id));
272
+ }
273
+ export async function reapLocalLanes(root, options, deps = {}) {
274
+ const nowFn = deps.now ?? (() => new Date());
275
+ const now = nowFn();
276
+ const stateDir = reaperStateDirectory();
277
+ fs.mkdirSync(stateDir, { recursive: true });
278
+ const host = safeHostComponent(deps.hostname?.() ?? os.hostname());
279
+ const lockDir = path.join(stateDir, `.lock-${host}`);
280
+ try {
281
+ fs.mkdirSync(lockDir);
282
+ }
283
+ catch (err) {
284
+ if (err.code !== "EEXIST")
285
+ throw err;
286
+ const lockAge = now.getTime() - fs.statSync(lockDir).mtimeMs;
287
+ if (lockAge <= REAP_SINGLE_FLIGHT_LOCK_STALE_MS)
288
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: options.stop ? "stop" : options.applyStaleState ? "apply_stale_state" : "report", stop_enabled: false, skipped: "already_running", lanes: [] };
289
+ const retiredLock = `${lockDir}.stale-${process.pid}-${now.getTime()}`;
290
+ try {
291
+ fs.renameSync(lockDir, retiredLock);
292
+ }
293
+ catch {
294
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "already_running", lanes: [] };
295
+ }
296
+ fs.rmSync(retiredLock, { recursive: true, force: true });
297
+ try {
298
+ fs.mkdirSync(lockDir);
299
+ }
300
+ catch {
301
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "already_running", lanes: [] };
302
+ }
303
+ }
304
+ const stamp = path.join(stateDir, `.last-sweep-${host}`);
305
+ if (!options.stop && !options.applyStaleState) {
306
+ try {
307
+ const stampText = fs.readFileSync(stamp, "utf8").trim();
308
+ const stampedAt = /^\d+$/.test(stampText) ? Number(stampText) : fs.statSync(stamp).mtimeMs;
309
+ const age = now.getTime() - stampedAt;
310
+ if (age >= 0 && age < REAP_RECENT_SWEEP_MS) {
311
+ fs.rmSync(lockDir, { recursive: true, force: true });
312
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "recent_sweep", lanes: [] };
313
+ }
314
+ }
315
+ catch (err) {
316
+ if (err.code !== "ENOENT")
317
+ throw err;
318
+ }
319
+ }
320
+ try {
321
+ const findings = await classifyLocalLanes(root, now, options.minAgeMinutes, deps);
322
+ const isAlive = deps.isAlive ?? isProcessRunning;
323
+ const readStart = deps.readStarttime ?? readProcStarttime;
324
+ const clearStaleMarkerIfUnchanged = (finding, marker) => {
325
+ const expected = staleMarkerSnapshots.get(finding)?.get(marker);
326
+ if (!expected)
327
+ return;
328
+ const markerPath = path.join(laneRunDir(root, finding.lane_id), marker);
329
+ const current = readPidRecord(markerPath);
330
+ if (!current || current.pid !== expected.pid || current.starttime !== expected.starttime)
331
+ return;
332
+ if (isPidRecordAlive(current, isAlive, readStart))
333
+ return;
334
+ fs.rmSync(markerPath, { force: true });
335
+ };
336
+ let stopEnabled = false;
337
+ for (const finding of findings) {
338
+ if (finding.stale_state?.length && options.applyStaleState) {
339
+ for (const marker of finding.stale_state)
340
+ if (marker.endsWith(".pid"))
341
+ clearStaleMarkerIfUnchanged(finding, marker);
342
+ }
343
+ if (!finding.orphaned || !options.stop || finding.pid === undefined) {
344
+ if (finding.orphaned && options.stop)
345
+ finding.would_stop = true;
346
+ continue;
347
+ }
348
+ const lane = readLane(root, finding.lane_id);
349
+ const enabled = await (deps.stopEnabled?.(lane) ?? false);
350
+ stopEnabled ||= enabled;
351
+ if (!enabled) {
352
+ finding.would_stop = true;
353
+ continue;
354
+ }
355
+ if (!deps.stopLane)
356
+ throw new Error("--stop requires the normal lane stop path");
357
+ const current = readLane(root, finding.lane_id);
358
+ const currentNow = nowFn();
359
+ const currentAge = currentNow.getTime() - Date.parse(current.created_at);
360
+ if (!localRun(current, deps.hostname?.() ?? os.hostname()) || current.state !== "running" ||
361
+ current.senior.kind !== "session" || !current.senior.id || current.senior.id !== finding.session_id ||
362
+ current.keep_alive === true || current.keep_alive_standing === true ||
363
+ current.senior_history.some((entry) => entry.reason === "reparented") ||
364
+ !Number.isFinite(currentAge) || currentAge < options.minAgeMinutes * 60_000) {
365
+ finding.orphaned = false;
366
+ finding.reason = "lane_protection_changed_before_stop";
367
+ continue;
368
+ }
369
+ const currentState = deps.sessionState?.(root, current.senior.id, currentNow) ?? null;
370
+ if (currentState !== "ended" && currentState !== "lost") {
371
+ finding.orphaned = false;
372
+ finding.reason = currentState === "live" ? "senior_session_alive" : "senior_session_not_proven_dead";
373
+ continue;
374
+ }
375
+ const rec = readPidRecord(path.join(laneRunDir(root, current.lane_id), "worker.pid"));
376
+ if (!rec || rec.pid !== finding.pid || rec.starttime !== finding.process_starttime || rec.starttime == null || !isPidRecordAlive(rec, isAlive, readStart)) {
377
+ finding.orphaned = false;
378
+ finding.reason = "worker_pid_changed_before_stop";
379
+ continue;
380
+ }
381
+ const processes = (deps.processes ?? readProcesses)();
382
+ if (ancestryReachesSession(finding.pid, processes)) {
383
+ finding.orphaned = false;
384
+ finding.reason = "process_has_live_session_ancestor";
385
+ continue;
386
+ }
387
+ const { remaining } = stopTree(rec.pid, rec.starttime, processes, deps);
388
+ await deps.stopLane(current.lane_id, options.reason);
389
+ finding.stop_complete = remaining.length === 0;
390
+ finding.stopped = finding.stop_complete;
391
+ if (!finding.stop_complete)
392
+ finding.stop_error = `process_tree_stop_incomplete:${remaining.join(",")}`;
393
+ }
394
+ fs.writeFileSync(stamp, `${now.getTime()}\n`);
395
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: options.stop ? "stop" : options.applyStaleState ? "apply_stale_state" : "report", stop_enabled: stopEnabled, lanes: findings };
396
+ }
397
+ finally {
398
+ fs.rmSync(lockDir, { recursive: true, force: true });
399
+ }
400
+ }
401
+ /** Internal alias used by session-start hooks; it has no shell or OS-specific dependency. */
402
+ export const runSessionStartReap = reapLocalLanes;
403
+ //# sourceMappingURL=reaper.js.map
@@ -11,6 +11,8 @@ export interface LanesFlagClient {
11
11
  }
12
12
  export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
13
13
  export interface ReadLanesFlagOptions {
14
+ /** Fallback for a missing registry row; kill switches should normally use true. */
15
+ defaultValue?: boolean;
14
16
  company?: LaneCompanyRef | null;
15
17
  companyUidSource?: string;
16
18
  env?: NodeJS.ProcessEnv;
@@ -155,7 +155,7 @@ async function readRegistryValue(flagKey, endpoint, options) {
155
155
  rememberFailure(error);
156
156
  }
157
157
  try {
158
- resolution = client.explain(flagKey, { fallback: false });
158
+ resolution = client.explain(flagKey, { fallback: options.defaultValue ?? false });
159
159
  }
160
160
  catch (error) {
161
161
  rememberFailure(error);
@@ -128,6 +128,8 @@ export declare function readProcStarttimeDetailed(pid: number): {
128
128
  starttime: number | null;
129
129
  error: string | null;
130
130
  };
131
+ /** macOS has no /proc; use ps's process start time as the PID identity token. */
132
+ export declare function parsePsStarttime(value: string): number | null;
131
133
  export declare function readProcStarttime(pid: number): number | null;
132
134
  export declare function parsePidRecord(text: string): PidRecord | null;
133
135
  export declare function readPidRecord(filePath: string): PidRecord | null;
@@ -31,6 +31,7 @@ import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./s
31
31
  import { managerStatusGateHeartbeat } from "./manager-status-gate.js";
32
32
  import { LANE_QUOTA_ACCOUNT_FAILOVER_FLAG } from "./flag-keys.js";
33
33
  import { readLanesRegistryFlagFailOff } from "./registry-flag.js";
34
+ import { LANE_WORKER_EXIT_PATH_ENV } from "./worker-process.js";
34
35
  import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
35
36
  import { LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT } from "./keep-alive.js";
36
37
  import { copySessionFiles, planSessionCopy } from "./move-account.js";
@@ -932,6 +933,16 @@ export function readProcStarttimeDetailed(pid) {
932
933
  }
933
934
  catch (err) {
934
935
  const code = err.code;
936
+ if (code === "ENOENT" && process.platform === "darwin") {
937
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
938
+ encoding: "utf8",
939
+ windowsHide: true,
940
+ });
941
+ const starttime = parsePsStarttime(result.status === 0 ? result.stdout : "");
942
+ return starttime === null
943
+ ? { starttime: null, error: result.status === 0 ? "invalid_ps_starttime" : "ENOENT" }
944
+ : { starttime, error: null };
945
+ }
935
946
  if (code === "ENOENT")
936
947
  return { starttime: null, error: "ENOENT" };
937
948
  process.stderr.write(`hq lanes: failed to read starttime for pid ${pid}: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -941,6 +952,14 @@ export function readProcStarttimeDetailed(pid) {
941
952
  };
942
953
  }
943
954
  }
955
+ /** macOS has no /proc; use ps's process start time as the PID identity token. */
956
+ export function parsePsStarttime(value) {
957
+ const trimmed = value.trim();
958
+ if (!trimmed)
959
+ return null;
960
+ const parsed = Date.parse(trimmed);
961
+ return Number.isFinite(parsed) ? parsed : null;
962
+ }
944
963
  export function readProcStarttime(pid) {
945
964
  return readProcStarttimeDetailed(pid).starttime;
946
965
  }
@@ -1698,6 +1717,17 @@ function spawnDetached(spawnProcess, command, args, env, cwd, stdoutPath, stderr
1698
1717
  closeFd(errFd, stderrPath);
1699
1718
  }
1700
1719
  }
1720
+ function spawnTrackedWorker(spawnProcess, command, args, env, cwd, stdoutPath, stderrPath, exitPath) {
1721
+ try {
1722
+ fs.unlinkSync(exitPath);
1723
+ }
1724
+ catch (error) {
1725
+ if (error.code !== "ENOENT")
1726
+ throw error;
1727
+ }
1728
+ const runnerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/lib/lanes/worker-process.js");
1729
+ return spawnDetached(spawnProcess, process.execPath, [runnerPath, command, ...args], { ...env, [LANE_WORKER_EXIT_PATH_ENV]: exitPath }, cwd, stdoutPath, stderrPath);
1730
+ }
1701
1731
  function requireDetached(proveDetached, pid, kind) {
1702
1732
  const proof = proveDetached(pid);
1703
1733
  if (proof.detached)
@@ -2572,7 +2602,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2572
2602
  let workerPid;
2573
2603
  let watcherPid;
2574
2604
  try {
2575
- workerPid = spawnDetached(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"));
2605
+ workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
2576
2606
  writePidRecord(path.join(runDir, "worker.pid"), {
2577
2607
  pid: workerPid,
2578
2608
  starttime: readProcStarttime(workerPid) ?? undefined,
@@ -2881,7 +2911,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2881
2911
  const previous = readSpawnManifest(runDir);
2882
2912
  let workerPid;
2883
2913
  try {
2884
- workerPid = spawnDetached(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"));
2914
+ workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
2885
2915
  writePidRecord(path.join(runDir, "worker.pid"), {
2886
2916
  pid: workerPid,
2887
2917
  starttime: readProcStarttime(workerPid) ?? undefined,
@@ -0,0 +1,12 @@
1
+ import { type LaneRecord } from "./types.js";
2
+ export interface LaneStatusRowFields {
3
+ last_line: string | null;
4
+ pr: string | null;
5
+ inbox_pending: number | null;
6
+ elapsed_s: number | null;
7
+ exit: number | null;
8
+ }
9
+ export type LaneStatusRow = LaneRecord & Partial<LaneStatusRowFields>;
10
+ /** Bounded, local-only status data. Remote-owned lanes report null for file-backed fields. */
11
+ export declare function readLaneStatusRowFields(hqRoot: string, lane: LaneRecord, nowMs?: number, local?: boolean): LaneStatusRowFields;
12
+ //# sourceMappingURL=status-fields.d.ts.map