@indigoai-us/hq-cli 5.342.11 → 5.342.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.342.12] — 2026-10-04
6
+
7
+ - Record every lane worker resume with its launch time and trigger, and classify watcher, monitor-event, and pending-work wakes separately from keep-alive timer accuracy.
8
+
9
+ - Hand off the package-use lease through a non-JSON marker after a re-executed CLI publishes its matching lease, and forward parent-only stop signals to the child.
10
+
5
11
  ## [5.342.11] — 2026-10-04
6
12
  - Release the CLI package-use lease before interactive login waits after resident modules preload.
7
13
  - Show the cached version update nudge after interactive commands when a newer cached release remains and its feature flag is enabled; avoid waiting for registry readiness when there is no nudge to show or repeating guidance after the updater already reported its result.
@@ -11147,7 +11147,7 @@ export declare const COMMAND_CATALOG: readonly [{
11147
11147
  readonly subcommands: readonly [];
11148
11148
  }, {
11149
11149
  readonly name: "keep-alive-audit";
11150
- readonly description: "Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.";
11150
+ readonly description: "Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.";
11151
11151
  readonly aliases: readonly [];
11152
11152
  readonly hidden: false;
11153
11153
  readonly helpOption: true;
@@ -13537,7 +13537,7 @@ export const COMMAND_CATALOG = [
13537
13537
  },
13538
13538
  {
13539
13539
  "name": "keep-alive-audit",
13540
- "description": "Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.",
13540
+ "description": "Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.",
13541
13541
  "aliases": [],
13542
13542
  "hidden": false,
13543
13543
  "helpOption": true,
@@ -2,7 +2,7 @@ import { buildKeepAliveAudit, formatKeepAliveAuditText } from "../lib/lanes/keep
2
2
  export function registerLaneKeepAliveAuditCommand(parent, deps) {
3
3
  parent
4
4
  .command("keep-alive-audit")
5
- .description("Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.")
5
+ .description("Audit lead lane keep-alive timing. Durable worker-resume events retain launch time and trigger after bounded lane history rolls over; late_restarts_after_window counts known launches after the grace window.")
6
6
  .option("--since <iso>", "Include stopped turns at or after this ISO date-time")
7
7
  .option("--lane <id>", "Audit one exact lane id")
8
8
  .option("--json", "Print the report as JSON")
@@ -1,8 +1,9 @@
1
- type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
1
+ type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "work_triggered" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
2
2
  interface KeepAliveAuditRestart {
3
3
  at: string | null;
4
4
  trigger: string;
5
5
  caller: string | null;
6
+ workerEvent?: boolean;
6
7
  }
7
8
  interface KeepAliveAuditRow {
8
9
  lane: string;
@@ -55,6 +55,7 @@ const VERDICTS = [
55
55
  "late",
56
56
  "early_wake",
57
57
  "message_wake",
58
+ "work_triggered",
58
59
  "self_wake",
59
60
  "early_by_keep_alive",
60
61
  "restarted_untimed",
@@ -68,6 +69,50 @@ function parseTime(value) {
68
69
  const parsed = Date.parse(value);
69
70
  return Number.isFinite(parsed) ? parsed : undefined;
70
71
  }
72
+ function readLaneWorkerResumeEvents(hqRoot, laneId) {
73
+ const root = fs.realpathSync(hqRoot);
74
+ const eventsPath = path.join(laneRunDir(root, laneId), "watcher", "events.jsonl");
75
+ if (!withinRoot(root, eventsPath)) {
76
+ throw new LanesError("keep_alive_audit_path_invalid", `Lane watcher event path escapes the HQ root for ${laneId}`, { lane_id: laneId });
77
+ }
78
+ let text;
79
+ try {
80
+ const stat = fs.lstatSync(eventsPath);
81
+ if (!stat.isFile() || stat.isSymbolicLink()) {
82
+ throw new LanesError("keep_alive_audit_event_invalid", `Lane watcher events must be a regular file: ${eventsPath}`, { lane_id: laneId, path: eventsPath });
83
+ }
84
+ const realPath = fs.realpathSync(eventsPath);
85
+ if (!withinRoot(root, realPath)) {
86
+ throw new LanesError("keep_alive_audit_path_invalid", `Lane watcher event file escapes the HQ root for ${laneId}`, { lane_id: laneId, path: eventsPath });
87
+ }
88
+ text = fs.readFileSync(realPath, "utf8");
89
+ }
90
+ catch (err) {
91
+ if (err.code === "ENOENT")
92
+ return [];
93
+ throw err;
94
+ }
95
+ const events = [];
96
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
97
+ if (!line.trim())
98
+ continue;
99
+ let value;
100
+ try {
101
+ value = JSON.parse(line);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ if (!isRecord(value) || value.event !== "lane_worker_resumed")
107
+ continue;
108
+ const at = parseTime(value.resumed_at);
109
+ if (at === undefined || typeof value.reason !== "string" || !value.reason.trim()) {
110
+ throw new LanesError("keep_alive_audit_event_invalid", `Lane worker resume event has invalid timing or reason: ${eventsPath}:${index + 1}`, { lane_id: laneId, path: eventsPath, line: index + 1 });
111
+ }
112
+ events.push({ at, reason: value.reason, caller: typeof value.caller === "string" ? value.caller : null });
113
+ }
114
+ return events.sort((a, b) => a.at - b.at);
115
+ }
71
116
  function iso(ms) {
72
117
  return new Date(ms).toISOString();
73
118
  }
@@ -176,9 +221,7 @@ function readKeepAliveResumeEvents(hqRoot, laneId) {
176
221
  if (nextEligibleAt === undefined) {
177
222
  throw new LanesError("keep_alive_audit_event_invalid", `Keep-alive resume event has invalid timing fields: ${eventsPath}:${index + 1}`, { lane_id: laneId, path: eventsPath, line: index + 1 });
178
223
  }
179
- const reason = value.reason === "pending_work" || value.reason === "next_check_at_due" || value.reason === "backoff"
180
- ? value.reason
181
- : undefined;
224
+ const reason = typeof value.reason === "string" ? value.reason : undefined;
182
225
  events.push({ at, nextEligibleAt, ...(reason === undefined ? {} : { reason }) });
183
226
  }
184
227
  return events.sort((a, b) => a.at - b.at);
@@ -286,7 +329,18 @@ function laneWasKeepAliveOffAtStop(lane, stoppedAt) {
286
329
  const optedInAt = parseTime(lane.keep_alive_opted_in_at);
287
330
  return optedInAt === undefined || optedInAt > stoppedAt;
288
331
  }
289
- function firstRestartAfter(lane, stop, nextEnvelopeMs) {
332
+ function firstRestartAfter(lane, stop, nextEnvelopeMs, workerResumeEvents) {
333
+ const recordedWorkerResume = workerResumeEvents
334
+ .filter((event) => event.at > stop.mtimeMs && (nextEnvelopeMs === undefined || event.at < nextEnvelopeMs))
335
+ .sort((a, b) => a.at - b.at)[0];
336
+ if (recordedWorkerResume !== undefined) {
337
+ return {
338
+ at: iso(recordedWorkerResume.at),
339
+ trigger: recordedWorkerResume.reason,
340
+ caller: recordedWorkerResume.caller,
341
+ workerEvent: true,
342
+ };
343
+ }
290
344
  const restart = (lane.resume_history ?? [])
291
345
  .map((entry) => ({ entry, at: parseTime(entry.at) }))
292
346
  .filter(({ entry, at }) => at !== undefined && at > stop.mtimeMs && (nextEnvelopeMs === undefined || at < nextEnvelopeMs) && typeof entry.trigger === "string")
@@ -329,6 +383,14 @@ function classifyVerdict(input) {
329
383
  if (restart?.trigger === "unknown" && laneWasKeepAliveOffAtStop(lane, input.stoppedAt))
330
384
  return "keep_alive_off";
331
385
  if (restart) {
386
+ if (restart.trigger === "pending_work" || restart.trigger === "watcher" || restart.trigger === "monitor_event")
387
+ return "work_triggered";
388
+ const workEvidenceEnd = input.nextEnvelopeAt ?? Number.POSITIVE_INFINITY;
389
+ const triggeredWork = restart.workerEvent !== true && input.resumeEvents
390
+ .filter((event) => event.at > input.stoppedAt && event.at < workEvidenceEnd && event.at <= (restart.at === null ? Number.POSITIVE_INFINITY : Date.parse(restart.at)))
391
+ .some((event) => event.reason === "pending_work" || event.reason === "watcher" || event.reason === "monitor_event");
392
+ if (triggeredWork)
393
+ return "work_triggered";
332
394
  if (restart.at === null)
333
395
  return "restarted_untimed";
334
396
  const restartAt = Date.parse(restart.at);
@@ -336,12 +398,14 @@ function classifyVerdict(input) {
336
398
  return "self_wake";
337
399
  if (restart.trigger === "message" || restart.trigger === "answered_questions")
338
400
  return "message_wake";
339
- if (restart.trigger === "keep_alive") {
401
+ if (restart.trigger === "keep_alive" || restart.trigger === "next_check_at_due" || restart.trigger === "backoff") {
340
402
  const event = input.resumeEvents
341
403
  .filter((candidate) => candidate.at > input.stoppedAt && candidate.at <= restartAt + 2 * 60_000)
342
404
  .sort((left, right) => Math.abs(left.at - restartAt) - Math.abs(right.at - restartAt))[0];
343
405
  if (event?.reason === "pending_work")
344
- return "message_wake";
406
+ return "work_triggered";
407
+ if (event?.reason === "watcher" || event?.reason === "monitor_event")
408
+ return "work_triggered";
345
409
  if (event?.reason === undefined &&
346
410
  (hasDeliveredDropboxEvidence(input.hqRoot, lane.lane_id, input.stoppedAt, restartAt) ||
347
411
  hasMessageResumeEvidence(lane, input.stoppedAt, restartAt) ||
@@ -384,6 +448,7 @@ export function buildKeepAliveAudit(input) {
384
448
  try {
385
449
  const { allMtimes, stops } = readStopEnvelopes(input.hqRoot, lane.lane_id);
386
450
  const keepAliveResumeEvents = readKeepAliveResumeEvents(input.hqRoot, lane.lane_id);
451
+ const laneWorkerResumeEvents = readLaneWorkerResumeEvents(input.hqRoot, lane.lane_id);
387
452
  const doneClassifications = readDoneClassifications(input.hqRoot, lane.lane_id);
388
453
  for (const stop of stops) {
389
454
  if (stop.mtimeMs < sinceMs || stop.mtimeMs > nowMs)
@@ -391,7 +456,7 @@ export function buildKeepAliveAudit(input) {
391
456
  if (!doneClassifications.some((at) => at >= stop.mtimeMs && at - stop.mtimeMs <= 10 * 60_000))
392
457
  continue;
393
458
  const nextEnvelopeMs = allMtimes.find((mtime) => mtime > stop.mtimeMs);
394
- const restart = firstRestartAfter(lane, stop, nextEnvelopeMs);
459
+ const restart = firstRestartAfter(lane, stop, nextEnvelopeMs, laneWorkerResumeEvents);
395
460
  const due = dueTime(stop, keepAliveResumeEvents);
396
461
  rows.push({
397
462
  lane: lane.lane_id,
@@ -402,7 +467,7 @@ export function buildKeepAliveAudit(input) {
402
467
  due_at: iso(due.at),
403
468
  due_reason: due.reason,
404
469
  restart,
405
- verdict: classifyVerdict({ dueAt: due.at, stoppedAt: stop.mtimeMs, nextCheckAt: stop.nextCheckAt, restart, lane, nowMs, resumeEvents: keepAliveResumeEvents, hqRoot: input.hqRoot }),
470
+ verdict: classifyVerdict({ dueAt: due.at, stoppedAt: stop.mtimeMs, nextCheckAt: stop.nextCheckAt, restart, lane, nowMs, resumeEvents: keepAliveResumeEvents, ...(nextEnvelopeMs === undefined ? {} : { nextEnvelopeAt: nextEnvelopeMs }), hqRoot: input.hqRoot }),
406
471
  });
407
472
  }
408
473
  }
@@ -56,6 +56,70 @@ const PROVIDER_COMMAND = {
56
56
  codex: "codex",
57
57
  grok: "grok",
58
58
  };
59
+ function keepAliveResumeReason(runDir, pendingResume) {
60
+ if (pendingResume?.keep_alive !== true || typeof pendingResume.at !== "string")
61
+ return undefined;
62
+ const eventsPath = path.join(runDir, "watcher", "events.jsonl");
63
+ let content;
64
+ try {
65
+ content = fs.readFileSync(eventsPath, "utf8");
66
+ }
67
+ catch (err) {
68
+ if (err.code === "ENOENT")
69
+ return "keep_alive";
70
+ throw err;
71
+ }
72
+ for (const line of content.split(/\r?\n/).reverse()) {
73
+ if (!line.trim())
74
+ continue;
75
+ let value;
76
+ try {
77
+ value = JSON.parse(line);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
83
+ const event = value;
84
+ if (event.event === "keep_alive_resume" && event.at === pendingResume.at && typeof event.reason === "string") {
85
+ return event.reason;
86
+ }
87
+ }
88
+ }
89
+ return "keep_alive";
90
+ }
91
+ function appendLaneWorkerResumeEvent(input) {
92
+ const eventsPath = path.join(laneRunDir(input.hqRoot, input.laneId), "watcher", "events.jsonl");
93
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
94
+ let separator = "";
95
+ try {
96
+ const fd = fs.openSync(eventsPath, "r");
97
+ try {
98
+ const size = fs.fstatSync(fd).size;
99
+ if (size > 0) {
100
+ const lastByte = Buffer.alloc(1);
101
+ fs.readSync(fd, lastByte, 0, 1, size - 1);
102
+ if (lastByte[0] !== 0x0a)
103
+ separator = "\n";
104
+ }
105
+ }
106
+ finally {
107
+ fs.closeSync(fd);
108
+ }
109
+ }
110
+ catch (err) {
111
+ if (err.code !== "ENOENT")
112
+ throw err;
113
+ }
114
+ fs.appendFileSync(eventsPath, `${separator}${JSON.stringify({
115
+ event: "lane_worker_resumed",
116
+ lane_id: input.laneId,
117
+ resumed_at: input.resumedAt,
118
+ reason: input.reason,
119
+ ...(input.caller === undefined ? {} : { caller: input.caller }),
120
+ ...(input.turnId === undefined ? {} : { turn_id: input.turnId }),
121
+ })}\n`, "utf8");
122
+ }
59
123
  export function providerCommandOnPath(command, pathEnv) {
60
124
  // Test and embedded callers can intentionally omit PATH and delegate lookup
61
125
  // to their spawn implementation. A present PATH is authoritative.
@@ -2081,6 +2145,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2081
2145
  trigger.kind === "resume" || trigger.kind === "message"
2082
2146
  ? "user"
2083
2147
  : undefined);
2148
+ const resumeReason = keepAliveResumeReason(runDir, pendingResume) ?? resumeHistoryTrigger;
2084
2149
  let statusGateHeartbeat;
2085
2150
  try {
2086
2151
  statusGateHeartbeat = managerStatusGateHeartbeat(hqRoot, existing, laneTurnId, nowIso);
@@ -2356,6 +2421,14 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2356
2421
  ...(resumeHistoryCaller ? { caller: resumeHistoryCaller } : {}),
2357
2422
  }),
2358
2423
  }));
2424
+ appendLaneWorkerResumeEvent({
2425
+ hqRoot,
2426
+ laneId: lane.lane_id,
2427
+ resumedAt: nowIso,
2428
+ reason: resumeReason,
2429
+ ...(resumeHistoryCaller === undefined ? {} : { caller: resumeHistoryCaller }),
2430
+ turnId: laneTurnId,
2431
+ });
2359
2432
  }
2360
2433
  return laneLaunchResult(readLane(hqRoot, lane.lane_id));
2361
2434
  }
@@ -2670,6 +2743,12 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2670
2743
  ...(callerLaneId ? { caller: callerLaneId } : {}),
2671
2744
  }),
2672
2745
  }));
2746
+ appendLaneWorkerResumeEvent({
2747
+ hqRoot,
2748
+ laneId: lane.lane_id,
2749
+ resumedAt: startedAt,
2750
+ reason: "envelope_repair",
2751
+ });
2673
2752
  return workerPid;
2674
2753
  }
2675
2754
  export { PROVIDER_HOME_ENV, PROVIDER_COMMAND };
@@ -1,3 +1,4 @@
1
+ export declare const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
1
2
  export declare class PackageUseUpdatePendingError extends Error {
2
3
  constructor(message: string);
3
4
  }
@@ -10,11 +11,24 @@ export interface PackageUseLeaseRecord {
10
11
  start_time_ms: number;
11
12
  hq_version: string;
12
13
  }
14
+ export interface PackageUseLeaseHandoff {
15
+ readonly envValue: string;
16
+ waitForChild(pid: number, childAlive: () => boolean): Promise<boolean>;
17
+ cleanup(): void;
18
+ }
13
19
  export declare function npmPrefix(root: string, platform?: NodeJS.Platform): string | null;
14
20
  export declare function packageUseLeaseDirectory(prefix: string, stateDirectory?: string): string;
15
21
  export declare function packageUseUpdateRequestPath(prefix: string, stateDirectory?: string): string;
16
22
  declare function waitForUpdateRequest(requestPath: string, timeoutMs: number): void;
17
23
  export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string): PackageUseLease;
24
+ /**
25
+ * Create a bounded, per-reexec rendezvous in the current lease directory.
26
+ * The child confirms readiness only after publishing its own atomic lease.
27
+ */
28
+ export declare function createPackageUseLeaseHandoff(lease: PackageUseLease, timeoutMs?: number): PackageUseLeaseHandoff;
29
+ export declare function createCurrentPackageUseLeaseHandoff(timeoutMs?: number): PackageUseLeaseHandoff | null;
30
+ /** Publish this process's lease identity to a parent waiting in the same prefix. */
31
+ export declare function publishPackageUseLeaseHandoff(lease: PackageUseLease, env?: NodeJS.ProcessEnv): boolean;
18
32
  export declare function acquireCurrentPackageUseLease(): PackageUseLease | null;
19
33
  /** Release this process's package-use lease after a long-running command has loaded its package modules. */
20
34
  export declare function releaseCurrentPackageUseLease(): void;
@@ -30,6 +44,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
30
44
  export declare function currentPackageUseUpdateRequested(): boolean;
31
45
  export declare const __test__: {
32
46
  waitForUpdateRequest: typeof waitForUpdateRequest;
47
+ setCurrentPackageUseLease(lease: PackageUseLease | null): void;
33
48
  };
34
49
  export {};
35
50
  //# sourceMappingURL=package-use-lease.d.ts.map
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
6
6
  import { performance } from "node:perf_hooks";
7
7
  const CLI_NAME = "@indigoai-us/hq-cli";
8
8
  const UPDATE_REQUEST = "update.pending.json";
9
+ export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
10
+ const HANDOFF_TIMEOUT_MS = 2_000;
9
11
  export class PackageUseUpdatePendingError extends Error {
10
12
  constructor(message) {
11
13
  super(message);
@@ -172,6 +174,93 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory) {
172
174
  }
173
175
  throw new PackageUseUpdatePendingError("An HQ CLI update is waiting for the package lease; retry when it finishes");
174
176
  }
177
+ /**
178
+ * Create a bounded, per-reexec rendezvous in the current lease directory.
179
+ * The child confirms readiness only after publishing its own atomic lease.
180
+ */
181
+ export function createPackageUseLeaseHandoff(lease, timeoutMs = HANDOFF_TIMEOUT_MS) {
182
+ const directory = path.dirname(lease.leasePath);
183
+ const markerPath = path.join(directory, `.reexec-${process.pid}-${randomBytes(12).toString("hex")}.handoff`);
184
+ let cleaned = false;
185
+ const cleanup = () => {
186
+ if (cleaned)
187
+ return;
188
+ cleaned = true;
189
+ process.removeListener("exit", cleanup);
190
+ try {
191
+ rmSync(markerPath, { force: true });
192
+ rmSync(`${markerPath}.tmp`, { force: true });
193
+ }
194
+ catch (error) {
195
+ process.stderr.write(`hq-cli package-use lease handoff cleanup failed (${errorLabel(error)})\n`);
196
+ }
197
+ };
198
+ process.once("exit", cleanup);
199
+ return {
200
+ envValue: markerPath,
201
+ async waitForChild(pid, childAlive) {
202
+ const deadline = Date.now() + timeoutMs;
203
+ while (childAlive() && Date.now() <= deadline) {
204
+ try {
205
+ const marker = JSON.parse(readFileSync(markerPath, "utf8"));
206
+ if (marker.pid === pid && Number.isSafeInteger(marker.start_time_ms) && typeof marker.lease_name === "string") {
207
+ const leasePath = path.join(directory, marker.lease_name);
208
+ if (path.basename(leasePath) === marker.lease_name) {
209
+ const record = JSON.parse(readFileSync(leasePath, "utf8"));
210
+ if (record.pid === pid && record.start_time_ms === marker.start_time_ms)
211
+ return true;
212
+ }
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) {
217
+ process.stderr.write(`hq-cli package-use lease handoff check failed (${errorLabel(error)})\n`);
218
+ }
219
+ }
220
+ await new Promise((resolve) => setTimeout(resolve, 25));
221
+ }
222
+ return false;
223
+ },
224
+ cleanup,
225
+ };
226
+ }
227
+ export function createCurrentPackageUseLeaseHandoff(timeoutMs = HANDOFF_TIMEOUT_MS) {
228
+ return currentPackageUseLease ? createPackageUseLeaseHandoff(currentPackageUseLease, timeoutMs) : null;
229
+ }
230
+ /** Publish this process's lease identity to a parent waiting in the same prefix. */
231
+ export function publishPackageUseLeaseHandoff(lease, env = process.env) {
232
+ const markerPath = env[PACKAGE_USE_LEASE_HANDOFF_ENV];
233
+ if (!markerPath)
234
+ return false;
235
+ const directory = path.dirname(lease.leasePath);
236
+ if (path.dirname(path.resolve(markerPath)) !== path.resolve(directory)) {
237
+ delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
238
+ return false;
239
+ }
240
+ const match = /^(\d+)-(\d+)\.json$/.exec(path.basename(lease.leasePath));
241
+ if (!match || Number(match[1]) !== process.pid) {
242
+ delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
243
+ return false;
244
+ }
245
+ const record = {
246
+ pid: process.pid,
247
+ start_time_ms: Number(match[2]),
248
+ lease_name: path.basename(lease.leasePath),
249
+ };
250
+ const tempPath = `${markerPath}.tmp`;
251
+ try {
252
+ writeFileSync(tempPath, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
253
+ renameSync(tempPath, markerPath);
254
+ delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
255
+ return true;
256
+ }
257
+ catch (error) {
258
+ rmSync(tempPath, { force: true });
259
+ delete env[PACKAGE_USE_LEASE_HANDOFF_ENV];
260
+ process.stderr.write(`hq-cli package-use lease handoff publish failed (${errorLabel(error)})\n`);
261
+ return false;
262
+ }
263
+ }
175
264
  function exists(file) {
176
265
  try {
177
266
  readFileSync(file);
@@ -198,6 +287,7 @@ export function acquireCurrentPackageUseLease() {
198
287
  }
199
288
  const lease = acquirePackageUseLease(prefix, pkg.version);
200
289
  currentPackageUseLease = lease;
290
+ publishPackageUseLeaseHandoff(lease);
201
291
  return lease;
202
292
  }
203
293
  /** Release this process's package-use lease after a long-running command has loaded its package modules. */
@@ -260,5 +350,8 @@ export function currentPackageUseUpdateRequested() {
260
350
  }
261
351
  export const __test__ = {
262
352
  waitForUpdateRequest,
353
+ setCurrentPackageUseLease(lease) {
354
+ currentPackageUseLease = lease;
355
+ },
263
356
  };
264
357
  //# sourceMappingURL=package-use-lease.js.map
@@ -56,6 +56,7 @@
56
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
+ import { spawn } from "node:child_process";
59
60
  import * as fs from "node:fs";
60
61
  import { type RunningInstall, type UpdateResult } from "./version-gate.js";
61
62
  import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
@@ -145,6 +146,10 @@ export interface SelfUpdateDeps {
145
146
  renamePath?: typeof fs.renameSync;
146
147
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
147
148
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
149
+ /** Async process-spawn seam for lease handoff tests. */
150
+ spawnReexec?: typeof spawn;
151
+ /** stdin TTY probe for re-exec signal forwarding tests. */
152
+ stdinIsTTY?: () => boolean;
148
153
  acquireLock?: () => (() => void) | null;
149
154
  /**
150
155
  * Read-your-writes convergence check run after a "successful" install:
@@ -56,7 +56,7 @@
56
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
- import { spawnSync } from "node:child_process";
59
+ import { spawn } from "node:child_process";
60
60
  import { randomBytes } from "node:crypto";
61
61
  import * as fs from "node:fs";
62
62
  import * as path from "node:path";
@@ -70,6 +70,7 @@ import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
70
70
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
71
71
  import { markLatestIneffective } from "./version-check.js";
72
72
  import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
73
+ import { createCurrentPackageUseLeaseHandoff, PACKAGE_USE_LEASE_HANDOFF_ENV, releaseCurrentPackageUseLease, } from "./package-use-lease.js";
73
74
  /**
74
75
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
75
76
  * One update + one re-exec per user invocation, ever.
@@ -596,18 +597,101 @@ export function acquireUpdateLock(now = Date.now()) {
596
597
  * command. Returns the child's exit status, or null when the child could not be
597
598
  * started at all (e.g. `hq` not on PATH in a minimal-PATH parent).
598
599
  */
599
- function reexecHq(argv, env) {
600
+ async function reexecHq(argv, env, spawnProcess = spawn, stdinIsTTY = () => process.stdin.isTTY === true) {
600
601
  const plan = buildSpawnPlan("hq", argv);
601
- const result = spawnSync(plan.cmd, plan.args, {
602
- stdio: "inherit",
603
- shell: plan.shell,
604
- env,
605
- });
606
- if (result.error)
602
+ const handoff = createCurrentPackageUseLeaseHandoff();
603
+ const childEnv = handoff
604
+ ? { ...env, [PACKAGE_USE_LEASE_HANDOFF_ENV]: handoff.envValue }
605
+ : env;
606
+ let childForSignals;
607
+ let childSpawned = false;
608
+ let childClosed = false;
609
+ let spawnedChild;
610
+ const pendingSignals = [];
611
+ const reexecSignals = process.platform === "win32"
612
+ ? ["SIGINT", "SIGTERM"]
613
+ : ["SIGINT", "SIGTERM", "SIGHUP"];
614
+ const forwardSignal = (signal) => {
615
+ if (signal === "SIGINT" && stdinIsTTY())
616
+ return;
617
+ if (childClosed)
618
+ return;
619
+ if (!childForSignals || !childSpawned) {
620
+ pendingSignals.push(signal);
621
+ return;
622
+ }
623
+ if (childForSignals.exitCode === null && childForSignals.signalCode === null) {
624
+ childForSignals.kill(signal);
625
+ }
626
+ };
627
+ const signalHandlers = new Map(reexecSignals.map((signal) => [signal, () => forwardSignal(signal)]));
628
+ for (const [signal, handler] of signalHandlers)
629
+ process.on(signal, handler);
630
+ const removeSignalHandlers = () => {
631
+ for (const [signal, handler] of signalHandlers)
632
+ process.removeListener(signal, handler);
633
+ };
634
+ // spawnSync left default parent signal handling in place, so a parent-only
635
+ // SIGTERM or SIGHUP could terminate it. Forward stop signals to the async
636
+ // child while keeping this process waiting for the child's exit status.
637
+ try {
638
+ spawnedChild = spawnProcess(plan.cmd, plan.args, {
639
+ stdio: "inherit",
640
+ shell: plan.shell,
641
+ env: childEnv,
642
+ });
643
+ childForSignals = spawnedChild;
644
+ }
645
+ catch (error) {
646
+ removeSignalHandlers();
647
+ handoff?.cleanup();
648
+ process.stderr.write(`hq-cli re-exec spawn failed (${reexecErrorClass(error)})\n`);
607
649
  return null;
608
- // A signal-killed child has status null; surface it as a failure exit rather
609
- // than pretending the command completed.
610
- return result.status ?? 1;
650
+ }
651
+ return new Promise((resolve) => {
652
+ let alive = true;
653
+ let spawnFailed = false;
654
+ spawnedChild.once("error", (error) => {
655
+ spawnFailed = true;
656
+ process.stderr.write(`hq-cli re-exec spawn failed (${reexecErrorClass(error)})\n`);
657
+ });
658
+ spawnedChild.once("spawn", () => {
659
+ childSpawned = true;
660
+ for (const signal of pendingSignals.splice(0)) {
661
+ if (spawnedChild.exitCode === null && spawnedChild.signalCode === null)
662
+ spawnedChild.kill(signal);
663
+ }
664
+ if (!handoff || spawnedChild.pid === undefined)
665
+ return;
666
+ void handoff.waitForChild(spawnedChild.pid, () => alive).then((matched) => {
667
+ if (matched && alive) {
668
+ releaseCurrentPackageUseLease();
669
+ handoff.cleanup();
670
+ }
671
+ }).catch((error) => {
672
+ process.stderr.write(`hq-cli package-use lease handoff wait failed (${error instanceof Error ? error.name : "unknown"})\n`);
673
+ });
674
+ });
675
+ spawnedChild.once("close", (code, _signal) => {
676
+ alive = false;
677
+ childClosed = true;
678
+ pendingSignals.length = 0;
679
+ removeSignalHandlers();
680
+ handoff?.cleanup();
681
+ // The former spawnSync path returned null on spawn failure and 1 for a signal.
682
+ resolve(spawnFailed ? null : code ?? 1);
683
+ });
684
+ });
685
+ }
686
+ function reexecErrorClass(error) {
687
+ if (error && typeof error === "object") {
688
+ const code = error.code;
689
+ if (typeof code === "string" && /^[A-Z0-9_]{1,32}$/.test(code))
690
+ return code;
691
+ if (error instanceof Error)
692
+ return error.name;
693
+ }
694
+ return "unknown";
611
695
  }
612
696
  async function updateAndReexec(argv, flavor, known, deps) {
613
697
  const env = deps.env ?? process.env;
@@ -829,7 +913,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
829
913
  return { action: "updated", latest };
830
914
  }
831
915
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
832
- const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
916
+ const status = deps.spawnReexec
917
+ ? await reexecHq([...argv.slice(2)], childEnv, deps.spawnReexec, deps.stdinIsTTY)
918
+ : deps.reexec
919
+ ? deps.reexec([...argv.slice(2)], childEnv)
920
+ : await reexecHq([...argv.slice(2)], childEnv, spawn, deps.stdinIsTTY);
833
921
  if (status === null) {
834
922
  console.error(chalk.yellow(`⚠ Updated hq-cli to ${latest}, but couldn't re-launch \`hq\` from PATH; ` +
835
923
  `continuing the ${flavor.noun} on ${current}.`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.342.11",
3
+ "version": "5.342.12",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {