@indigoai-us/hq-cli 5.304.0 → 5.306.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/command-catalog.generated.d.ts +3 -3
  3. package/dist/command-catalog.generated.js +5 -3
  4. package/dist/command-registration-plan.js +8 -0
  5. package/dist/commands/daemon.js +24 -23
  6. package/dist/commands/lanes.js +13 -0
  7. package/dist/commands/mcp-status.js +1 -0
  8. package/dist/commands/members.js +0 -1
  9. package/dist/commands/people.d.ts +3 -0
  10. package/dist/commands/people.js +27 -3
  11. package/dist/index.js +6 -1
  12. package/dist/lazy-commands.js +5 -2
  13. package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
  14. package/dist/lib/aws-sdk-node-warning-output.js +22 -0
  15. package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
  16. package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
  17. package/dist/lib/daemon/catalog.js +6 -2
  18. package/dist/lib/daemon/remote-control.d.ts +19 -8
  19. package/dist/lib/daemon/remote-control.js +62 -12
  20. package/dist/lib/daemon/supervisor.d.ts +2 -0
  21. package/dist/lib/daemon/supervisor.js +11 -0
  22. package/dist/lib/doctor/checks/work-context.js +2 -2
  23. package/dist/lib/lanes/keep-alive.js +13 -2
  24. package/dist/lib/mesh/live/daemon/doctor.js +2 -2
  25. package/dist/lib/mesh/live/flush.d.ts +2 -2
  26. package/dist/lib/mesh/live/flush.js +49 -29
  27. package/dist/lib/mesh/live/spool.d.ts +6 -0
  28. package/dist/lib/mesh/live/spool.js +203 -4
  29. package/dist/lib/work-context/bind-best.js +13 -1
  30. package/dist/main.js +16 -3
  31. package/dist/startup-registration.js +9 -3
  32. package/dist/utils/install-tree-torn.d.ts +13 -6
  33. package/dist/utils/install-tree-torn.js +42 -16
  34. package/dist/utils/self-update.d.ts +4 -0
  35. package/dist/utils/self-update.js +46 -9
  36. package/dist/utils/sentry-fingerprint.js +1 -0
  37. package/dist/utils/version-check.d.ts +7 -0
  38. package/dist/utils/version-check.js +30 -1
  39. package/dist/utils/version-gate.d.ts +28 -10
  40. package/dist/utils/version-gate.js +159 -29
  41. package/dist/utils/version-report.d.ts +2 -1
  42. package/dist/utils/version-report.js +8 -1
  43. package/package.json +1 -1
  44. package/dist/lib/work-context/index.d.ts +0 -26
  45. package/dist/lib/work-context/index.js +0 -22
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
4
  import { dropboxInboxHasMessages } from "./dropbox.js";
5
5
  import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
6
- import { isIsoDateTime } from "./types.js";
6
+ import { isTerminalLaneState, isIsoDateTime, } from "./types.js";
7
7
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
8
8
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
9
9
  export const LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG = "lanes.keep-alive-next-check-at";
@@ -254,7 +254,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
254
254
  return lanes.find((candidate) => {
255
255
  if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
256
256
  return false;
257
- if (candidate.state === "cancelled" || candidate.state === "killed" || candidate.state === "failed")
257
+ if (candidate.state === "cancelled" ||
258
+ candidate.state === "killed" ||
259
+ candidate.state === "failed" ||
260
+ (lane.state === "running" && isTerminalLaneState(candidate.state)))
258
261
  return false;
259
262
  const candidateCompany = companyOf(candidate) ?? null;
260
263
  const sameCompany = laneCompany === null
@@ -270,8 +273,16 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
270
273
  const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
271
274
  candidate.senior.kind === lane.senior.kind &&
272
275
  candidate.senior.id === lane.senior.id;
276
+ const differentKnownSenior = lane.senior.kind === "lane" &&
277
+ candidate.senior.kind === "lane" &&
278
+ typeof lane.senior.id === "string" &&
279
+ lane.senior.id.trim().length > 0 &&
280
+ typeof candidate.senior.id === "string" &&
281
+ candidate.senior.id.trim().length > 0 &&
282
+ candidate.senior.id !== lane.senior.id;
273
283
  const sameProfile = lane.worker_profile !== undefined &&
274
284
  candidate.worker_profile === lane.worker_profile &&
285
+ !(lane.state === "running" && differentKnownSenior) &&
275
286
  !(lineageAware && sameLaneSenior);
276
287
  const projectLead = lane.project_id !== undefined &&
277
288
  candidate.project_id === lane.project_id &&
@@ -11,7 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
12
  import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
13
13
  import { formatDroppedPresenceCompany } from "./credentials.js";
14
- import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
14
+ import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
15
15
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
16
16
  import { daemonDir, daemonPidPath } from "./paths.js";
17
17
  import { readEmitState } from "../emit.js";
@@ -178,7 +178,7 @@ export function collectDaemonDoctor(deps = {}) {
178
178
  const heldPath = workMeshHeldPath(meshRoot);
179
179
  const deadPath = workMeshDeadLetterPath(meshRoot);
180
180
  const spoolDepth = countJsonlLines(spoolPath);
181
- const heldCount = countJsonlLines(heldPath);
181
+ const heldCount = countDistinctHeldEvents(heldPath);
182
182
  const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
183
183
  const outboxOperations = listOutboxOperations(ctxRoot);
184
184
  const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
@@ -14,7 +14,7 @@ import { type SessionEventsPoster } from "./session-events-client.js";
14
14
  export declare const HELD_TTL_MS: number;
15
15
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
16
16
  export declare const HELD_RETRY_INTERVAL_MS: number;
17
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
17
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
18
18
  export declare const HELD_MAX_LINES = 20000;
19
19
  /**
20
20
  * Re-check an unbound session against the project catalog at most this often.
@@ -46,7 +46,7 @@ export interface FlushDeps {
46
46
  lastHeldRetryAtMs?: number;
47
47
  /** Invoked when this flush claims held, so the daemon can advance its timestamp. */
48
48
  onHeldClaimed?: (atMs: number) => void;
49
- /** Max lines retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
49
+ /** Max distinct eventIds retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
50
50
  heldMaxLines?: number;
51
51
  /** Optional logger (daemon wires this); used once per overflow trim. */
52
52
  log?: (message: string) => void;
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
22
22
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
23
23
  import { enrichSessionEndEvent } from "./report-summary.js";
24
24
  import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
25
- import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendSpoolLine, claimHeld, claimSpool, countJsonlLines, ensureSpoolFile, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
25
+ import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
26
26
  import { workMeshHeldPath, workMeshRoot } from "./paths.js";
27
27
  /** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
28
28
  const SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH = 25;
@@ -31,7 +31,7 @@ const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
31
31
  export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
32
32
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
33
33
  export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
34
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
34
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
35
35
  export const HELD_MAX_LINES = 20_000;
36
36
  /**
37
37
  * Re-check an unbound session against the project catalog at most this often.
@@ -279,11 +279,9 @@ export async function flushSessionEvents(deps) {
279
279
  state = readSessionState(sessionId, deps.workContextRoot) ?? state;
280
280
  if (!allowed) {
281
281
  const heldAt = now().toISOString();
282
- for (const item of remainingItems) {
283
- appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
284
- summary.held += 1;
282
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt)), deps.workMeshRoot);
283
+ for (const item of remainingItems)
285
284
  markDisposed(item.event);
286
- }
287
285
  continue;
288
286
  }
289
287
  }
@@ -306,11 +304,9 @@ export async function flushSessionEvents(deps) {
306
304
  }
307
305
  if (decision.action === "hold") {
308
306
  const heldAt = now().toISOString();
309
- for (const item of remainingItems) {
310
- appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
311
- summary.held += 1;
307
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt)), deps.workMeshRoot);
308
+ for (const item of remainingItems)
312
309
  markDisposed(item.event);
313
- }
314
310
  continue;
315
311
  }
316
312
  // Post a bounded portion of this session's remaining backlog (after any
@@ -368,11 +364,9 @@ export async function flushSessionEvents(deps) {
368
364
  deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
369
365
  }
370
366
  const heldAt = now().toISOString();
371
- for (const item of originalChunk) {
372
- appendHeldLine(heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt), deps.workMeshRoot);
373
- summary.held += 1;
367
+ summary.held += appendHeldLinesIfAbsent(originalChunk.map((item) => heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt)), deps.workMeshRoot);
368
+ for (const item of originalChunk)
374
369
  markDisposed(item.event);
375
- }
376
370
  continue;
377
371
  }
378
372
  if (!result.retryable) {
@@ -429,15 +423,17 @@ export async function flushSessionEvents(deps) {
429
423
  * flusher (`hq mesh flush` beside the daemon) may have appended to a fresh
430
424
  * held.jsonl after our claim renamed the old one away.
431
425
  * - The overflow claim file is removed only after every dead letter and every
432
- * retained line is on disk. If anything throws, the claim stays behind and
433
- * recoverOrphanClaims re-reads it on the next flush (duplicates are possible,
434
- * loss is not).
426
+ * retained line is on disk. Retained rows are idempotent by eventId, so a
427
+ * retry after an interrupted append is safe; the claim stays until recovery
428
+ * can finish, preserving the append-before-remove ordering.
435
429
  */
436
430
  function trimHeldOverflow(meshRoot, maxLines, now, summary) {
437
431
  if (maxLines <= 0)
438
432
  return 0;
439
433
  const heldPath = workMeshHeldPath(meshRoot);
440
- if (countJsonlLines(heldPath) <= maxLines)
434
+ const lineCount = countJsonlLines(heldPath);
435
+ const distinctCount = countDistinctHeldEvents(heldPath);
436
+ if (lineCount <= maxLines && lineCount === distinctCount)
441
437
  return 0;
442
438
  const claim = claimHeld(meshRoot, `${now().getTime()}.overflow`);
443
439
  if (!claim.claimPath)
@@ -447,15 +443,26 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
447
443
  return 0;
448
444
  }
449
445
  try {
450
- if (claim.lines.length <= maxLines) {
451
- appendHeldLines(meshRoot, claim.lines);
446
+ const alreadyHeldIds = readHeldEventIds(heldPath);
447
+ const seenClaimIds = new Set();
448
+ const uniqueLines = claim.lines.filter((line) => {
449
+ const eventId = heldEventIdOfLine(line);
450
+ if (!eventId)
451
+ return true;
452
+ if (alreadyHeldIds.has(eventId) || seenClaimIds.has(eventId))
453
+ return false;
454
+ seenClaimIds.add(eventId);
455
+ return true;
456
+ });
457
+ if (uniqueLines.length <= maxLines) {
458
+ appendHeldLinesOnce(meshRoot, uniqueLines);
452
459
  removeClaimFile(claim.claimPath);
453
460
  return 0;
454
461
  }
455
- const overflow = claim.lines.length - maxLines;
462
+ const overflow = uniqueLines.length - maxLines;
456
463
  // Oldest by heldAt: retries regroup held by session, so file order is not
457
464
  // chronological. Unparseable lines sort first (dead-lettered first).
458
- const ordered = claim.lines
465
+ const ordered = uniqueLines
459
466
  .map((line, idx) => ({ line, idx, atMs: heldAtMs(line) }))
460
467
  .sort((a, b) => a.atMs - b.atMs || a.idx - b.idx);
461
468
  const toDead = ordered.slice(0, overflow);
@@ -475,7 +482,7 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
475
482
  }
476
483
  summary.deadLettered += 1;
477
484
  }
478
- appendHeldLines(meshRoot, toKeep);
485
+ appendHeldLinesOnce(meshRoot, toKeep);
479
486
  summary.heldOverflow = overflow;
480
487
  removeClaimFile(claim.claimPath);
481
488
  return overflow;
@@ -496,12 +503,25 @@ function heldAtMs(line) {
496
503
  const ms = Date.parse(heldAt);
497
504
  return Number.isFinite(ms) ? ms : 0;
498
505
  }
499
- function appendHeldLines(meshRoot, lines) {
500
- if (lines.length === 0)
501
- return;
502
- const heldPath = workMeshHeldPath(meshRoot);
503
- ensureSpoolFile(heldPath);
504
- fs.appendFileSync(heldPath, `${lines.join("\n")}\n`, { mode: 0o600 });
506
+ function appendHeldLinesOnce(meshRoot, lines) {
507
+ appendHeldLinesIfAbsent(lines, meshRoot);
508
+ }
509
+ function heldEventIdOfLine(line) {
510
+ const parsed = parseLine(line);
511
+ return parsed ? eventIdOf(unwrapHeld(parsed).event) : null;
512
+ }
513
+ function readHeldEventIds(filePath) {
514
+ if (!fs.existsSync(filePath))
515
+ return new Set();
516
+ const ids = new Set();
517
+ for (const line of fs.readFileSync(filePath, "utf8").split("\n")) {
518
+ if (!line.trim())
519
+ continue;
520
+ const id = heldEventIdOfLine(line);
521
+ if (id)
522
+ ids.add(id);
523
+ }
524
+ return ids;
505
525
  }
506
526
  /**
507
527
  * Account a 2xx batch: dead-letter rejected eventIds with their codes;
@@ -19,6 +19,10 @@ export declare function ensureSpoolFile(filePath: string): void;
19
19
  export declare function appendJsonlLine(filePath: string, line: string): void;
20
20
  export declare function appendSpoolLine(line: string, root?: string): string;
21
21
  export declare function appendHeldLine(line: string, root?: string): string;
22
+ /** Append a held envelope only when its eventId is not already held. */
23
+ export declare function appendHeldLineIfAbsent(line: string, root?: string): boolean;
24
+ /** Append a deduplicated held batch under one file lock and one write. */
25
+ export declare function appendHeldLinesIfAbsent(lines: string[], root?: string): number;
22
26
  export declare function appendDeadLetterLine(line: string, root?: string): string;
23
27
  export declare function appendDroppedReceipt(receipt: Record<string, unknown>, root?: string): string;
24
28
  export interface ClaimResult {
@@ -49,6 +53,8 @@ export declare function recoverOrphanClaims(root: string): {
49
53
  export declare function removeClaimFile(claimPath: string | null): void;
50
54
  export declare function deadLetterNonEmpty(root?: string): boolean;
51
55
  export declare function countJsonlLines(filePath: string): number;
56
+ /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
57
+ export declare function countDistinctHeldEvents(filePath: string): number;
52
58
  /** Local queues that still lack a usable company attribution. */
53
59
  export interface UnattributedEventCounts {
54
60
  spool: number;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
+ import { randomBytes } from "node:crypto";
8
9
  import { ensureOwnerDir } from "../../work-context/atomic.js";
9
10
  import { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
10
11
  /** Linux PIPE_BUF is typically 4096; keep a conservative ceiling for one line. */
@@ -65,12 +66,160 @@ export function appendSpoolLine(line, root) {
65
66
  }
66
67
  export function appendHeldLine(line, root) {
67
68
  const held = workMeshHeldPath(root ?? workMeshRoot());
68
- appendJsonlLine(held, line);
69
+ appendHeldLineIfAbsent(line, root);
69
70
  return held;
70
71
  }
72
+ /** Append a held envelope only when its eventId is not already held. */
73
+ export function appendHeldLineIfAbsent(line, root) {
74
+ return appendHeldLinesIfAbsent([line], root) === 1;
75
+ }
76
+ /** Append a deduplicated held batch under one file lock and one write. */
77
+ export function appendHeldLinesIfAbsent(lines, root) {
78
+ const heldPath = workMeshHeldPath(root ?? workMeshRoot());
79
+ ensureSpoolFile(heldPath);
80
+ return withJsonlLock(heldPath, () => {
81
+ const seen = readEventIds(heldPath);
82
+ const append = [];
83
+ for (const line of lines) {
84
+ const eventId = heldEventId(line);
85
+ if (eventId) {
86
+ if (seen.has(eventId))
87
+ continue;
88
+ seen.add(eventId);
89
+ }
90
+ append.push(line);
91
+ }
92
+ appendJsonlLines(heldPath, append);
93
+ return append.length;
94
+ });
95
+ }
96
+ function heldEventId(line) {
97
+ try {
98
+ const parsed = JSON.parse(line);
99
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
100
+ return null;
101
+ const record = parsed;
102
+ const event = record.event && typeof record.event === "object" && !Array.isArray(record.event)
103
+ ? record.event
104
+ : record;
105
+ return typeof event.eventId === "string" && event.eventId.trim() ? event.eventId.trim() : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function readEventIds(filePath) {
112
+ const ids = new Set();
113
+ let raw;
114
+ try {
115
+ raw = fs.readFileSync(filePath, "utf8");
116
+ }
117
+ catch (error) {
118
+ if (error.code === "ENOENT")
119
+ return ids;
120
+ throw error;
121
+ }
122
+ for (const line of raw.split("\n")) {
123
+ if (!line.trim())
124
+ continue;
125
+ const eventId = heldEventId(line);
126
+ if (eventId)
127
+ ids.add(eventId);
128
+ }
129
+ return ids;
130
+ }
131
+ function appendJsonlLines(filePath, lines) {
132
+ if (lines.length === 0)
133
+ return;
134
+ ensureSpoolFile(filePath);
135
+ fs.appendFileSync(filePath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
136
+ chmodOwnerFile(filePath);
137
+ }
138
+ const JSONL_LOCK_STALE_MS = 5 * 60_000;
139
+ const JSONL_LOCK_TIMEOUT_MS = 5_000;
140
+ /** Serialize check-and-append with claims; stale locks expire after five minutes. */
141
+ function withJsonlLock(filePath, fn) {
142
+ const lockPath = `${filePath}.lock`;
143
+ const token = randomBytes(12).toString("hex");
144
+ const deadline = Date.now() + JSONL_LOCK_TIMEOUT_MS;
145
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
146
+ while (true) {
147
+ try {
148
+ const fd = fs.openSync(lockPath, "wx", 0o600);
149
+ try {
150
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() }));
151
+ }
152
+ finally {
153
+ fs.closeSync(fd);
154
+ }
155
+ break;
156
+ }
157
+ catch (error) {
158
+ if (error.code !== "EEXIST")
159
+ throw error;
160
+ let stale = false;
161
+ try {
162
+ const before = fs.statSync(lockPath);
163
+ stale = Date.now() - before.mtimeMs >= JSONL_LOCK_STALE_MS;
164
+ if (stale) {
165
+ const after = fs.statSync(lockPath);
166
+ if (before.dev === after.dev && before.ino === after.ino && after.mtimeMs === before.mtimeMs) {
167
+ fs.unlinkSync(lockPath);
168
+ }
169
+ else {
170
+ stale = false;
171
+ }
172
+ }
173
+ }
174
+ catch (statError) {
175
+ if (statError.code !== "ENOENT")
176
+ throw statError;
177
+ }
178
+ if (stale)
179
+ continue;
180
+ if (Date.now() >= deadline)
181
+ throw new SpoolWriteError(`Timed out waiting for ${path.basename(filePath)} lock`);
182
+ Atomics.wait(waitCell, 0, 0, 10);
183
+ }
184
+ }
185
+ let result;
186
+ let operationError;
187
+ let operationFailed = false;
188
+ try {
189
+ result = fn();
190
+ }
191
+ catch (error) {
192
+ operationError = error;
193
+ operationFailed = true;
194
+ }
195
+ let cleanupError;
196
+ try {
197
+ const record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
198
+ if (record.token === token)
199
+ fs.unlinkSync(lockPath);
200
+ }
201
+ catch (error) {
202
+ if (error.code !== "ENOENT")
203
+ cleanupError = error;
204
+ }
205
+ if (operationFailed && cleanupError !== undefined) {
206
+ throw new AggregateError([operationError, cleanupError], "JSONL write and lock cleanup failed");
207
+ }
208
+ if (operationFailed)
209
+ throw operationError;
210
+ if (cleanupError !== undefined)
211
+ throw cleanupError;
212
+ return result;
213
+ }
71
214
  export function appendDeadLetterLine(line, root) {
72
215
  const dl = workMeshDeadLetterPath(root ?? workMeshRoot());
73
- appendJsonlLine(dl, line);
216
+ withJsonlLock(dl, () => {
217
+ ensureSpoolFile(dl);
218
+ const ids = readEventIds(dl);
219
+ const eventId = heldEventId(line);
220
+ if (!eventId || !ids.has(eventId))
221
+ appendJsonlLine(dl, line);
222
+ });
74
223
  return dl;
75
224
  }
76
225
  export function appendDroppedReceipt(receipt, root) {
@@ -108,7 +257,8 @@ export function claimSpool(root, ts = `${Date.now()}`) {
108
257
  }
109
258
  export function claimHeld(root, ts = `${Date.now()}`) {
110
259
  ensureOwnerDir(root);
111
- return claimJsonlByRename(workMeshHeldPath(root), workMeshClaimPath("held", ts, root));
260
+ const heldPath = workMeshHeldPath(root);
261
+ return withJsonlLock(heldPath, () => claimJsonlByRename(heldPath, workMeshClaimPath("held", ts, root)));
112
262
  }
113
263
  /** Match leftover claim files: spool.<ts>.claimed / held.<ts>.claimed (and held.<ts>.held.claimed). */
114
264
  const ORPHAN_CLAIM_RE = /^(spool|held)\..+\.claimed$/;
@@ -195,6 +345,31 @@ export function countJsonlLines(filePath) {
195
345
  }
196
346
  return n;
197
347
  }
348
+ /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
349
+ export function countDistinctHeldEvents(filePath) {
350
+ let raw;
351
+ try {
352
+ raw = fs.readFileSync(filePath, "utf8");
353
+ }
354
+ catch {
355
+ // A concurrent claim can rename held.jsonl between existence and read checks.
356
+ return 0;
357
+ }
358
+ const seen = new Set();
359
+ let count = 0;
360
+ for (const line of raw.split("\n")) {
361
+ if (!line.trim())
362
+ continue;
363
+ const eventId = heldEventId(line);
364
+ if (eventId) {
365
+ if (seen.has(eventId))
366
+ continue;
367
+ seen.add(eventId);
368
+ }
369
+ count += 1;
370
+ }
371
+ return count;
372
+ }
198
373
  function record(value) {
199
374
  return value && typeof value === "object" && !Array.isArray(value)
200
375
  ? value
@@ -258,8 +433,32 @@ function countUnattributedJsonlLines(filePath) {
258
433
  export function countUnattributedEvents(root) {
259
434
  const meshRoot = root ?? workMeshRoot();
260
435
  const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
261
- const held = countUnattributedJsonlLines(workMeshHeldPath(meshRoot));
436
+ const held = countUnattributedHeldEvents(workMeshHeldPath(meshRoot));
262
437
  const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
263
438
  return { spool, held, deadLetter, total: spool + held + deadLetter };
264
439
  }
440
+ function countUnattributedHeldEvents(filePath) {
441
+ let raw;
442
+ try {
443
+ raw = fs.readFileSync(filePath, "utf8");
444
+ }
445
+ catch {
446
+ // A concurrent held claim may rename this file between the read and open.
447
+ return 0;
448
+ }
449
+ const seen = new Set();
450
+ let count = 0;
451
+ for (const line of raw.split("\n")) {
452
+ if (!line.trim() || !isUnattributedLine(line))
453
+ continue;
454
+ const eventId = heldEventId(line);
455
+ if (eventId) {
456
+ if (seen.has(eventId))
457
+ continue;
458
+ seen.add(eventId);
459
+ }
460
+ count += 1;
461
+ }
462
+ return count;
463
+ }
265
464
  //# sourceMappingURL=spool.js.map
@@ -57,6 +57,17 @@ function readJson(filePath) {
57
57
  return null;
58
58
  }
59
59
  }
60
+ function isInactiveProject(hqRoot, project) {
61
+ if (!hqRoot || !project.prdPath)
62
+ return false;
63
+ const state = readJson(path.join(hqRoot, "workspace", "orchestrator", project.projectId, "state.json"));
64
+ const status = typeof state?.status === "string" ? state.status.trim().toLowerCase() : "";
65
+ if (status !== "archived" && status !== "retired")
66
+ return false;
67
+ if (typeof state?.prd_path !== "string" || !state.prd_path.trim())
68
+ return false;
69
+ return path.resolve(hqRoot, state.prd_path) === path.resolve(project.prdPath);
70
+ }
60
71
  function reposFromMetadata(metadata) {
61
72
  if (!metadata || typeof metadata !== "object")
62
73
  return [];
@@ -225,7 +236,8 @@ export function refineProjectBinding(input) {
225
236
  const hint = input.hint?.trim();
226
237
  if (!hint)
227
238
  return { action: "keep" };
228
- const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now());
239
+ const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
240
+ .filter((project) => !isInactiveProject(input.hqRoot?.trim(), project));
229
241
  const exact = catalog.find((row) => row.projectId === hint);
230
242
  if (exact?.hasBoard) {
231
243
  return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
package/dist/main.js CHANGED
@@ -38,7 +38,7 @@ import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
38
38
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
39
39
  import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
40
40
  import { upgradeToTeam } from "./utils/team-upgrade.js";
41
- import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
41
+ import { printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
42
42
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
43
43
  import { autoUpdateAndReexec } from "./utils/self-update.js";
44
44
  import { CLI_VERSION } from "./cli-version.js";
@@ -161,7 +161,8 @@ const program = new Command();
161
161
  program
162
162
  .name("hq")
163
163
  .description("HQ management CLI — modules, packages, and cloud sync")
164
- .version(CLI_VERSION);
164
+ .version(CLI_VERSION)
165
+ .option("--verbose", "Show verbose diagnostics, including SDK runtime warnings");
165
166
  program.hook("preAction", async () => {
166
167
  // A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
167
168
  // every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
@@ -206,6 +207,7 @@ export async function runCli() {
206
207
  // already did the work, so this process only has to carry its exit status
207
208
  // out (after the finally block's telemetry, hence not process.exit here).
208
209
  let reexecStatus = null;
210
+ let versionNudgeHandled = false;
209
211
  try {
210
212
  runGlobalNetworkLifecycle(process.argv, () => Sentry.addBreadcrumb({
211
213
  category: "command",
@@ -248,6 +250,15 @@ export async function runCli() {
248
250
  });
249
251
  if (versionAction === "reexec")
250
252
  return;
253
+ // Commander exits immediately for its built-in --version action, before a
254
+ // nudge can be appended. Handle the global version-only form here so the
255
+ // installed semver stays the first stdout line and optional guidance follows.
256
+ if (process.argv.length === 3 &&
257
+ ["--version", "-V"].includes(process.argv[2] ?? "")) {
258
+ versionNudgeHandled = true;
259
+ await printVersionAndNudge();
260
+ return;
261
+ }
251
262
  // Register every contributor for the selected root only. Root help and
252
263
  // unknown-command recovery use generated metadata, so they keep the full
253
264
  // Commander surface without evaluating every command implementation.
@@ -298,7 +309,9 @@ export async function runCli() {
298
309
  const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
299
310
  const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
300
311
  await settleWithin([
301
- refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
312
+ ...(versionNudgeHandled
313
+ ? []
314
+ : [refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs })]),
302
315
  Sentry.flush(releaseHealthTimeoutMs),
303
316
  ...firstSyncOutcomeFlushes,
304
317
  ...inviteTeammateExposureFlushes,
@@ -150,9 +150,15 @@ export async function registerCommandsWithRecovery(args) {
150
150
  // writer held the shared lock past its own ceiling — report 'lock-held'
151
151
  // (its remedy tells the user to let the update finish, never to reinstall
152
152
  // over it). Otherwise the tree genuinely never settled with no writer
153
- // visible. The settle probe only uses `stale_tree` when it could observe
154
- // an unchanged tree with no active writer or retired sibling.
155
- outcome: result.lockHeldAtEnd ? "lock-held" : result.staleTree ? "stale_tree" : "unsettled",
153
+ // visible. A partial retired-sibling scan gets its own outcome so we do
154
+ // not claim that an updater is absent when part of its tree was unreadable.
155
+ outcome: result.lockHeldAtEnd
156
+ ? "lock-held"
157
+ : result.retiredSiblingUnverified
158
+ ? "retired_unverified"
159
+ : result.staleTree
160
+ ? "stale_tree"
161
+ : "unsettled",
156
162
  treeState,
157
163
  attempt: 1,
158
164
  waitedMs: result.waitedMs,
@@ -274,6 +274,8 @@ export interface WaitForInstallTreeSettledResult {
274
274
  staleTree?: boolean;
275
275
  /** Presence on the final poll; null means the sibling directory was unreadable. */
276
276
  retiredSiblingPresent?: boolean | null;
277
+ /** A stable partial retired-sibling scan ended the wait without claiming the tree is stale. */
278
+ retiredSiblingUnverified?: boolean;
277
279
  }
278
280
  export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
279
281
  /**
@@ -288,11 +290,14 @@ export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
288
290
  * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
289
291
  * reinstall look stale:
290
292
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
291
- * by `deadlineMs`. If the target is still missing and the observed tree,
292
- * including retired sibling names and mtimes, stays unchanged for
293
- * `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
294
- * change keeps the ordinary settle deadline in force. `deadlineMs === 0`
295
- * evaluates readiness once.
293
+ * by `deadlineMs`. If the target is still missing and the observable tree
294
+ * signature stays unchanged for `staleProbeMs`, the wait returns `staleTree`
295
+ * early. When only a named retired npm sibling's deep scan is incomplete,
296
+ * a stable partial signature ends the wait early as `retiredSiblingUnverified`
297
+ * instead. That outcome preserves uncertainty and tells the user to wait for
298
+ * any installer before attempting a repair. Any observed signature change
299
+ * keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
300
+ * readiness once.
296
301
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
297
302
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
298
303
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -331,7 +336,7 @@ export interface InstallRewriteSignalArgs {
331
336
  */
332
337
  export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
333
338
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
334
- export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree";
339
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree" | "retired_unverified";
335
340
  export type InstallTreeState = "in_flight" | "stale";
336
341
  export type InstallTreeTornDiagnostics = {
337
342
  dialect: ModuleErrorDialect;
@@ -408,6 +413,8 @@ export declare function scrubHomeDirectoryPrefix(value: string, homeDirectory?:
408
413
  export declare const INSTALL_TREE_TORN_REMEDY: string;
409
414
  /** Fixed repair guidance for an unchanged tree with no active updater signal. */
410
415
  export declare const INSTALL_TREE_STALE_REMEDY: string;
416
+ /** Guidance when a retired package sibling's activity could not be verified. */
417
+ export declare const INSTALL_TREE_RETIRED_UNVERIFIED_REMEDY: string;
411
418
  /**
412
419
  * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
413
420
  * held the shared update lock past its own staleness ceiling. Telling the user to