@indigoai-us/hq-cli 5.345.6 → 5.345.8

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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/commands/__fixtures__/make-tar.d.ts +2 -10
  3. package/dist/commands/__fixtures__/make-tar.js +2 -2
  4. package/dist/commands/agent-enroll.d.ts +2 -3
  5. package/dist/commands/agent-enroll.js +2 -2
  6. package/dist/commands/mcp-status.d.ts +4 -3
  7. package/dist/commands/reindex.d.ts +0 -6
  8. package/dist/commands/reindex.js +1 -1
  9. package/dist/commands/safe-extract.d.ts +2 -17
  10. package/dist/commands/safe-extract.js +2 -2
  11. package/dist/commands/search.d.ts +2 -1
  12. package/dist/lazy-commands.d.ts +0 -6
  13. package/dist/lazy-commands.js +1 -1
  14. package/dist/lib/agent-kit/run/inbox.js +29 -3
  15. package/dist/lib/daemon/supervisor.d.ts +1 -0
  16. package/dist/lib/daemon/supervisor.js +97 -12
  17. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +8 -9
  18. package/dist/lib/doctor/__testing__/fake-hq-tree.js +1 -1
  19. package/dist/lib/doctor/checks/runtime-probe.d.ts +0 -21
  20. package/dist/lib/doctor/checks/runtime-probe.js +4 -4
  21. package/dist/lib/doctor/checks/sync-health.d.ts +2 -3
  22. package/dist/lib/doctor/checks/sync-health.js +1 -1
  23. package/dist/lib/doctor/compat.d.ts +2 -1
  24. package/dist/lib/doctor/deep/classify.d.ts +2 -1
  25. package/dist/lib/doctor/fix/backup.d.ts +0 -8
  26. package/dist/lib/doctor/fix/backup.js +2 -2
  27. package/dist/lib/doctor/fixtures/discover.d.ts +2 -10
  28. package/dist/lib/doctor/fixtures/discover.js +1 -1
  29. package/dist/lib/lanes/autopilot.d.ts +2 -17
  30. package/dist/lib/lanes/autopilot.js +8 -8
  31. package/dist/lib/lanes/flag-keys.d.ts +2 -0
  32. package/dist/lib/lanes/flag-keys.js +2 -0
  33. package/dist/lib/lanes/keep-alive.d.ts +2 -0
  34. package/dist/lib/lanes/keep-alive.js +9 -0
  35. package/dist/lib/lanes/reconcile.js +44 -27
  36. package/dist/lib/lanes/spawn.js +2 -0
  37. package/dist/lib/lanes/tracker-report.d.ts +16 -15
  38. package/dist/lib/lanes/wait.d.ts +2 -5
  39. package/dist/lib/lanes/wait.js +3 -3
  40. package/dist/lib/local-tree-diff.d.ts +3 -2
  41. package/dist/lib/search-index/background.d.ts +0 -2
  42. package/dist/lib/search-index/background.js +1 -1
  43. package/dist/lib/search-index/index.d.ts +0 -19
  44. package/dist/lib/search-index/index.js +2 -2
  45. package/dist/main.d.ts +3 -0
  46. package/dist/main.js +126 -12
  47. package/dist/types.d.ts +3 -3
  48. package/dist/utils/client-health-contract.d.ts +14 -17
  49. package/dist/utils/client-health-contract.js +11 -11
  50. package/dist/utils/self-update.d.ts +2 -0
  51. package/dist/utils/self-update.js +37 -7
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,10 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.8] — 2026-10-05
6
+ - Prioritize due lead keep-alive retries and honor due next-check times ahead of later backoff deadlines behind an hq-flags gate.
7
+ - Drain bot turns before replacing a running bot after a daemon plan change.
8
+
9
+ ## [5.345.7] — 2026-10-05
10
+ - Retry a just-published hq-cli version when npm briefly reports `notarget`.
11
+ - Wait for an active sync pass to finish before restarting sync after a CLI update.
12
+ - Add bounded context to captured missing-module errors targeting hq-cli's own dist output.
13
+ - Recover interrupted agent inbox polls without duplicating mirrored journal entries.
14
+ - Record the actual launch time, trigger, and caller for resumed lane workers even when legacy launch history is missing.
15
+
5
16
  ## [5.345.6] — 2026-10-05
6
17
  - `hq mesh context default set <slug> --count-usage` now works for people signed in with a normal HQ account. It previously refused with "the signed-in person could not be determined". An opt-in saved by 5.344.0 is ignored; run the command again to opt in.
7
18
  - `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
8
19
 
20
+ ## [5.345.5] — 2026-10-05
21
+ - `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
22
+
9
23
  ## [5.345.4] — 2026-10-05
10
24
  - Run a pinned or newer managed hq-cloud sync runner ahead of the staged sync bundle, so daemon sync no longer stays on the hq-cloud version bundled with the CLI after an update or pin (hq-cli#1547).
11
25
  - Avoid dispatching the global hook when the working directory is inside an HQ root.
@@ -22,7 +22,7 @@
22
22
  * lists whatever bytes we wrote, so the guard sees the real attack everywhere.
23
23
  */
24
24
  /** ustar typeflag values we use. */
25
- export type TarType = 'file' | 'symlink' | 'hardlink';
25
+ type TarType = 'file' | 'symlink' | 'hardlink';
26
26
  export interface TarEntrySpec {
27
27
  /** Recorded entry name — written VERBATIM (may contain `..`, be absolute…). */
28
28
  name: string;
@@ -32,15 +32,7 @@ export interface TarEntrySpec {
32
32
  /** File content (regular files only). */
33
33
  content?: string | Buffer;
34
34
  }
35
- /**
36
- * Build one 512-byte ustar header (plus content blocks for regular files) for a
37
- * single entry. The header checksum is computed exactly per spec: sum every
38
- * header byte treating the 8 checksum bytes themselves as ASCII spaces, then
39
- * write that sum as 6 octal digits + NUL + space.
40
- */
41
- export declare function makeTarEntry(spec: TarEntrySpec): Buffer;
42
- /** Concatenate entries and append the two zero blocks that end every tar. */
43
- export declare function makeTar(entries: TarEntrySpec[]): Buffer;
44
35
  /** Build the tar, gzip it, and write it to `outPath`. Returns `outPath`. */
45
36
  export declare function writeMaliciousTarGz(outPath: string, entries: TarEntrySpec[]): string;
37
+ export {};
46
38
  //# sourceMappingURL=make-tar.d.ts.map
@@ -50,7 +50,7 @@ function writeOctal(buf, offset, width, value) {
50
50
  * header byte treating the 8 checksum bytes themselves as ASCII spaces, then
51
51
  * write that sum as 6 octal digits + NUL + space.
52
52
  */
53
- export function makeTarEntry(spec) {
53
+ function makeTarEntry(spec) {
54
54
  const type = spec.type ?? 'file';
55
55
  const content = type === 'file'
56
56
  ? Buffer.isBuffer(spec.content)
@@ -89,7 +89,7 @@ export function makeTarEntry(spec) {
89
89
  return Buffer.concat([header, content, Buffer.alloc(pad)]);
90
90
  }
91
91
  /** Concatenate entries and append the two zero blocks that end every tar. */
92
- export function makeTar(entries) {
92
+ function makeTar(entries) {
93
93
  const blocks = entries.map(makeTarEntry);
94
94
  blocks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive marker
95
95
  return Buffer.concat(blocks);
@@ -17,18 +17,16 @@
17
17
  import { Command } from "commander";
18
18
  import { generateHostKeyPair } from "../lib/agent-kit/creds.js";
19
19
  import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
20
- export declare const ENROLL_PATH = "/v1/agents/enroll";
21
20
  /** Canonical code: un-grouped, uppercase (server hashes exactly this). */
22
21
  export declare function normalizeEnrollmentCode(raw: string): string;
23
22
  /** 24 chars of Crockford base32 (no I, L, O, U). */
24
23
  export declare function isWellFormedEnrollmentCode(code: string): boolean;
25
- export interface HostInfo {
24
+ interface HostInfo {
26
25
  os: string;
27
26
  arch: string;
28
27
  hostname: string;
29
28
  cliVersion: string;
30
29
  }
31
- export declare function collectHostInfo(): HostInfo;
32
30
  export type ExistingIdentity = {
33
31
  kind: "none";
34
32
  } | {
@@ -143,4 +141,5 @@ export interface EnrollResult {
143
141
  /** Pure-ish orchestration so tests can drive it without a TTY or network. */
144
142
  export declare function enrollHost(opts: EnrollOptions, deps?: EnrollDeps): Promise<EnrollResult>;
145
143
  export declare function registerAgentEnrollCommand(agent: Command): void;
144
+ export {};
146
145
  //# sourceMappingURL=agent-enroll.d.ts.map
@@ -25,7 +25,7 @@ import { networkTransportErrorCode } from "../utils/network-transport-error.js";
25
25
  import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
26
26
  import { generateHostKeyPair, machineCredsFileExists, readExternalMachineCreds, writeHostKeyPair, writeMachineCreds, } from "../lib/agent-kit/creds.js";
27
27
  import { DEFAULT_LOCAL_AGENT_NAME, agentKitPaths, localAgentDir, } from "../lib/agent-kit/paths.js";
28
- export const ENROLL_PATH = "/v1/agents/enroll";
28
+ const ENROLL_PATH = "/v1/agents/enroll";
29
29
  /** Canonical code: un-grouped, uppercase (server hashes exactly this). */
30
30
  export function normalizeEnrollmentCode(raw) {
31
31
  return raw.replace(/[\s-]+/g, "").toUpperCase();
@@ -34,7 +34,7 @@ export function normalizeEnrollmentCode(raw) {
34
34
  export function isWellFormedEnrollmentCode(code) {
35
35
  return /^[0-9A-HJKMNP-TV-Z]{24}$/.test(code);
36
36
  }
37
- export function collectHostInfo() {
37
+ function collectHostInfo() {
38
38
  return {
39
39
  os: `${os.platform()} ${os.release()}`,
40
40
  arch: os.arch(),
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { type SafeWriteEnv } from './mcp-registration.js';
3
3
  /** A server's presence on ONE runtime surface. */
4
- export type PresenceState =
4
+ type PresenceState =
5
5
  /** The server def is present (and parsed) on this runtime. */
6
6
  'present'
7
7
  /** The runtime parsed fine but this server is not registered there. */
@@ -30,7 +30,7 @@ export interface RuntimeServerStatus {
30
30
  healthy?: boolean;
31
31
  }
32
32
  /** A single HQ-owned MCP server's cross-runtime status. */
33
- export interface McpServerStatus {
33
+ interface McpServerStatus {
34
34
  /** The bare server name (the `mcpServers` / `mcp_servers` key). */
35
35
  server: string;
36
36
  /** The pack that owns it (`_hqPack` provenance). */
@@ -59,7 +59,7 @@ export interface McpServerStatus {
59
59
  drifted: boolean;
60
60
  }
61
61
  /** A runtime's top-level status. */
62
- export interface RuntimeStatus {
62
+ interface RuntimeStatus {
63
63
  /** Whether the runtime is installed (Claude is always; Codex iff `~/.codex` exists). */
64
64
  installed: boolean;
65
65
  /** Whether the runtime's config parsed (false => unparseable). */
@@ -133,4 +133,5 @@ export declare function renderMcpCapabilities(caps: McpCapabilities): string;
133
133
  * {@link registerReindexCommand} and the subcommand-group pattern in index.ts.
134
134
  */
135
135
  export declare function registerMcpCommand(program: Command): void;
136
+ export {};
136
137
  //# sourceMappingURL=mcp-status.d.ts.map
@@ -5,12 +5,6 @@ import { Command } from 'commander';
5
5
  * layout scan and Git's status output rather than recursively walking trees.
6
6
  */
7
7
  export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, deadline?: number): void;
8
- /**
9
- * Check hook health without relying on lifecycle hooks. A fully disabled
10
- * configuration is repaired only after a successful reindex; partial and
11
- * malformed configurations remain untouched and receive recovery guidance.
12
- */
13
- export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
14
8
  /**
15
9
  * Stop the generated project settings from silently downgrading an operator who
16
10
  * chose Bypass mode globally.
@@ -491,7 +491,7 @@ function runSilently(operation) {
491
491
  * configuration is repaired only after a successful reindex; partial and
492
492
  * malformed configurations remain untouched and receive recovery guidance.
493
493
  */
494
- export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
494
+ function repairExtremeHookDrift(hqRoot, allowRepair = true) {
495
495
  // `hq reindex` can still be invoked from an arbitrary directory. Never turn
496
496
  // an absent settings file there into a rescue attempt.
497
497
  if (!isHqRoot(hqRoot))
@@ -45,7 +45,7 @@ export declare const DEFAULT_CAPS: ExtractCaps;
45
45
  export declare class UnsafeArchiveError extends Error {
46
46
  constructor(message: string);
47
47
  }
48
- export type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
48
+ type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
49
49
  export interface TarEntry {
50
50
  type: EntryType;
51
51
  /** Entry path as recorded in the archive (may contain `..`, be absolute…). */
@@ -108,14 +108,6 @@ export declare function resolveContainedPath(targetDir: string, entryName: strin
108
108
  * way the final location must be contained in the pack dir.
109
109
  */
110
110
  export declare function assertLinkTargetContained(packDir: string, entryAbs: string, linkTarget: string): void;
111
- /**
112
- * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
113
- * argv form, never a shell string, keeps hostile filenames out of shell syntax.
114
- * Windows drive paths always use a basename relative to the archive directory.
115
- * GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows' bundled
116
- * bsdtar accepts the same relative argv without GNU-only `--force-local`.
117
- */
118
- export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtractOptions, 'platform' | 'execFileSync'>): TarEntry[];
119
111
  /**
120
112
  * Pre-flight validation against the entry headers. Enforces (a) containment of
121
113
  * every entry name under `targetDir`, (b) containment of every link target
@@ -123,14 +115,6 @@ export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtract
123
115
  * UnsafeArchiveError on the first violation — default-deny.
124
116
  */
125
117
  export declare function validateEntries(entries: TarEntry[], targetDir: string, caps?: ExtractCaps): void;
126
- /**
127
- * Walk everything actually written under `stagingDir` and assert that no entry
128
- * — and, for symlinks, no link target — escapes. This is the defense-in-depth
129
- * backstop: it does not trust the pre-flight scan OR the tar binary. Uses
130
- * realpath on the link's PARENT (which exists) plus lstat to inspect links
131
- * without following them off-tree.
132
- */
133
- export declare function assertExtractContained(stagingDir: string): void;
134
118
  export interface SafeExtractOptions {
135
119
  caps?: ExtractCaps;
136
120
  /** Test seam for platform-specific argv construction. */
@@ -158,4 +142,5 @@ export interface SafeExtractOptions {
158
142
  * so a half-extracted pack can never be wired.
159
143
  */
160
144
  export declare function safeExtractTarball(tarballPath: string, finalDir: string, opts?: SafeExtractOptions): void;
145
+ export {};
161
146
  //# sourceMappingURL=safe-extract.d.ts.map
@@ -219,7 +219,7 @@ function tarballArgvLocation(tarballPath, platform) {
219
219
  * GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows' bundled
220
220
  * bsdtar accepts the same relative argv without GNU-only `--force-local`.
221
221
  */
222
- export function listTarball(tarballPath, opts = {}) {
222
+ function listTarball(tarballPath, opts = {}) {
223
223
  const platform = opts.platform ?? process.platform;
224
224
  const location = tarballArgvLocation(tarballPath, platform);
225
225
  const out = (opts.execFileSync ?? execFileSync)('tar', ['-tvf', location.archiveArg], {
@@ -277,7 +277,7 @@ export function validateEntries(entries, targetDir, caps = DEFAULT_CAPS) {
277
277
  * realpath on the link's PARENT (which exists) plus lstat to inspect links
278
278
  * without following them off-tree.
279
279
  */
280
- export function assertExtractContained(stagingDir) {
280
+ function assertExtractContained(stagingDir) {
281
281
  const realRoot = fs.realpathSync(stagingDir);
282
282
  const walk = (dir) => {
283
283
  for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- export type SearchMode = 'keyword' | 'semantic' | 'hybrid';
2
+ type SearchMode = 'keyword' | 'semantic' | 'hybrid';
3
3
  export type SearchOptions = {
4
4
  mode?: SearchMode;
5
5
  collection?: string;
@@ -32,4 +32,5 @@ export declare function normalizeQueryForQmd(query: string): string;
32
32
  export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
33
33
  export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
34
34
  export declare function registerSearchCommand(program: Command): void;
35
+ export {};
35
36
  //# sourceMappingURL=search.d.ts.map
@@ -5,12 +5,6 @@ export type LazyCommand = {
5
5
  /** Registers every contributor for this root and no unrelated root. */
6
6
  register: (program: Command) => Promise<void>;
7
7
  };
8
- /**
9
- * One entry for every root, including groups that are assembled by several
10
- * registrar modules. The routing metadata itself is generated from the eager
11
- * command graph and checked in tests and the build.
12
- */
13
- export declare const LAZY_COMMANDS: readonly LazyCommand[];
14
8
  /**
15
9
  * Keep the registration seam at register-all.js. Besides making one place own
16
10
  * the graph, this lets the torn-install recovery wrap failures while an update
@@ -9,7 +9,7 @@ const commandCatalog = COMMAND_CATALOG;
9
9
  * registrar modules. The routing metadata itself is generated from the eager
10
10
  * command graph and checked in tests and the build.
11
11
  */
12
- export const LAZY_COMMANDS = commandCatalog.map((command) => ({
12
+ const LAZY_COMMANDS = commandCatalog.map((command) => ({
13
13
  name: command.name,
14
14
  register: async (program) => {
15
15
  const registered = await registerCommandRoot(program, command.name);
@@ -54,10 +54,34 @@ export function writeSeenIds(paths, seen) {
54
54
  const list = [...seen].slice(-MAX_SEEN_IDS);
55
55
  writePrivateFile(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), 0o600);
56
56
  }
57
+ function inboxJournalContains(paths, id) {
58
+ const journal = path.join(paths.inboxDir, INBOX_JSONL_NAME);
59
+ if (!fs.existsSync(journal))
60
+ return false;
61
+ for (const line of fs.readFileSync(journal, "utf8").split("\n")) {
62
+ if (!line)
63
+ continue;
64
+ let entry;
65
+ try {
66
+ entry = JSON.parse(line);
67
+ }
68
+ catch {
69
+ // An interrupted append can leave one malformed final record; it does
70
+ // not prove that this item made it into the durable journal.
71
+ continue;
72
+ }
73
+ if (entry && typeof entry === "object" && entry.id === id)
74
+ return true;
75
+ }
76
+ return false;
77
+ }
57
78
  export function mirrorInboxItem(paths, item) {
58
79
  const dest = path.join(paths.inboxDir, `${item.id}.json`);
80
+ const recoveringInterruptedMirror = fs.existsSync(dest);
59
81
  writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
60
- fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
82
+ if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
83
+ fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
84
+ }
61
85
  return dest;
62
86
  }
63
87
  export const INBOX_ERROR_BODY_MAX = 300;
@@ -148,7 +172,6 @@ export async function pollInboxOnce(deps) {
148
172
  const fresh = extractInboxItems(res.body).filter((i) => !seen.has(i.id));
149
173
  for (const item of fresh) {
150
174
  mirrorInboxItem(deps.paths, item);
151
- seen.add(item.id);
152
175
  if (deps.ack) {
153
176
  try {
154
177
  const status = await ackItem(token, item.id);
@@ -159,9 +182,12 @@ export async function pollInboxOnce(deps) {
159
182
  deps.log("warn", `ack ${item.id} failed: ${err instanceof Error ? err.message : String(err)}`);
160
183
  }
161
184
  }
185
+ // Persist each completed item before starting another one. If shutdown
186
+ // interrupts a later ack or mirror, earlier items will not be replayed.
187
+ seen.add(item.id);
188
+ writeSeenIds(deps.paths, seen);
162
189
  }
163
190
  if (fresh.length > 0) {
164
- writeSeenIds(deps.paths, seen);
165
191
  deps.log("info", `inbox mirrored ${fresh.length} item(s)`);
166
192
  try {
167
193
  await warmCache(token, deps.agentUid);
@@ -27,6 +27,7 @@ import type { ControlRequest } from "./requests.js";
27
27
  import { syncPassResult } from "./sync-runner.js";
28
28
  export declare const STOP_ESCALATION_MS = 10000;
29
29
  export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
30
+ export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
30
31
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
31
32
  export declare const HANDOFF_STAGGER_MS = 15000;
32
33
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
@@ -28,6 +28,7 @@ const BACKOFF_MAX_MS = 60_000;
28
28
  const HEALTHY_RUN_MS = 60_000;
29
29
  export const STOP_ESCALATION_MS = 10_000;
30
30
  export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
31
+ export const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60_000;
31
32
  const MESH_OWNER_RECHECK_MS = 15_000;
32
33
  const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
33
34
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
@@ -92,6 +93,7 @@ export function createSupervisor(deps) {
92
93
  let deferredCliRestart = false;
93
94
  let deferredCliRestartTimer;
94
95
  let cliRestartInProgress = false;
96
+ let pendingSyncCommandRestart;
95
97
  const snapshot = () => ({
96
98
  version: 1,
97
99
  pid: deps.pid,
@@ -125,15 +127,26 @@ export function createSupervisor(deps) {
125
127
  clearT(deferredCliRestartTimer);
126
128
  deferredCliRestartTimer = undefined;
127
129
  };
128
- const runDeferredCliRestart = () => {
130
+ const clearPendingSyncCommandRestart = (slot) => {
131
+ if (!pendingSyncCommandRestart || (slot && pendingSyncCommandRestart.slot !== slot))
132
+ return;
133
+ if (pendingSyncCommandRestart.timer !== undefined)
134
+ clearT(pendingSyncCommandRestart.timer);
135
+ pendingSyncCommandRestart = undefined;
136
+ };
137
+ const runDeferredCliRestart = (idleTimeout = false) => {
129
138
  if (!deferredCliRestart)
130
139
  return;
140
+ clearPendingSyncCommandRestart();
131
141
  clearDeferredCliRestart();
132
142
  cliRestartInProgress = true;
133
143
  const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
134
- if (sync)
135
- terminate(sync);
136
- deps.log("info", "sync runner is between passes; restarting daemon onto the updated CLI");
144
+ if (sync) {
145
+ terminate(sync, undefined, false, idleTimeout ? CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS : STOP_ESCALATION_MS);
146
+ }
147
+ deps.log(idleTimeout ? "warn" : "info", idleTimeout
148
+ ? "sync pass remained active for 10 minutes; restarting daemon onto the updated CLI"
149
+ : "sync runner is between passes; restarting daemon onto the updated CLI");
137
150
  const releaseHold = () => {
138
151
  cliRestartInProgress = false;
139
152
  const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
@@ -163,6 +176,17 @@ export function createSupervisor(deps) {
163
176
  releaseHold();
164
177
  }
165
178
  };
179
+ const finishPendingSyncCommandRestart = (idleTimeout = false) => {
180
+ const pending = pendingSyncCommandRestart;
181
+ if (!pending)
182
+ return;
183
+ clearPendingSyncCommandRestart(pending.slot);
184
+ if (deferredCliRestart) {
185
+ runDeferredCliRestart(idleTimeout);
186
+ return;
187
+ }
188
+ pending.onFinish(idleTimeout);
189
+ };
166
190
  const requestCliUpdateRestart = () => {
167
191
  if (!deps.restartDaemon)
168
192
  return;
@@ -173,7 +197,10 @@ export function createSupervisor(deps) {
173
197
  }
174
198
  if (sync.syncPassActive === false) {
175
199
  deferredCliRestart = true;
176
- runDeferredCliRestart();
200
+ if (pendingSyncCommandRestart?.slot === sync)
201
+ finishPendingSyncCommandRestart();
202
+ else
203
+ runDeferredCliRestart();
177
204
  return;
178
205
  }
179
206
  if (deferredCliRestart) {
@@ -182,7 +209,25 @@ export function createSupervisor(deps) {
182
209
  }
183
210
  deferredCliRestart = true;
184
211
  deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
185
- deferredCliRestartTimer = setT(runDeferredCliRestart, CLI_UPDATE_SYNC_IDLE_MAX_MS);
212
+ if (pendingSyncCommandRestart?.slot === sync)
213
+ return;
214
+ deferredCliRestartTimer = setT(() => runDeferredCliRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
215
+ };
216
+ const deferSyncCommandRestart = (slot, onFinish) => {
217
+ if (slot.syncPassActive === false) {
218
+ onFinish(false);
219
+ return;
220
+ }
221
+ if (pendingSyncCommandRestart?.slot === slot)
222
+ return;
223
+ clearPendingSyncCommandRestart();
224
+ const pending = { slot, onFinish };
225
+ pendingSyncCommandRestart = pending;
226
+ deps.log("info", `deferring restart of ${slot.plan.id} until its sync pass is idle (maximum 10 minutes)`);
227
+ // A pending CLI restart already owns the same 10-minute idle window.
228
+ if (!deferredCliRestart) {
229
+ pending.timer = setT(() => finishPendingSyncCommandRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
230
+ }
186
231
  };
187
232
  const setBlocked = (slot) => {
188
233
  const block = slot.plan.blocked;
@@ -204,7 +249,8 @@ export function createSupervisor(deps) {
204
249
  * `drain`, a unit that has a drain signal gets it first and SIGTERM only
205
250
  * after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
206
251
  */
207
- const terminate = (slot, then, drain = false) => {
252
+ const terminate = (slot, then, drain = false, killGraceMs = STOP_ESCALATION_MS) => {
253
+ clearPendingSyncCommandRestart(slot);
208
254
  clearTimer(slot);
209
255
  clearHeartbeatTimer(slot);
210
256
  const child = slot.child;
@@ -227,9 +273,9 @@ export function createSupervisor(deps) {
227
273
  slot.killTimer = undefined;
228
274
  if (slot.child !== child)
229
275
  return;
230
- deps.log("warn", `${slot.plan.id} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
276
+ deps.log("warn", `${slot.plan.id} did not exit within ${killGraceMs / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
231
277
  signalChild(slot, child, "SIGKILL");
232
- }, STOP_ESCALATION_MS);
278
+ }, killGraceMs);
233
279
  };
234
280
  const drainSignal = drain ? slot.plan.drainSignal : undefined;
235
281
  if (drainSignal) {
@@ -309,8 +355,12 @@ export function createSupervisor(deps) {
309
355
  slot.syncPassActive = true;
310
356
  }
311
357
  }
312
- if (deferredCliRestart && slot.syncPassActive === false)
313
- runDeferredCliRestart();
358
+ if (slot.syncPassActive === false) {
359
+ if (pendingSyncCommandRestart?.slot === slot)
360
+ finishPendingSyncCommandRestart();
361
+ else if (deferredCliRestart)
362
+ runDeferredCliRestart();
363
+ }
314
364
  }
315
365
  const at = now();
316
366
  slot.state.lastHeartbeatAt = new Date(at).toISOString();
@@ -357,6 +407,11 @@ export function createSupervisor(deps) {
357
407
  if (slot.child !== child)
358
408
  return;
359
409
  slot.child = undefined;
410
+ if (pendingSyncCommandRestart?.slot === slot) {
411
+ clearPendingSyncCommandRestart(slot);
412
+ if (deferredCliRestart)
413
+ runDeferredCliRestart();
414
+ }
360
415
  clearHeartbeatTimer(slot);
361
416
  if (slot.killTimer !== undefined) {
362
417
  clearT(slot.killTimer);
@@ -628,6 +683,7 @@ export function createSupervisor(deps) {
628
683
  existing.blockedReads = 0;
629
684
  const adopted = existing.adopted === true;
630
685
  existing.adopted = false;
686
+ const hadDrainSignal = existing.plan.drainSignal !== undefined;
631
687
  const key = planKey(plan, deps.cliVersion);
632
688
  const wasBlocked = existing.plan.blocked !== undefined;
633
689
  const changed = key !== existing.key;
@@ -652,6 +708,34 @@ export function createSupervisor(deps) {
652
708
  else
653
709
  activate(existing);
654
710
  }
711
+ else if (changed && cliRestartInProgress && plan.service === "sync" && existing.child) {
712
+ // The deferred CLI restart owns this stop; do not queue a second
713
+ // replacement through the adopted-unit handoff path.
714
+ terminate(existing);
715
+ }
716
+ else if (changed && plan.service === "sync" && existing.child) {
717
+ existing.attempts = 0;
718
+ existing.state.restarts = 0;
719
+ const replaceWhenIdle = () => {
720
+ if (adopted) {
721
+ deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
722
+ handoffQueue.push(existing);
723
+ pumpHandoff();
724
+ }
725
+ else {
726
+ deps.log("info", `restarting ${plan.id}: its command changed`);
727
+ terminate(existing, () => activate(existing));
728
+ }
729
+ };
730
+ deferSyncCommandRestart(existing, (idleTimeout) => {
731
+ if (!idleTimeout) {
732
+ replaceWhenIdle();
733
+ return;
734
+ }
735
+ deps.log("warn", `${plan.id} sync pass did not become idle within 10 minutes; stopping it to apply the changed command`);
736
+ terminate(existing, () => activate(existing), false, CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS);
737
+ });
738
+ }
655
739
  else if (changed && adopted && existing.child) {
656
740
  deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
657
741
  handoffQueue.push(existing);
@@ -663,7 +747,7 @@ export function createSupervisor(deps) {
663
747
  if (cliRestartInProgress && plan.service === "sync")
664
748
  terminate(existing);
665
749
  else
666
- terminate(existing, () => activate(existing));
750
+ terminate(existing, () => activate(existing), hadDrainSignal);
667
751
  }
668
752
  }
669
753
  slots = next;
@@ -878,6 +962,7 @@ export function createSupervisor(deps) {
878
962
  if (handoffTimer !== undefined)
879
963
  clearT(handoffTimer);
880
964
  clearDeferredCliRestart();
965
+ clearPendingSyncCommandRestart();
881
966
  const childOwners = new Map();
882
967
  for (const slot of slots.values()) {
883
968
  if (slot.child)
@@ -25,7 +25,7 @@
25
25
  * which stands up a throwaway HQ tree with `mktemp -d` and stubbed hook scripts.
26
26
  */
27
27
  /** Claude/Codex lifecycle events a hook can be registered against. */
28
- export type HookEventName = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "SessionStart" | "Stop" | "SubagentStop" | "PreCompact" | "Notification";
28
+ type HookEventName = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "SessionStart" | "Stop" | "SubagentStop" | "PreCompact" | "Notification";
29
29
  /** The three `hook-gate.sh` allowlist profiles. */
30
30
  export type GateProfile = "minimal" | "standard" | "strict";
31
31
  /** Canonical ordering of the gate profiles. */
@@ -35,7 +35,7 @@ export declare const GATE_PROFILES: readonly GateProfile[];
35
35
  * canonical `.claude/hooks/` scripts; only Codex runs duplicated copies, so the
36
36
  * Codex mirror is the entire drift surface the doctor has to watch.
37
37
  */
38
- export interface FakeCodexMirrorSpec {
38
+ interface FakeCodexMirrorSpec {
39
39
  /** Whether the mirror file is written to `.codex/hooks/`. Default: true. */
40
40
  present?: boolean;
41
41
  /**
@@ -99,7 +99,7 @@ export interface FakeHookSpec {
99
99
  * written by default (declare a hook's `codex` spec explicitly, or set
100
100
  * `legacyHooksJson`, to model pre-adapter leftovers).
101
101
  */
102
- export interface FakeCodexAdapterSpec {
102
+ interface FakeCodexAdapterSpec {
103
103
  /** Events wired to the adapter in config.toml. Default: the shipped eight. */
104
104
  events?: string[];
105
105
  /** Whether the adapter script is written. Default: true. */
@@ -111,10 +111,8 @@ export interface FakeCodexAdapterSpec {
111
111
  /** Whether a leftover `.codex/hooks.json` is still written. Default: false. */
112
112
  legacyHooksJson?: boolean;
113
113
  }
114
- /** The lifecycle events the shipped `.codex/config.toml` wires. */
115
- export declare const ADAPTER_DEFAULT_EVENTS: readonly string[];
116
114
  /** Declares the `.grok/hooks/` state. */
117
- export interface FakeGrokSpec {
115
+ interface FakeGrokSpec {
118
116
  /** Whether the `.grok/hooks/` scaffold is created at all. Default: true. */
119
117
  present?: boolean;
120
118
  /** Whether `hq-grok-hook-adapter.sh` carries the executable bit. Default: true. */
@@ -147,7 +145,7 @@ export interface FakeHqTreeSpec {
147
145
  prefix?: string;
148
146
  }
149
147
  /** Manifest entry describing a materialised Codex mirror. */
150
- export interface FakeCodexManifestEntry {
148
+ interface FakeCodexManifestEntry {
151
149
  /** Absolute path where the mirror lives (whether or not it was written). */
152
150
  scriptPath: string;
153
151
  /** Whether the mirror file exists on disk. */
@@ -162,7 +160,7 @@ export interface FakeCodexManifestEntry {
162
160
  drifted: boolean;
163
161
  }
164
162
  /** Manifest entry describing a materialised hook across all platforms. */
165
- export interface FakeHookManifestEntry {
163
+ interface FakeHookManifestEntry {
166
164
  id: string;
167
165
  /** Absolute path of the Claude script (whether or not it was written). */
168
166
  scriptPath: string;
@@ -184,7 +182,7 @@ export interface FakeHookManifestEntry {
184
182
  codex: FakeCodexManifestEntry | null;
185
183
  }
186
184
  /** A structured description of everything {@link buildFakeHqTree} wrote. */
187
- export interface FakeHqTreeManifest {
185
+ interface FakeHqTreeManifest {
188
186
  root: string;
189
187
  claudeSettingsPath: string;
190
188
  claudeHooksDir: string;
@@ -224,4 +222,5 @@ export declare function trackedFakeHqTreeRoots(): string[];
224
222
  * when the process exits.
225
223
  */
226
224
  export declare function buildFakeHqTree(spec?: FakeHqTreeSpec): FakeHqTree;
225
+ export {};
227
226
  //# sourceMappingURL=fake-hq-tree.d.ts.map
@@ -39,7 +39,7 @@ const EXECUTABLE_MODE = 0o755;
39
39
  const NON_EXECUTABLE_MODE = 0o644;
40
40
  const DEFAULT_PREFIX = "hq-doctor-fake-";
41
41
  /** The lifecycle events the shipped `.codex/config.toml` wires. */
42
- export const ADAPTER_DEFAULT_EVENTS = [
42
+ const ADAPTER_DEFAULT_EVENTS = [
43
43
  "SessionStart",
44
44
  "UserPromptSubmit",
45
45
  "PreToolUse",