@indigoai-us/hq-cli 5.103.11 → 5.103.13

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.13] — 2026-08-20
6
+
7
+ ### Fixed
8
+
9
+ - The bundled checkpoint stop-gate now *enforces* the user-facing reply
10
+ instead of only asking for it: a satisfying checkpoint on a work turn whose
11
+ genuine reply is under `HQ_CHECKPOINT_REPLY_MIN` non-whitespace characters
12
+ (default 80) blocks once with a dedicated reply demand, stamped per
13
+ checkpoint tool id and counted against the shared 3-consecutive-blocks loop
14
+ guard, so it can never strand a session. The reply window accepts text on
15
+ either side of the checkpoint and crosses the gate's own block feedback, so
16
+ an agent that already replied is never told to repeat itself — composing
17
+ with the reply-aware block variants — while mid-turn notes written before
18
+ the last work tool do not count. Flag exemptions are parsed robustly:
19
+ `--gate-probe` / `--idle` inside a quoted `--summary` value are data, not
20
+ flags, and a compound command ending in a checkpoint keeps owing the reply.
21
+ The `--idle` path of `hq core checkpoint` prints a conditional reminder
22
+ matching the gate's idle-only exemption.
23
+
24
+ ## [5.103.12] — 2026-08-20
25
+
5
26
  ## [5.103.11] — 2026-08-20
6
27
 
7
28
  ### Reverted
@@ -232,65 +232,105 @@ set -uo pipefail
232
232
  end;
233
233
  def checkpoint_command:
234
234
  test("(^|[;&|(\\s])(command\\s+)?([A-Za-z_][A-Za-z0-9_]*=[^\\s]*\\s+)*hq\\s+core\\s+checkpoint(\\s|$)");
235
+ def strip_quoted:
236
+ # Remove quoted spans so flag text inside a --summary value (e.g.
237
+ # --summary "investigated --gate-probe behavior") is never mistaken for
238
+ # an active flag. Double-quoted spans honor backslash escapes. The
239
+ # u0027 escape is a single-quote character, written that way because
240
+ # this whole program lives inside a single-quoted shell string.
241
+ gsub("\"(\\\\.|[^\"\\\\])*\""; "")
242
+ | gsub("\u0027[^\u0027]*\u0027"; "");
243
+ def checkpoint_flag($flag):
244
+ strip_quoted | test("(^|\\s)" + $flag + "(\\s|$)");
245
+ def pure_checkpoint:
246
+ # The command is ONLY a checkpoint invocation (optional env-assignment
247
+ # prefix, no other command joined by a separator). A compound call like
248
+ # `git status; hq core checkpoint --idle` did other work in the same
249
+ # tool call, so its exemptions must not apply.
250
+ strip_quoted
251
+ | test("^\\s*(command\\s+)?([A-Za-z_][A-Za-z0-9_]*=[^\\s]*\\s+)*hq\\s+core\\s+checkpoint([\\s][^;&|\n]*)?$");
252
+ def gate_feedback_user:
253
+ # This gate surfaces its own Stop feedback as a synthetic user row. That
254
+ # row is a turn boundary for the checkpoint requirement, but NOT for
255
+ # deciding whether the USER has been answered: a reply the agent wrote
256
+ # before the block is already on screen and must never be demanded again
257
+ # (double-messaging).
258
+ .type == "user"
259
+ and ((.message.content? | type) == "string")
260
+ and (.message.content | startswith("Stop hook feedback:"));
235
261
  [split("\n")[] | select(length > 0) | {line: ., entry: (fromjson)}] as $rows
236
262
  | [range(0; $rows | length) | select($rows[.].entry | real_user)] as $user_indexes
237
263
  | if ($user_indexes | length) == 0 then error("no real user entry") else
238
264
  $user_indexes[-1] as $user_index
239
- | $rows[($user_index + 1):] as $turn
265
+ | ([$user_indexes[] | select(($rows[.].entry | gate_feedback_user) | not)]
266
+ | if length > 0 then .[-1] else $user_indexes[0] end) as $genuine_index
240
267
  | [
241
- range(0; $turn | length) as $ti
242
- | $turn[$ti].entry
268
+ range($genuine_index + 1; $rows | length) as $ri
269
+ | $rows[$ri].entry
243
270
  | if .type == "assistant" then
244
271
  .message.content?
245
272
  | if type == "array" then .[]? else empty end
246
- | select(.type == "tool_use")
247
- | {
248
- i: $ti,
249
- runtime: "claude",
250
- id: (.id? // ""),
251
- name: (.name? // ""),
252
- command: (.input.command? // "")
253
- }
273
+ | if .type == "tool_use" then
274
+ {
275
+ row: $ri,
276
+ kind: "tool",
277
+ runtime: "claude",
278
+ id: (.id? // ""),
279
+ name: (.name? // ""),
280
+ command: (.input.command? // "")
281
+ }
282
+ elif .type == "text" then
283
+ # Whitespace-stripped so blank filler can never pass for a
284
+ # reply.
285
+ {row: $ri, kind: "text",
286
+ chars: ((.text? // "") | gsub("\\s+"; "") | length)}
287
+ else empty
288
+ end
254
289
  elif (
255
290
  .type == "response_item"
256
291
  and (.payload.type? == "custom_tool_call" or .payload.type? == "function_call")
257
292
  ) then
258
293
  .payload
259
294
  | {
260
- i: $ti,
295
+ row: $ri,
296
+ kind: "tool",
261
297
  runtime: "codex",
262
298
  id: (.call_id? // .id? // ""),
263
299
  name: (.name? // ""),
264
300
  command: codex_command
265
301
  }
302
+ elif (
303
+ .type == "response_item"
304
+ and .payload.type? == "message"
305
+ and .payload.role? == "assistant"
306
+ and ((.payload.content? | type) == "array")
307
+ ) then
308
+ {row: $ri, kind: "text",
309
+ chars: ([.payload.content[]? | (.text? // "")] | join("")
310
+ | gsub("\\s+"; "") | length)}
266
311
  else empty
267
312
  end
268
- ] as $tools
313
+ ] as $events
314
+ | [$events[] | select(.row > $user_index)] as $seg_events
315
+ | [$seg_events[] | select(.kind == "tool")] as $tools
269
316
  | (if ($tools | length) > 0 then $tools[-1] else {} end) as $last_tool
270
- # A user-visible assistant reply strictly AFTER the turn-s last tool call
271
- # means the user has already been answered, so the block reason must
272
- # forbid a restatement instead of demanding one. Text earlier in the turn
273
- # (status notes between tool calls) does not count as the reply.
274
- | (if ($tools | length) > 0 then $tools[-1].i else -1 end) as $last_tool_i
275
- | ([
276
- $turn[($last_tool_i + 1):][]
277
- | .entry
278
- | if .type == "assistant" then
279
- .message.content?
280
- | if type == "string" then .
281
- elif type == "array" then ([.[]? | select(.type == "text") | (.text? // "")] | join(""))
282
- else ""
283
- end
284
- elif (
285
- .type == "response_item"
286
- and .payload.type? == "message"
287
- and .payload.role? == "assistant"
288
- and ((.payload.content? | type) == "array")
289
- ) then
290
- ([.payload.content[]? | (.text? // "")] | join(""))
291
- else ""
292
- end
293
- ] | join("") | test("\\S")) as $replied
317
+ # Reply visible after THIS segment last tool call: chooses which block
318
+ # variant an unsatisfied turn gets (write your reply vs do not repeat
319
+ # it). Mid-turn notes between tool calls do not count.
320
+ | ([range(0; $seg_events | length) | select($seg_events[.].kind == "tool")]
321
+ | if length > 0 then .[-1] else -1 end) as $seg_last_tool_index
322
+ | ([$seg_events[($seg_last_tool_index + 1):][] | select(.kind == "text") | .chars]
323
+ | add // 0) as $seg_reply_chars
324
+ # Reply owed by the WHOLE genuine turn: all assistant text after its
325
+ # last non-checkpoint tool call, crossing this gate feedback
326
+ # boundaries. An agent that replied and then checkpointed (in either
327
+ # order across a block) is never told to write the reply again.
328
+ | ([range(0; $events | length)
329
+ | select(($events[.].kind == "tool")
330
+ and (($events[.].command | checkpoint_command) | not))]
331
+ | if length > 0 then .[-1] else -1 end) as $last_work_index
332
+ | ([$events[($last_work_index + 1):][] | select(.kind == "text") | .chars]
333
+ | add // 0) as $reply_chars
294
334
  | ([$tools[] | select((.command | checkpoint_command) | not)] | length) as $work_tool_count
295
335
  | ([
296
336
  $rows[]
@@ -305,18 +345,50 @@ set -uo pipefail
305
345
  ($tools | length) > 0
306
346
  and ($last_tool.command | checkpoint_command)
307
347
  ) then
308
- if $last_tool.runtime == "claude" and ($last_result_error | not) then "1"
348
+ if $last_tool.runtime == "claude" and ($last_result_error | not) then
349
+ # A reply is owed after a real (--summary) checkpoint always, and
350
+ # after an --idle checkpoint only when the turn ran other tools
351
+ # (a read-only investigation still owes its findings). A
352
+ # --gate-probe is pure bookkeeping and never owes one. Both
353
+ # exemptions apply only to a PURE checkpoint invocation, with the
354
+ # flags read outside quoted text — a compound command did other
355
+ # work in the same call, and flag text inside a --summary value
356
+ # is data, not a flag.
357
+ (
358
+ (($last_tool.command | pure_checkpoint) | not)
359
+ or (
360
+ (($last_tool.command | checkpoint_flag("--gate-probe")) | not)
361
+ and (
362
+ (($last_tool.command | checkpoint_flag("--idle")) | not)
363
+ or ($work_tool_count > 0)
364
+ )
365
+ )
366
+ ) as $reply_required
367
+ | "1\t\($last_tool.id)\t\($reply_chars)\t\(if $reply_required then 1 else 0 end)"
309
368
  elif $last_tool.runtime == "codex" then "stamp"
310
- else (if $replied then "0-replied" else "0-unreplied" end)
369
+ else (if $seg_reply_chars > 0 then "0-replied" else "0-unreplied" end)
311
370
  end
312
371
  elif $work_tool_count == 0 then "idle"
313
- else (if $replied then "0-replied" else "0-unreplied" end)
372
+ else (if $seg_reply_chars > 0 then "0-replied" else "0-unreplied" end)
314
373
  end
315
374
  end
316
375
  ' 2>/dev/null)" || exit 0
376
+ # A Claude success verdict carries the satisfying checkpoint's tool id, the
377
+ # count of non-whitespace assistant text characters delivered after the
378
+ # genuine turn's last work tool (its user-facing reply, wherever it sits
379
+ # relative to the checkpoint), and whether this checkpoint shape owes the
380
+ # user a reply at all. An unsatisfied verdict says whether a reply is
381
+ # already visible, so the block message never demands a duplicate.
317
382
  satisfied="$parsed"
318
383
  replied=0
319
- case "$satisfied" in
384
+ reply_tool_id=""
385
+ reply_chars=""
386
+ reply_required=""
387
+ gate_tab="$(printf '\t')"
388
+ case "$parsed" in
389
+ "1${gate_tab}"*)
390
+ IFS="$gate_tab" read -r satisfied reply_tool_id reply_chars reply_required <<<"$parsed"
391
+ ;;
320
392
  0-replied) satisfied=0; replied=1 ;;
321
393
  0-unreplied) satisfied=0 ;;
322
394
  esac
@@ -350,49 +422,95 @@ set -uo pipefail
350
422
  # there is nothing to record. Demanding one anyway turns every conversational
351
423
  # reply — and every background-notification wake-up — into an --idle round
352
424
  # trip that writes no state and only costs a turn.
425
+ # Consecutive-block counter, shared by every block this gate can emit (the
426
+ # missing-checkpoint demand and the reply demand alike). A session whose
427
+ # checkpoint command keeps FAILING (a broken or mid-self-update `hq` binary)
428
+ # would otherwise be re-prompted on every Stop, forever. Past the cap the
429
+ # gate fails open per this file's never-strand-a-session doctrine; any Stop
430
+ # that ends in an allow resets it.
431
+ block_count_file="$state_dir/checkpoint-block-count-$session_key"
432
+ block_count="$(tr -d '\r\n' <"$block_count_file" 2>/dev/null || true)"
433
+ case "$block_count" in
434
+ ''|*[!0-9]*) block_count=0 ;;
435
+ esac
436
+ bump_block_count() {
437
+ block_count_tmp="$block_count_file.$$"
438
+ if (umask 077 && printf '%s' "$((block_count + 1))" >"$block_count_tmp" && mv -f "$block_count_tmp" "$block_count_file"); then
439
+ :
440
+ else
441
+ rm -f "$block_count_tmp" 2>/dev/null || true
442
+ fi
443
+ }
444
+
353
445
  if [ "$satisfied" = "1" ] || [ "$satisfied" = "idle" ]; then
354
446
  if [ "$runtime" = "codex" ]; then
355
447
  rm -f "$state_dir/codex-checkpoint-reprompt-$session_key" 2>/dev/null || true
356
448
  fi
357
- rm -f "$state_dir/checkpoint-block-count-$session_key" 2>/dev/null || true
449
+
450
+ # Reply-owed enforcement (Claude runtime only). The checkpoint payload
451
+ # goes to the background sibling, never the user. A genuine turn that did
452
+ # work and reaches a satisfying checkpoint with (next to) no assistant
453
+ # text after its last WORK tool call is a turn whose user-facing reply is
454
+ # missing, however rich the checkpoint flags are: block once and demand
455
+ # the reply. The window deliberately crosses this gate's own feedback
456
+ # boundaries and accepts a reply on either side of the checkpoint, so an
457
+ # agent that replied and then checkpointed is never told to repeat itself
458
+ # (double-messaging is the failure the reply-aware block variants exist
459
+ # to prevent). Evidence (transcript audit 2026-08-19, 1471 stop-gate
460
+ # checkpoint turns): 2.7% ended with no reply anywhere while the substance
461
+ # lived only in the checkpoint payload. The nudge fires at most once per
462
+ # checkpoint tool id (stamped in hook-state), so a legitimately terse turn
463
+ # costs exactly one extra Stop round-trip and the gate can never loop.
464
+ # Codex is excluded: its Stop feedback is a synthetic re-prompt with its
465
+ # own delivery contract. HQ_CHECKPOINT_REPLY_MIN overrides the minimum
466
+ # non-whitespace character threshold; 0 disables the enforcement.
467
+ if [ "$satisfied" = "1" ] && [ "$runtime" = "claude" ] \
468
+ && [ "$reply_required" = "1" ] && [ -n "$reply_tool_id" ]; then
469
+ reply_min="${HQ_CHECKPOINT_REPLY_MIN:-80}"
470
+ case "$reply_min" in ''|*[!0-9]*) reply_min=80 ;; esac
471
+ case "$reply_chars" in ''|*[!0-9]*) reply_chars=0 ;; esac
472
+ if [ "$reply_min" -gt 0 ] && [ "$reply_chars" -lt "$reply_min" ] && [ "$block_count" -lt 3 ]; then
473
+ nudge_file="$state_dir/checkpoint-reply-nudge-$session_key"
474
+ nudge_prev="$(cat "$nudge_file" 2>/dev/null || true)"
475
+ if [ "$nudge_prev" != "$reply_tool_id" ]; then
476
+ nudge_tmp="$nudge_file.$$"
477
+ if (umask 077 && printf '%s' "$reply_tool_id" >"$nudge_tmp" && mv -f "$nudge_tmp" "$nudge_file"); then
478
+ bump_block_count
479
+ reply_reason='Checkpoint recorded — but the turn cannot end yet: the user has not received a reply this turn. The checkpoint payload went to a background maintenance agent; the user sees NONE of it, and nothing you have written since your last real action reads as a reply to them. Deliver your complete reply to the user now, as ordinary text: every result, finding, link, decision, and anything awaiting their input, in plain language. Do not run another checkpoint and do not call any other tool — end the turn with text only.'
480
+ printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reply_reason" | hq_json_encode)"
481
+ exit 0
482
+ fi
483
+ rm -f "$nudge_tmp" 2>/dev/null || true
484
+ fi
485
+ fi
486
+ fi
487
+ # This Stop ends in an allow: the session is not stuck, so the
488
+ # consecutive-block counter starts over.
489
+ rm -f "$block_count_file" 2>/dev/null || true
358
490
  exit 0
359
491
  fi
360
492
 
361
- # Loop guard: a session whose checkpoint command keeps FAILING (a broken or
362
- # mid-self-update `hq` binary) would otherwise be re-prompted on every Stop,
363
- # forever — block → retry checkpoint → error → block again. Cap consecutive
364
- # blocks per session; past the cap, fail open per this file's
365
- # never-strand-a-session doctrine. Any successful checkpoint or idle turn
366
- # resets the counter above.
367
- block_count_file="$state_dir/checkpoint-block-count-$session_key"
368
- block_count="$(tr -d '\r\n' <"$block_count_file" 2>/dev/null || true)"
369
- case "$block_count" in
370
- ''|*[!0-9]*) block_count=0 ;;
371
- esac
493
+ # Loop guard for the missing-checkpoint demand below: cap consecutive blocks
494
+ # per session, then fail open (see the counter's comment above).
372
495
  if [ "$block_count" -ge 3 ]; then
373
496
  exit 0
374
497
  fi
375
- block_count_tmp="$block_count_file.$$"
376
- if (umask 077 && printf '%s' "$((block_count + 1))" >"$block_count_tmp" && mv -f "$block_count_tmp" "$block_count_file"); then
377
- :
378
- else
379
- rm -f "$block_count_tmp" 2>/dev/null || true
380
- fi
498
+ bump_block_count
381
499
 
382
500
  # Built with printf rather than concatenation so the session id can appear in
383
501
  # both commands without re-splitting the message into fragments.
384
502
  #
385
503
  # Two variants, chosen from the transcript rather than left to the agent's
386
- # judgment: the historical single instruction either demanded a full
387
- # restatement (double-messaging hosts that render pre-tool text the CLI and
388
- # the desktop app both do) or forbade post-checkpoint text (hiding the reply
389
- # when the agent had not yet written one). The transcript already tells us
390
- # which case we are in, so say exactly one thing.
504
+ # judgment: a single instruction either demanded a full restatement
505
+ # (double-messaging hosts that render pre-tool text) or forbade
506
+ # post-checkpoint text (hiding the reply when the agent had not yet written
507
+ # one). The transcript already tells us which case we are in, so say exactly
508
+ # one thing.
391
509
  flags_spec=' hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle'
392
510
  if [ "$replied" = 1 ]; then
393
511
  reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn and end the turn immediately after it, adding no further text:\n\n'"$flags_spec" "$session_id" "$session_id")"
394
512
  else
395
- reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message.\n\n'"$flags_spec" "$session_id" "$session_id")"
513
+ reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message. The gate enforces this: a turn that ends without a user-facing reply is blocked until the reply is delivered.\n\n'"$flags_spec" "$session_id" "$session_id")"
396
514
  fi
397
515
 
398
516
  # Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
@@ -20,12 +20,21 @@ type SpawnableBackend = Exclude<Backend, "none">;
20
20
  * (both the CLI and the desktop app render pre-tool-call text), while an
21
21
  * unconditional "no commentary after" hid the reply from agents that had not
22
22
  * yet written one. The Stop gate makes the same distinction from the
23
- * transcript; this line covers proactive checkpoints the gate never sees.
24
- * Mirrors the hq-core policies
23
+ * transcript and backstops this reminder mechanically: a satisfying
24
+ * checkpoint on a work turn whose genuine reply is still missing is blocked
25
+ * once with a dedicated reply demand. This line covers proactive checkpoints
26
+ * the gate never sees. Mirrors the hq-core policies
25
27
  * `checkpoint-is-bookkeeping-not-user-communication` and
26
28
  * `checkpoint-is-not-the-user-report`.
27
29
  */
28
30
  export declare const CHECKPOINT_REPLY_REMINDER: string;
31
+ /**
32
+ * The --idle variant: an idle-only turn (nothing ran, nothing to report) is
33
+ * exempt at the Stop gate, so this text must never order a reply into
34
+ * existence — but an --idle after read-only work is NOT exempt (the gate
35
+ * demands the findings), so the conditional half still points there.
36
+ */
37
+ export declare const CHECKPOINT_IDLE_REPLY_REMINDER: string;
29
38
  /**
30
39
  * Kept in TypeScript rather than in a bundled asset: it is an instruction to
31
40
  * a locally-installed agent, not a scaffold script that should be packaged.
@@ -51,8 +51,10 @@ class CheckpointUsageError extends Error {
51
51
  * (both the CLI and the desktop app render pre-tool-call text), while an
52
52
  * unconditional "no commentary after" hid the reply from agents that had not
53
53
  * yet written one. The Stop gate makes the same distinction from the
54
- * transcript; this line covers proactive checkpoints the gate never sees.
55
- * Mirrors the hq-core policies
54
+ * transcript and backstops this reminder mechanically: a satisfying
55
+ * checkpoint on a work turn whose genuine reply is still missing is blocked
56
+ * once with a dedicated reply demand. This line covers proactive checkpoints
57
+ * the gate never sees. Mirrors the hq-core policies
56
58
  * `checkpoint-is-bookkeeping-not-user-communication` and
57
59
  * `checkpoint-is-not-the-user-report`.
58
60
  */
@@ -62,6 +64,15 @@ export const CHECKPOINT_REPLY_REMINDER = "checkpoint: REMINDER — this checkpoi
62
64
  "the user, write your user-facing reply now as the FINAL text of the turn. If you " +
63
65
  "already delivered it earlier this turn, end the turn now WITHOUT repeating it — the " +
64
66
  "user has already seen that message, and repeating it double-messages them.";
67
+ /**
68
+ * The --idle variant: an idle-only turn (nothing ran, nothing to report) is
69
+ * exempt at the Stop gate, so this text must never order a reply into
70
+ * existence — but an --idle after read-only work is NOT exempt (the gate
71
+ * demands the findings), so the conditional half still points there.
72
+ */
73
+ export const CHECKPOINT_IDLE_REPLY_REMINDER = "checkpoint: idle recorded — invisible bookkeeping; the user sees none of it. If this " +
74
+ "turn surfaced findings or answers the user has not been told, deliver them now as the " +
75
+ "turn's final text. If the turn genuinely produced nothing to report, end it.";
65
76
  function printResult(line) {
66
77
  process.stdout.write(`${line}\n`);
67
78
  }
@@ -824,8 +835,10 @@ function runCheckpoint(options, command, group) {
824
835
  writeStamps(liveRoot, resolveSessionId(options, command, readPayload(options.payload)));
825
836
  printResult("checkpoint: idle (nothing to record)");
826
837
  // An idle turn persisted nothing, but a read-only turn can still have
827
- // produced substantive findings the user has not been told about.
828
- printResult(CHECKPOINT_REPLY_REMINDER);
838
+ // produced substantive findings the user has not been told about. The
839
+ // conditional idle variant matches the Stop gate, which exempts an
840
+ // idle-only turn from the reply demand.
841
+ printResult(CHECKPOINT_IDLE_REPLY_REMINDER);
829
842
  return;
830
843
  }
831
844
  const payload = readPayload(options.payload);
@@ -104,15 +104,18 @@ export declare function buildSelfUpdatePlan(install: RunningInstall): {
104
104
  */
105
105
  export declare function runUpdateQuiet(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
106
106
  /**
107
- * Serialize self-updates across concurrent `hq` processes. Without this, a
108
- * machine running several HQ agents can fire many `npm install -g` at the same
109
- * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
110
- * exact partial-install state `cleanStalePartialInstall` exists to repair.
111
- * A caller that cannot take the lock simply skips its update: another process
112
- * is already installing the very version it wanted.
107
+ * Serialize self-updates across concurrent `hq` processes AND the hq-sync
108
+ * menubar app. Without this, a machine running several HQ agents can fire many
109
+ * `npm install -g` at the same global prefix at once, and the losers fail with
110
+ * ENOTEMPTY mid-rename — the exact partial-install state
111
+ * `cleanStalePartialInstall` exists to repair. A caller that cannot take the
112
+ * lock simply skips its update: another process is already installing the very
113
+ * version it wanted.
113
114
  *
114
- * `mkdir` is the atomic primitive (same approach as version-check's refresh
115
- * lock); a lock left behind by a killed process goes stale and is reclaimed.
115
+ * Delegates to the shared advisory lock in `update-lock.ts`
116
+ * (`$HOME/.hq/locks/cli-update.lock` a cross-repo contract also honored by
117
+ * the hq-sync Rust app), so every updater on the machine contends on ONE lock
118
+ * instead of each tool keeping its own.
116
119
  */
117
120
  export declare function acquireUpdateLock(now?: number): (() => void) | null;
118
121
  /** Injectable surface so the flow is unit-testable without network or spawns. */
@@ -57,13 +57,11 @@
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
59
  import { spawnSync } from "node:child_process";
60
- import * as fs from "node:fs";
61
- import * as os from "node:os";
62
- import * as path from "node:path";
63
60
  import semver from "semver";
64
61
  import chalk from "chalk";
65
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
66
63
  import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
+ import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
67
65
  /**
68
66
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
69
67
  * One update + one re-exec per user invocation, ever.
@@ -71,8 +69,6 @@ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, bu
71
69
  export const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
72
70
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
73
71
  const FETCH_TIMEOUT_MS = 3_000;
74
- /** A held update lock older than this is treated as abandoned (crashed owner). */
75
- const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
76
72
  /** Tail of captured package-manager stderr kept for the failure warning. */
77
73
  const DETAIL_MAX_CHARS = 400;
78
74
  /** npm `latest` for this package, or null on any failure (offline, 5xx, bad body). */
@@ -150,49 +146,23 @@ export function runUpdateQuiet(cmd, args, env) {
150
146
  output.dispose();
151
147
  }
152
148
  }
153
- function lockDir() {
154
- return path.join(os.homedir(), ".hq", "self-update.lock");
155
- }
156
149
  /**
157
- * Serialize self-updates across concurrent `hq` processes. Without this, a
158
- * machine running several HQ agents can fire many `npm install -g` at the same
159
- * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
160
- * exact partial-install state `cleanStalePartialInstall` exists to repair.
161
- * A caller that cannot take the lock simply skips its update: another process
162
- * is already installing the very version it wanted.
150
+ * Serialize self-updates across concurrent `hq` processes AND the hq-sync
151
+ * menubar app. Without this, a machine running several HQ agents can fire many
152
+ * `npm install -g` at the same global prefix at once, and the losers fail with
153
+ * ENOTEMPTY mid-rename — the exact partial-install state
154
+ * `cleanStalePartialInstall` exists to repair. A caller that cannot take the
155
+ * lock simply skips its update: another process is already installing the very
156
+ * version it wanted.
163
157
  *
164
- * `mkdir` is the atomic primitive (same approach as version-check's refresh
165
- * lock); a lock left behind by a killed process goes stale and is reclaimed.
158
+ * Delegates to the shared advisory lock in `update-lock.ts`
159
+ * (`$HOME/.hq/locks/cli-update.lock` a cross-repo contract also honored by
160
+ * the hq-sync Rust app), so every updater on the machine contends on ONE lock
161
+ * instead of each tool keeping its own.
166
162
  */
167
163
  export function acquireUpdateLock(now = Date.now()) {
168
- const dir = lockDir();
169
- const release = () => {
170
- try {
171
- fs.rmSync(dir, { recursive: true, force: true });
172
- }
173
- catch {
174
- // best-effort lock cleanup
175
- }
176
- };
177
- try {
178
- fs.mkdirSync(path.dirname(dir), { recursive: true });
179
- fs.mkdirSync(dir);
180
- return release;
181
- }
182
- catch {
183
- try {
184
- const stat = fs.statSync(dir);
185
- if (now - stat.mtimeMs > UPDATE_LOCK_STALE_MS) {
186
- fs.rmSync(dir, { recursive: true, force: true });
187
- fs.mkdirSync(dir);
188
- return release;
189
- }
190
- }
191
- catch {
192
- // lock vanished or is unreadable — treat as held and skip
193
- }
194
- return null;
195
- }
164
+ const handle = acquireSharedUpdateLock({ now, tool: "hq-cli-self-update" });
165
+ return handle ? handle.release : null;
196
166
  }
197
167
  /**
198
168
  * Re-run `hq <argv…>` from PATH so the freshly-installed version handles the
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Shared advisory cross-process lock serializing every actor that runs
3
+ * `npm install -g @indigoai-us/hq-cli` (or the pnpm/bun equivalent) against
4
+ * the same machine.
5
+ *
6
+ * Why: production logs showed FOUR independent updaters — this CLI's hard
7
+ * version-gate, the soft self-updater, and updaters inside the hq-sync menubar
8
+ * app — each firing `npm install -g` with no coordination. On a machine with a
9
+ * second "ghost" install (e.g. a pnpm-global shim shadowing the npm one) the
10
+ * updaters kept "successfully updating" forever, and eventually two concurrent
11
+ * installs collided mid-rename (npm stages by renaming the package dir aside
12
+ * to a hidden `.hq-cli-XXXX` dir), gutting the install: no package.json, no
13
+ * bin/hq. This lock makes losing that race impossible for cooperating actors.
14
+ *
15
+ * ── CROSS-REPO CONTRACT ─────────────────────────────────────────────────────
16
+ * The lock file path and JSON shape below are a contract shared with the
17
+ * hq-sync Rust menubar app, which honors the SAME file before running its own
18
+ * CLI updates. Do not change either without a coordinated hq-sync release.
19
+ *
20
+ * Path: $HOME/.hq/locks/cli-update.lock
21
+ * Content: JSON object with exactly these fields:
22
+ * {
23
+ * "pid": <number> — owning process id,
24
+ * "startedAt": <string> — ISO-8601 acquisition timestamp,
25
+ * "tool": <string> — which actor holds it (e.g. "hq-cli-version-gate"),
26
+ * "version": <string> — the holder's own version
27
+ * }
28
+ * Staleness: a holder is stale when `startedAt` is older than 10 minutes OR
29
+ * its `pid` is no longer alive (`kill(pid, 0)` → ESRCH). A stale lock may be
30
+ * removed and re-acquired; a fresh one means "someone else is installing the
31
+ * very version you want — skip".
32
+ * ────────────────────────────────────────────────────────────────────────────
33
+ *
34
+ * Mechanics: acquisition is a single atomic `open(…, 'wx')` (O_CREAT|O_EXCL) —
35
+ * no mkdir dance, no read-then-write window. Unparseable lock content is
36
+ * treated as stale (a torn write from a crashed holder must not wedge updates
37
+ * forever). Everything is best-effort: any unexpected filesystem error reads
38
+ * as "lock held", because the callers' shared philosophy is that updating is
39
+ * optional and the CLI keeps working either way.
40
+ */
41
+ /** A held lock older than this is treated as abandoned (crashed owner). */
42
+ export declare const UPDATE_LOCK_STALE_MS: number;
43
+ /** `tool` recorded when this CLI takes the lock. */
44
+ export declare const UPDATE_LOCK_TOOL = "hq-cli-version-gate";
45
+ export interface UpdateLockInfo {
46
+ pid: number;
47
+ startedAt: string;
48
+ tool: string;
49
+ version: string;
50
+ }
51
+ export interface UpdateLockHandle {
52
+ /** Absolute path of the held lock file. */
53
+ path: string;
54
+ /** Delete the lock file. Idempotent, never throws. */
55
+ release: () => void;
56
+ }
57
+ /** Injectable knobs so contention/staleness are unit-testable hermetically. */
58
+ export interface UpdateLockOptions {
59
+ lockPath?: string;
60
+ now?: number;
61
+ /** Override liveness probe (defaults to `process.kill(pid, 0)`). */
62
+ isPidAlive?: (pid: number) => boolean;
63
+ tool?: string;
64
+ version?: string;
65
+ }
66
+ export declare function updateLockPath(): string;
67
+ /**
68
+ * Whether an existing lock's recorded holder is safe to displace: its
69
+ * `startedAt` is older than {@link UPDATE_LOCK_STALE_MS}, or its pid is dead.
70
+ * Unreadable / malformed content is stale by definition — only a crashed or
71
+ * interrupted holder leaves one behind, and treating it as fresh would block
72
+ * every future update on this machine.
73
+ */
74
+ export declare function isLockStale(raw: string | null, now: number, isPidAlive: (pid: number) => boolean): boolean;
75
+ /**
76
+ * Take the machine-wide CLI-update lock, or return `null` when a fresh holder
77
+ * has it (this process should skip its install). A stale holder is removed and
78
+ * acquisition retried exactly once — two genuinely-racing processes still
79
+ * serialize correctly because the retry goes back through O_EXCL.
80
+ *
81
+ * Callers MUST release in a `finally` around the install.
82
+ */
83
+ export declare function acquireUpdateLock(options?: UpdateLockOptions): UpdateLockHandle | null;
84
+ //# sourceMappingURL=update-lock.d.ts.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared advisory cross-process lock serializing every actor that runs
3
+ * `npm install -g @indigoai-us/hq-cli` (or the pnpm/bun equivalent) against
4
+ * the same machine.
5
+ *
6
+ * Why: production logs showed FOUR independent updaters — this CLI's hard
7
+ * version-gate, the soft self-updater, and updaters inside the hq-sync menubar
8
+ * app — each firing `npm install -g` with no coordination. On a machine with a
9
+ * second "ghost" install (e.g. a pnpm-global shim shadowing the npm one) the
10
+ * updaters kept "successfully updating" forever, and eventually two concurrent
11
+ * installs collided mid-rename (npm stages by renaming the package dir aside
12
+ * to a hidden `.hq-cli-XXXX` dir), gutting the install: no package.json, no
13
+ * bin/hq. This lock makes losing that race impossible for cooperating actors.
14
+ *
15
+ * ── CROSS-REPO CONTRACT ─────────────────────────────────────────────────────
16
+ * The lock file path and JSON shape below are a contract shared with the
17
+ * hq-sync Rust menubar app, which honors the SAME file before running its own
18
+ * CLI updates. Do not change either without a coordinated hq-sync release.
19
+ *
20
+ * Path: $HOME/.hq/locks/cli-update.lock
21
+ * Content: JSON object with exactly these fields:
22
+ * {
23
+ * "pid": <number> — owning process id,
24
+ * "startedAt": <string> — ISO-8601 acquisition timestamp,
25
+ * "tool": <string> — which actor holds it (e.g. "hq-cli-version-gate"),
26
+ * "version": <string> — the holder's own version
27
+ * }
28
+ * Staleness: a holder is stale when `startedAt` is older than 10 minutes OR
29
+ * its `pid` is no longer alive (`kill(pid, 0)` → ESRCH). A stale lock may be
30
+ * removed and re-acquired; a fresh one means "someone else is installing the
31
+ * very version you want — skip".
32
+ * ────────────────────────────────────────────────────────────────────────────
33
+ *
34
+ * Mechanics: acquisition is a single atomic `open(…, 'wx')` (O_CREAT|O_EXCL) —
35
+ * no mkdir dance, no read-then-write window. Unparseable lock content is
36
+ * treated as stale (a torn write from a crashed holder must not wedge updates
37
+ * forever). Everything is best-effort: any unexpected filesystem error reads
38
+ * as "lock held", because the callers' shared philosophy is that updating is
39
+ * optional and the CLI keeps working either way.
40
+ */
41
+ import * as fs from "node:fs";
42
+ import * as os from "node:os";
43
+ import * as path from "node:path";
44
+ import { CLI_VERSION } from "../cli-version.js";
45
+ /** A held lock older than this is treated as abandoned (crashed owner). */
46
+ export const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
47
+ /** `tool` recorded when this CLI takes the lock. */
48
+ export const UPDATE_LOCK_TOOL = "hq-cli-version-gate";
49
+ export function updateLockPath() {
50
+ return path.join(os.homedir(), ".hq", "locks", "cli-update.lock");
51
+ }
52
+ function defaultIsPidAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (err) {
58
+ // ESRCH — no such process. EPERM means it exists but is not ours; that
59
+ // still counts as alive (we must not steal a root-owned updater's lock).
60
+ return err.code === "EPERM";
61
+ }
62
+ }
63
+ /**
64
+ * Whether an existing lock's recorded holder is safe to displace: its
65
+ * `startedAt` is older than {@link UPDATE_LOCK_STALE_MS}, or its pid is dead.
66
+ * Unreadable / malformed content is stale by definition — only a crashed or
67
+ * interrupted holder leaves one behind, and treating it as fresh would block
68
+ * every future update on this machine.
69
+ */
70
+ export function isLockStale(raw, now, isPidAlive) {
71
+ if (raw == null)
72
+ return true;
73
+ let info;
74
+ try {
75
+ info = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ return true;
79
+ }
80
+ if (typeof info.pid !== "number" || typeof info.startedAt !== "string") {
81
+ return true;
82
+ }
83
+ const startedAt = Date.parse(info.startedAt);
84
+ if (Number.isNaN(startedAt))
85
+ return true;
86
+ if (now - startedAt > UPDATE_LOCK_STALE_MS)
87
+ return true;
88
+ if (!isPidAlive(info.pid))
89
+ return true;
90
+ return false;
91
+ }
92
+ function tryCreate(lockPath, body) {
93
+ let fd;
94
+ try {
95
+ // 'wx' — O_CREAT|O_EXCL|O_WRONLY: fails atomically if the file exists.
96
+ fd = fs.openSync(lockPath, "wx");
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ try {
102
+ fs.writeSync(fd, body);
103
+ }
104
+ catch {
105
+ // A lock we created but could not stamp is still OURS — keep it.
106
+ }
107
+ finally {
108
+ try {
109
+ fs.closeSync(fd);
110
+ }
111
+ catch {
112
+ // already closed
113
+ }
114
+ }
115
+ return {
116
+ path: lockPath,
117
+ release: () => {
118
+ try {
119
+ fs.rmSync(lockPath, { force: true });
120
+ }
121
+ catch {
122
+ // best-effort lock cleanup
123
+ }
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Take the machine-wide CLI-update lock, or return `null` when a fresh holder
129
+ * has it (this process should skip its install). A stale holder is removed and
130
+ * acquisition retried exactly once — two genuinely-racing processes still
131
+ * serialize correctly because the retry goes back through O_EXCL.
132
+ *
133
+ * Callers MUST release in a `finally` around the install.
134
+ */
135
+ export function acquireUpdateLock(options = {}) {
136
+ const lockPath = options.lockPath ?? updateLockPath();
137
+ const now = options.now ?? Date.now();
138
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
139
+ const info = {
140
+ pid: process.pid,
141
+ startedAt: new Date(now).toISOString(),
142
+ tool: options.tool ?? UPDATE_LOCK_TOOL,
143
+ version: options.version ?? CLI_VERSION,
144
+ };
145
+ const body = JSON.stringify(info);
146
+ try {
147
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
148
+ }
149
+ catch {
150
+ return null; // can't even create ~/.hq/locks — treat as held, skip update
151
+ }
152
+ const first = tryCreate(lockPath, body);
153
+ if (first)
154
+ return first;
155
+ // Lock exists. Fresh holder → back off. Stale → remove and retry ONCE.
156
+ let raw;
157
+ try {
158
+ raw = fs.readFileSync(lockPath, "utf-8");
159
+ }
160
+ catch {
161
+ raw = null; // vanished (holder just released) or unreadable — stale path
162
+ }
163
+ if (!isLockStale(raw, now, isPidAlive))
164
+ return null;
165
+ try {
166
+ fs.rmSync(lockPath, { force: true });
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ return tryCreate(lockPath, body);
172
+ }
173
+ //# sourceMappingURL=update-lock.js.map
@@ -28,6 +28,7 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
31
+ import { type UpdateLockHandle } from "./update-lock.js";
31
32
  /** Which package manager owns the running global install. */
32
33
  export type InstallManager = "npm" | "pnpm" | "bun";
33
34
  export interface VersionCheckResponse {
@@ -250,21 +251,60 @@ declare function performUpdate(command: string, runner?: UpdateRunner): UpdateRe
250
251
  */
251
252
  declare function nudgeUpdateRecommended(decision: VersionCheckResponse, install?: RunningInstall): void;
252
253
  /**
253
- * Hard enforcement when the server says we're below `minVersion`. Print a
254
- * red banner, attempt the update, then exit so the user reruns against the
255
- * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
256
- * still gets a clear error rather than an opaque silent failure.
254
+ * The `hq` binary an ordinary shell PATH lookup would run, or null when it
255
+ * can't be resolved. Uses `command -v` through `/bin/sh` (POSIX) / `where`
256
+ * (Windows) rather than trusting our own install path the whole point is to
257
+ * see what the USER's next invocation resolves.
258
+ */
259
+ export declare function resolveHqOnPath(): string | null;
260
+ /** `<bin> --version` output (trimmed), or null on any failure/timeout. */
261
+ export declare function probeCliVersion(bin: string): string | null;
262
+ /**
263
+ * Read-your-writes convergence check, run after an install reports success.
257
264
  *
258
- * Exit codes:
259
- * 0update succeeded; user must rerun their command
260
- * 75 update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
265
+ * A "successful" `npm install -g` proves only that the npm prefix was
266
+ * rewritten NOT that the `hq` the user's PATH resolves is the copy that was
267
+ * just installed. On a machine with a second "ghost" install (e.g. a
268
+ * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
269
+ * every updater on the box probes the ghost's version, sees it stale, installs
270
+ * into the npm prefix, reports success, and repeats forever — the production
271
+ * updater-war this fix exists for. Detect the divergence here, warn ONCE with
272
+ * the exact shadowing path, and never retry: no number of retries into the
273
+ * same prefix can change what PATH resolves.
274
+ *
275
+ * Advisory only — resolution failure warns once and continues; nothing in
276
+ * here may crash or block the CLI.
261
277
  */
262
- declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
278
+ export declare function checkUpdateConvergence(targetVersion: string, deps?: {
279
+ resolveBin?: () => string | null;
280
+ probeVersion?: (bin: string) => string | null;
281
+ }): void;
282
+ /** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
283
+ interface EnforceUpdateDeps {
263
284
  performUpdateString?: (command: string) => UpdateResult;
264
285
  resolveInstall?: () => RunningInstall;
265
286
  runner?: UpdateRunner;
266
287
  cleanStale?: (prefix: string) => string[];
267
- }): never;
288
+ acquireLock?: () => UpdateLockHandle | null;
289
+ checkConvergence?: (targetVersion: string) => void;
290
+ }
291
+ /**
292
+ * Hard enforcement when the server says we're below `minVersion`. Print a
293
+ * red banner, take the machine-wide update lock, attempt the update, then
294
+ * exit so the user reruns against the fresh binary. Sequence chosen so a user
295
+ * with a broken `npm` global prefix still gets a clear error rather than an
296
+ * opaque silent failure.
297
+ *
298
+ * When another updater already holds the lock (fresh, live holder) the gate
299
+ * SKIPS the install and RETURNS, letting the CLI continue on the current
300
+ * version — the gate must never break the CLI, and the lock holder is
301
+ * installing the very version we want anyway.
302
+ *
303
+ * Exit codes:
304
+ * 0 — update succeeded; user must rerun their command
305
+ * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
306
+ */
307
+ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void;
268
308
  /**
269
309
  * Public entry point. Call before commander parses argv. Blocks the CLI on
270
310
  * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
@@ -291,8 +331,12 @@ export declare function enforceVersionGate(onUpdateRecommended?: (decision: Vers
291
331
  export declare function shouldSkipGate(argv: readonly string[]): boolean;
292
332
  export declare const __test__: {
293
333
  CLIENT_ID: string;
334
+ CONVERGENCE_TIMEOUT_MS: number;
294
335
  ENDPOINT_PATH: string;
295
336
  FETCH_TIMEOUT_MS: number;
337
+ checkUpdateConvergence: typeof checkUpdateConvergence;
338
+ probeCliVersion: typeof probeCliVersion;
339
+ resolveHqOnPath: typeof resolveHqOnPath;
296
340
  buildBunInstallArgv: typeof buildBunInstallArgv;
297
341
  buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
298
342
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
@@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url";
36
36
  import chalk from "chalk";
37
37
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
38
38
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
39
+ import { acquireUpdateLock, } from "./update-lock.js";
39
40
  const CLIENT_ID = "hq-cli";
40
41
  const ENDPOINT_PATH = "/v1/client-version/check";
41
42
  const FETCH_TIMEOUT_MS = 3_000;
@@ -514,11 +515,103 @@ function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
514
515
  console.error(chalk.dim(` Update: ${command}`));
515
516
  }
516
517
  }
518
+ /** Timeout for the read-your-writes PATH/version probes below. A broken or
519
+ * hung shell must not stall the gate — the probes are advisory only. */
520
+ const CONVERGENCE_TIMEOUT_MS = 3_000;
521
+ /**
522
+ * The `hq` binary an ordinary shell PATH lookup would run, or null when it
523
+ * can't be resolved. Uses `command -v` through `/bin/sh` (POSIX) / `where`
524
+ * (Windows) rather than trusting our own install path — the whole point is to
525
+ * see what the USER's next invocation resolves.
526
+ */
527
+ export function resolveHqOnPath() {
528
+ try {
529
+ const probe = process.platform === "win32"
530
+ ? spawnSync("where", ["hq"], {
531
+ encoding: "utf-8",
532
+ timeout: CONVERGENCE_TIMEOUT_MS,
533
+ })
534
+ : spawnSync("/bin/sh", ["-c", "command -v hq"], {
535
+ encoding: "utf-8",
536
+ timeout: CONVERGENCE_TIMEOUT_MS,
537
+ });
538
+ if (probe.error || probe.status !== 0)
539
+ return null;
540
+ const first = (probe.stdout ?? "")
541
+ .split(/\r?\n/)
542
+ .map((line) => line.trim())
543
+ .filter(Boolean)[0];
544
+ return first ?? null;
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ }
550
+ /** `<bin> --version` output (trimmed), or null on any failure/timeout. */
551
+ export function probeCliVersion(bin) {
552
+ try {
553
+ const result = spawnSync(bin, ["--version"], {
554
+ encoding: "utf-8",
555
+ timeout: CONVERGENCE_TIMEOUT_MS,
556
+ });
557
+ if (result.error || result.status !== 0)
558
+ return null;
559
+ const out = (result.stdout ?? "").trim();
560
+ return out || null;
561
+ }
562
+ catch {
563
+ return null;
564
+ }
565
+ }
566
+ /**
567
+ * Read-your-writes convergence check, run after an install reports success.
568
+ *
569
+ * A "successful" `npm install -g` proves only that the npm prefix was
570
+ * rewritten — NOT that the `hq` the user's PATH resolves is the copy that was
571
+ * just installed. On a machine with a second "ghost" install (e.g. a
572
+ * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
573
+ * every updater on the box probes the ghost's version, sees it stale, installs
574
+ * into the npm prefix, reports success, and repeats forever — the production
575
+ * updater-war this fix exists for. Detect the divergence here, warn ONCE with
576
+ * the exact shadowing path, and never retry: no number of retries into the
577
+ * same prefix can change what PATH resolves.
578
+ *
579
+ * Advisory only — resolution failure warns once and continues; nothing in
580
+ * here may crash or block the CLI.
581
+ */
582
+ export function checkUpdateConvergence(targetVersion, deps = {}) {
583
+ try {
584
+ const bin = (deps.resolveBin ?? resolveHqOnPath)();
585
+ if (!bin) {
586
+ console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
587
+ return;
588
+ }
589
+ const reported = (deps.probeVersion ?? probeCliVersion)(bin);
590
+ if (!reported) {
591
+ console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
592
+ return;
593
+ }
594
+ if (reported === targetVersion)
595
+ return; // converged — the normal case
596
+ console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
597
+ `a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
598
+ "or the updater will loop forever."));
599
+ }
600
+ catch {
601
+ // Verification is best-effort; never break the CLI over a probe.
602
+ }
603
+ }
517
604
  /**
518
605
  * Hard enforcement when the server says we're below `minVersion`. Print a
519
- * red banner, attempt the update, then exit so the user reruns against the
520
- * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
521
- * still gets a clear error rather than an opaque silent failure.
606
+ * red banner, take the machine-wide update lock, attempt the update, then
607
+ * exit so the user reruns against the fresh binary. Sequence chosen so a user
608
+ * with a broken `npm` global prefix still gets a clear error rather than an
609
+ * opaque silent failure.
610
+ *
611
+ * When another updater already holds the lock (fresh, live holder) the gate
612
+ * SKIPS the install and RETURNS, letting the CLI continue on the current
613
+ * version — the gate must never break the CLI, and the lock holder is
614
+ * installing the very version we want anyway.
522
615
  *
523
616
  * Exit codes:
524
617
  * 0 — update succeeded; user must rerun their command
@@ -545,6 +638,38 @@ function enforceUpdateRequired(decision, deps = {}) {
545
638
  }
546
639
  process.exit(75);
547
640
  }
641
+ // Serialize against every other updater on the machine (other hq processes,
642
+ // the hq-sync menubar app) via the shared advisory lock — see update-lock.ts
643
+ // for the cross-repo contract. A fresh holder means someone else is already
644
+ // installing the version we want: skip and continue on the current version
645
+ // (the gate must never break the CLI).
646
+ const lock = (deps.acquireLock ?? acquireUpdateLock)();
647
+ if (!lock) {
648
+ console.error(chalk.dim(" Another hq updater is already installing (update lock held); continuing on the current version."));
649
+ return;
650
+ }
651
+ let exitCode;
652
+ try {
653
+ exitCode = attemptRequiredUpdate(decision, deps, install);
654
+ }
655
+ finally {
656
+ // `process.exit` skips `finally` blocks, so the exit itself lives OUTSIDE
657
+ // the lock scope; this `finally` releases on the normal path and on any
658
+ // unexpected throw from the install attempt.
659
+ lock.release();
660
+ }
661
+ process.exit(exitCode);
662
+ }
663
+ /**
664
+ * The install attempt itself (npm/pnpm/bun routing, stale-partial cleanup,
665
+ * sudo fallback, post-success convergence check). Runs with the machine-wide
666
+ * update lock held. Returns the process exit code: 0 on success, 75
667
+ * (EX_TEMPFAIL) on failure.
668
+ */
669
+ function attemptRequiredUpdate(decision, deps, install) {
670
+ const command = decision.updateCommand;
671
+ const isManagedOutsideNpm = install.manager !== "npm";
672
+ const prefix = install.prefix;
548
673
  const runner = deps.runner ?? runUpdateCommand;
549
674
  // Resolve the concrete install argv once so the sudo fallback below can re-run
550
675
  // the EXACT same command under elevation.
@@ -641,10 +766,14 @@ function enforceUpdateRequired(decision, deps = {}) {
641
766
  if (isManagedOutsideNpm && result.code === "ENOENT" && command) {
642
767
  console.error(chalk.dim(` (hq-pro suggests \`${command}\` — that is for npm-managed installs; use it only if you have switched this install to npm.)`));
643
768
  }
644
- process.exit(75);
769
+ return 75;
645
770
  }
646
771
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
647
- process.exit(0);
772
+ // Read-your-writes: a "successful" install into the npm prefix does not
773
+ // prove the user's PATH resolves it. Warn (once, no retry) when a ghost
774
+ // install is shadowing the copy we just wrote — see checkUpdateConvergence.
775
+ (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
776
+ return 0;
648
777
  }
649
778
  export async function enforceVersionGate(onUpdateRecommended) {
650
779
  if (isOptedOut())
@@ -658,7 +787,10 @@ export async function enforceVersionGate(onUpdateRecommended) {
658
787
  // neither downstream path repeats it.
659
788
  const install = resolveRunningInstall();
660
789
  if (decision.updateRequired) {
661
- enforceUpdateRequired(decision, { resolveInstall: () => install }); // exits process
790
+ // Exits the process, EXCEPT when another updater holds the shared update
791
+ // lock — then it returns and the CLI continues on the current version.
792
+ enforceUpdateRequired(decision, { resolveInstall: () => install });
793
+ return "continue";
662
794
  }
663
795
  if (decision.updateRecommended) {
664
796
  if (onUpdateRecommended) {
@@ -682,8 +814,12 @@ export function shouldSkipGate(argv) {
682
814
  }
683
815
  export const __test__ = {
684
816
  CLIENT_ID,
817
+ CONVERGENCE_TIMEOUT_MS,
685
818
  ENDPOINT_PATH,
686
819
  FETCH_TIMEOUT_MS,
820
+ checkUpdateConvergence,
821
+ probeCliVersion,
822
+ resolveHqOnPath,
687
823
  buildBunInstallArgv,
688
824
  buildPnpmInstallArgv,
689
825
  buildPrefixedInstallArgv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.11",
3
+ "version": "5.103.13",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {