@indigoai-us/hq-cli 5.345.127 → 5.345.129

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 (77) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/assets/scaffold/core/scripts/handoff-knowledge-commit.sh +123 -0
  3. package/assets/scaffold/core/scripts/hq-detach.sh +169 -0
  4. package/dist/command-catalog.generated.d.ts +40 -2
  5. package/dist/command-catalog.generated.js +48 -2
  6. package/dist/commands/auth.js +19 -2
  7. package/dist/commands/core-bucket-c-handoff-knowledge-fast.d.ts +2 -0
  8. package/dist/commands/core-bucket-c-handoff-knowledge-fast.js +10 -0
  9. package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +46 -0
  10. package/dist/commands/core-bucket-c-handoff-knowledge-native.js +284 -0
  11. package/dist/commands/core-bucket-c-hq-detach-fast.d.ts +2 -0
  12. package/dist/commands/core-bucket-c-hq-detach-fast.js +10 -0
  13. package/dist/commands/core-bucket-c-hq-detach-native.d.ts +43 -0
  14. package/dist/commands/core-bucket-c-hq-detach-native.js +241 -0
  15. package/dist/commands/core-bucket-c-work-mesh-offer-native.js +8 -2
  16. package/dist/commands/core.js +12 -0
  17. package/dist/commands/daemon.js +2 -0
  18. package/dist/commands/lanes-accounts.js +3 -1
  19. package/dist/commands/lanes-shared.d.ts +7 -0
  20. package/dist/commands/lanes.js +139 -17
  21. package/dist/commands/mesh.js +32 -19
  22. package/dist/commands/skill.js +9 -4
  23. package/dist/fast-scaffold.js +28 -0
  24. package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +3 -5
  25. package/dist/lib/bot/inbox-poll-backoff-flag.js +20 -12
  26. package/dist/lib/bot/inbox-wake.d.ts +18 -0
  27. package/dist/lib/bot/inbox-wake.js +125 -0
  28. package/dist/lib/bot/run.d.ts +8 -0
  29. package/dist/lib/bot/run.js +51 -7
  30. package/dist/lib/bot/runtime/messages-stream.js +31 -3
  31. package/dist/lib/capacity/lane-login.js +1 -0
  32. package/dist/lib/daemon/catalog.d.ts +4 -0
  33. package/dist/lib/daemon/catalog.js +6 -3
  34. package/dist/lib/daemon/flag-monitor.d.ts +4 -1
  35. package/dist/lib/daemon/flag-monitor.js +12 -6
  36. package/dist/lib/daemon/flag.d.ts +2 -0
  37. package/dist/lib/daemon/flag.js +11 -5
  38. package/dist/lib/daemon/hq-child-bootstrap.mjs +47 -7
  39. package/dist/lib/daemon/kill-switch-flags.d.ts +5 -0
  40. package/dist/lib/daemon/kill-switch-flags.js +5 -0
  41. package/dist/lib/daemon/lanes-reconcile-loader.d.ts +7 -0
  42. package/dist/lib/daemon/lanes-reconcile-loader.js +24 -0
  43. package/dist/lib/daemon/lanes-reconcile-watcher.d.ts +17 -0
  44. package/dist/lib/daemon/lanes-reconcile-watcher.js +80 -0
  45. package/dist/lib/daemon/remote-control.d.ts +8 -5
  46. package/dist/lib/daemon/remote-control.js +8 -7
  47. package/dist/lib/daemon/run.d.ts +4 -1
  48. package/dist/lib/daemon/run.js +133 -20
  49. package/dist/lib/doctor/json-output.d.ts +15 -3
  50. package/dist/lib/doctor/json-output.js +13 -1
  51. package/dist/lib/lanes/accounts.js +2 -2
  52. package/dist/lib/lanes/daemon-reconcile.d.ts +3 -0
  53. package/dist/lib/lanes/daemon-reconcile.js +8 -0
  54. package/dist/lib/lanes/host-capacity.d.ts +4 -0
  55. package/dist/lib/lanes/host-capacity.js +10 -1
  56. package/dist/lib/lanes/index.d.ts +2 -2
  57. package/dist/lib/lanes/index.js +1 -1
  58. package/dist/lib/lanes/loop.d.ts +8 -0
  59. package/dist/lib/lanes/loop.js +35 -0
  60. package/dist/lib/lanes/quota-notice.d.ts +11 -11
  61. package/dist/lib/lanes/quota-notice.js +20 -29
  62. package/dist/lib/lanes/quota.d.ts +69 -50
  63. package/dist/lib/lanes/quota.js +146 -127
  64. package/dist/lib/lanes/reconcile-result.d.ts +10 -0
  65. package/dist/lib/lanes/reconcile-result.js +11 -0
  66. package/dist/lib/lanes/reconcile.d.ts +14 -0
  67. package/dist/lib/lanes/reconcile.js +77 -23
  68. package/dist/lib/lanes/watcher.d.ts +8 -0
  69. package/dist/lib/lanes/watcher.js +62 -20
  70. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -1
  71. package/dist/lib/mesh/live/daemon/work-subscribe.js +8 -1
  72. package/dist/lib/mesh/live/paths.d.ts +2 -0
  73. package/dist/lib/mesh/live/paths.js +2 -0
  74. package/dist/lib/mesh/story-status.d.ts +2 -2
  75. package/dist/lib/mesh/story-status.js +2 -1
  76. package/dist/utils/resident-package-preload.js +18 -4
  77. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
4
+ export { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
5
+ const INBOX_PUSH_SUBSCRIPTION_STALE_MS = 15_000;
6
+ function readWakeState(root, onError) {
7
+ const target = path.join(root, AGENT_INBOX_PUSH_STATE_BASENAME);
8
+ try {
9
+ const info = fs.lstatSync(target);
10
+ if (!info.isFile() || info.isSymbolicLink())
11
+ return { wakeSequence: 0, connected: false, heartbeatAtMs: 0 };
12
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
13
+ return {
14
+ wakeSequence: Number.isSafeInteger(parsed.wakeSequence) && Number(parsed.wakeSequence) >= 0
15
+ ? Number(parsed.wakeSequence)
16
+ : 0,
17
+ connected: parsed.connected === true,
18
+ heartbeatAtMs: typeof parsed.heartbeatAtMs === "number" && Number.isFinite(parsed.heartbeatAtMs)
19
+ ? parsed.heartbeatAtMs
20
+ : 0,
21
+ ...(typeof parsed.agentUid === "string" ? { agentUid: parsed.agentUid } : {}),
22
+ };
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "ENOENT")
26
+ onError?.(error);
27
+ return { wakeSequence: 0, connected: false, heartbeatAtMs: 0 };
28
+ }
29
+ }
30
+ /** Watches the content-free Work Mesh inbox sequence, with a bounded poll fallback. */
31
+ export function createInboxWakeWatcher(root, options = {}) {
32
+ const now = options.now ?? Date.now;
33
+ let lastState = readWakeState(root, options.onError);
34
+ let lastSequence = lastState.wakeSequence;
35
+ let closed = false;
36
+ let pendingWake = false;
37
+ const waiters = new Set();
38
+ let watcher;
39
+ const signalWake = () => {
40
+ pendingWake = waiters.size === 0;
41
+ for (const resolve of waiters)
42
+ resolve("wake");
43
+ waiters.clear();
44
+ };
45
+ try {
46
+ watcher = (options.watch ?? ((directory, listener) => fs.watch(directory, listener)))(root, (_event, filename) => {
47
+ if (filename !== null && filename.toString() !== AGENT_INBOX_PUSH_STATE_BASENAME)
48
+ return;
49
+ const nextState = readWakeState(root, options.onError);
50
+ const sequenceAdvanced = nextState.wakeSequence > lastSequence;
51
+ const subscriptionChanged = nextState.connected !== lastState.connected || nextState.agentUid !== lastState.agentUid;
52
+ lastState = nextState;
53
+ if (sequenceAdvanced) {
54
+ lastSequence = nextState.wakeSequence;
55
+ signalWake();
56
+ }
57
+ else if (subscriptionChanged) {
58
+ signalWake();
59
+ }
60
+ });
61
+ watcher.on?.("error", (error) => {
62
+ options.onError?.(error);
63
+ const failedWatcher = watcher;
64
+ watcher = undefined;
65
+ try {
66
+ failedWatcher?.close();
67
+ }
68
+ catch (closeError) {
69
+ options.onError?.(closeError);
70
+ }
71
+ });
72
+ // Close the read/register race: a writer can advance the sequence after
73
+ // the baseline read but before fs.watch is attached.
74
+ const currentState = readWakeState(root, options.onError);
75
+ if (currentState.wakeSequence > lastSequence) {
76
+ lastSequence = currentState.wakeSequence;
77
+ signalWake();
78
+ }
79
+ }
80
+ catch (error) {
81
+ options.onError?.(error);
82
+ }
83
+ return {
84
+ wait(delayMs, signal) {
85
+ if (closed || signal?.aborted)
86
+ return Promise.resolve("aborted");
87
+ if (pendingWake) {
88
+ pendingWake = false;
89
+ return Promise.resolve("wake");
90
+ }
91
+ return new Promise((resolve) => {
92
+ let settled = false;
93
+ const finish = (event) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ waiters.delete(finish);
99
+ signal?.removeEventListener("abort", abort);
100
+ resolve(event);
101
+ };
102
+ const abort = () => finish("aborted");
103
+ const timer = setTimeout(() => finish("timeout"), Math.max(0, delayMs));
104
+ waiters.add(finish);
105
+ signal?.addEventListener("abort", abort, { once: true });
106
+ });
107
+ },
108
+ isLiveFor(agentUid) {
109
+ const state = readWakeState(root, options.onError);
110
+ const ageMs = now() - state.heartbeatAtMs;
111
+ return state.connected && state.agentUid === agentUid &&
112
+ ageMs >= 0 && ageMs <= INBOX_PUSH_SUBSCRIPTION_STALE_MS;
113
+ },
114
+ close() {
115
+ if (closed)
116
+ return;
117
+ closed = true;
118
+ watcher?.close();
119
+ for (const resolve of waiters)
120
+ resolve("aborted");
121
+ waiters.clear();
122
+ },
123
+ };
124
+ }
125
+ //# sourceMappingURL=inbox-wake.js.map
@@ -58,7 +58,9 @@ import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js"
58
58
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
59
59
  import { type BotState, type BotStatusFile } from "./status.js";
60
60
  import { type CliUpdate, type CliUpdateWatch } from "./cli-update.js";
61
+ import { createInboxWakeWatcher } from "./inbox-wake.js";
61
62
  export declare const INBOX_POLL_INTERVAL_MS = 2000;
63
+ export declare const INBOX_MQTT_WAKE_FALLBACK_MS = 60000;
62
64
  export declare const HEARTBEAT_INTERVAL_MS = 30000;
63
65
  /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
64
66
  export declare const API_BACKOFF_BASE_MS = 4000;
@@ -154,6 +156,12 @@ export interface BotRunDeps {
154
156
  signInRetryMs?: number;
155
157
  /** How long model turns are held after FAILURE_LIMIT failures in a window (default FAILURE_WINDOW_MS). */
156
158
  modelHoldMs?: number;
159
+ /** Kill-switch reader for the Work Mesh MQTT inbox wake path. */
160
+ inboxMqttWakeEnabled?: () => Promise<boolean>;
161
+ /** Work Mesh state directory; tests use a scratch directory. */
162
+ inboxWakeRoot?: string;
163
+ /** Test seam for the inbox wake source. */
164
+ createInboxWakeWatcher?: typeof createInboxWakeWatcher;
157
165
  /**
158
166
  * Downloads a chat attachment's bytes with the bot's own machine identity
159
167
  * (vault presign). Unset = attachments are listed with a "cannot download"
@@ -89,8 +89,11 @@ import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
89
89
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
90
90
  import { CLI_VERSION } from "../../cli-version.js";
91
91
  import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
92
- import { createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
92
+ import { createInboxMqttWakeFlagReader, createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
93
+ import { createInboxWakeWatcher } from "./inbox-wake.js";
94
+ import { workMeshRoot } from "../mesh/live/paths.js";
93
95
  export const INBOX_POLL_INTERVAL_MS = 2_000;
96
+ export const INBOX_MQTT_WAKE_FALLBACK_MS = 60_000;
94
97
  export const HEARTBEAT_INTERVAL_MS = 30_000;
95
98
  /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
96
99
  export const API_BACKOFF_BASE_MS = 4_000;
@@ -337,6 +340,21 @@ export async function runBot(deps) {
337
340
  const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
338
341
  const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
339
342
  const inboxPollBackoffEnabled = deps.inboxPollBackoffEnabled ?? createInboxPollBackoffFlagReader(config);
343
+ const inboxMqttWakeEnabled = deps.inboxMqttWakeEnabled ?? createInboxMqttWakeFlagReader(config);
344
+ const makeInboxWakeWatcher = deps.createInboxWakeWatcher ?? createInboxWakeWatcher;
345
+ const inboxWakeWatcherRef = { current: null };
346
+ const getInboxWakeWatcher = () => {
347
+ const watcher = inboxWakeWatcherRef.current ?? makeInboxWakeWatcher(deps.inboxWakeRoot ?? workMeshRoot(), {
348
+ onError: (error) => log("warn", `Work Mesh inbox wake unavailable (${safeErrorClass(error)}); retaining the fallback inbox poll`),
349
+ });
350
+ inboxWakeWatcherRef.current = watcher;
351
+ return watcher;
352
+ };
353
+ const closeInboxWakeWatcher = () => {
354
+ const watcher = inboxWakeWatcherRef.current;
355
+ watcher?.close();
356
+ inboxWakeWatcherRef.current = null;
357
+ };
340
358
  const jobQuietOptionEnabled = deps.jobQuietOptionEnabled ?? botJobQuietOptionEnabled;
341
359
  const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
342
360
  const exit = deps.exit ?? ((code) => process.exit(code));
@@ -443,6 +461,7 @@ export async function runBot(deps) {
443
461
  if (state === "stopped" || state === "failed")
444
462
  return;
445
463
  state = final;
464
+ closeInboxWakeWatcher();
446
465
  setStatus({ state: final, ...(final === "failed" ? { lastError: reason.slice(0, 400) } : {}) });
447
466
  log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
448
467
  if (!deps.skipPidLock)
@@ -771,7 +790,7 @@ export async function runBot(deps) {
771
790
  inboxWaitGeneration += 1;
772
791
  activeInboxWait?.abort();
773
792
  };
774
- const waitForInbox = async (delayMs, adaptive) => {
793
+ const waitForInbox = async (delayMs, adaptive, useMqttWake = false) => {
775
794
  if (!adaptive) {
776
795
  await sleep(delayMs);
777
796
  return;
@@ -781,8 +800,13 @@ export async function runBot(deps) {
781
800
  while (remainingMs > 0 && !stopping && state === "running") {
782
801
  const controller = new AbortController();
783
802
  activeInboxWait = controller;
784
- const chunkMs = Math.min(inboxIntervalMs, remainingMs);
785
- await sleep(chunkMs, controller.signal);
803
+ const chunkMs = useMqttWake ? remainingMs : Math.min(inboxIntervalMs, remainingMs);
804
+ if (useMqttWake) {
805
+ await getInboxWakeWatcher().wait(chunkMs, controller.signal);
806
+ }
807
+ else {
808
+ await sleep(chunkMs, controller.signal);
809
+ }
786
810
  if (activeInboxWait === controller)
787
811
  activeInboxWait = null;
788
812
  if (stopping || state !== "running")
@@ -795,9 +819,8 @@ export async function runBot(deps) {
795
819
  continue;
796
820
  }
797
821
  remainingMs -= chunkMs;
798
- // Keep the install watcher at its original two-second cadence even when
799
- // the inbox itself is waiting longer between empty polls.
800
- if (remainingMs > 0 && pollForCliUpdate())
822
+ // The MQTT wake or bounded fallback also checks for CLI updates.
823
+ if (remainingMs > 0 && !useMqttWake && pollForCliUpdate())
801
824
  break;
802
825
  }
803
826
  };
@@ -2332,6 +2355,18 @@ export async function runBot(deps) {
2332
2355
  log("warn", `inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default-off`);
2333
2356
  return false;
2334
2357
  });
2358
+ const useMqttWake = await inboxMqttWakeEnabled().catch((error) => {
2359
+ log("warn", `inbox MQTT wake flag lookup failed (${safeErrorClass(error)}); using default-on`);
2360
+ return true;
2361
+ });
2362
+ // Arm before pulling so a wake arriving between the pull and wait is not
2363
+ // lost. The watcher also checks the sequence again after registration.
2364
+ if (useMqttWake) {
2365
+ getInboxWakeWatcher();
2366
+ }
2367
+ else {
2368
+ closeInboxWakeWatcher();
2369
+ }
2335
2370
  let items;
2336
2371
  try {
2337
2372
  items = await api.pullInbox(config.agentUid);
@@ -2361,6 +2396,15 @@ export async function runBot(deps) {
2361
2396
  }
2362
2397
  if (stopping || state !== "running")
2363
2398
  break;
2399
+ if (useMqttWake) {
2400
+ const hasLiveInboxSubscription = items.length === 0 &&
2401
+ getInboxWakeWatcher().isLiveFor?.(config.agentUid) === true;
2402
+ const delayMs = items.length > 0 || !hasLiveInboxSubscription
2403
+ ? inboxIntervalMs
2404
+ : INBOX_MQTT_WAKE_FALLBACK_MS;
2405
+ await waitForInbox(delayMs, true, true);
2406
+ continue;
2407
+ }
2364
2408
  if (!useInboxBackoff) {
2365
2409
  inboxBackoffIndex = 0;
2366
2410
  await waitForInbox(inboxIntervalMs, false);
@@ -87,7 +87,7 @@ export function messagesStreamResult(stdout, runtimeName) {
87
87
  if (!sawJson)
88
88
  throw new RuntimeError(`${runtimeName} returned non-JSON output`, stdout.trim().slice(-400), null, { excerptIsOutputTail: true });
89
89
  if (result?.is_error === true) {
90
- const detail = typeof result.result === "string" && result.result ? result.result : String(result.subtype ?? "error");
90
+ const detail = typeof result.result === "string" && result.result ? result.result : (resultErrors(result) ?? String(result.subtype ?? "error"));
91
91
  throw new RuntimeError(`${runtimeName} reported an error: ${detail.slice(0, 200)}`, detail.slice(0, 400), null);
92
92
  }
93
93
  const text = (typeof result?.result === "string" && result.result.trim() ? result.result : lastText) ?? "";
@@ -143,8 +143,12 @@ export function messagesStreamFailure(stdout) {
143
143
  let reason;
144
144
  for (const line of stdout.split("\n")) {
145
145
  const ev = parseJsonLine(line);
146
- if (ev?.type === "result" && ev.is_error === true && typeof ev.result === "string" && ev.result.trim())
147
- reason = ev.result.trim();
146
+ if (ev?.type === "result" && ev.is_error === true) {
147
+ if (typeof ev.result === "string" && ev.result.trim())
148
+ reason = ev.result.trim();
149
+ else
150
+ reason = resultErrors(ev) ?? reason;
151
+ }
148
152
  if (ev?.type === "error" && typeof ev.message === "string" && ev.message.trim())
149
153
  reason = ev.message.trim();
150
154
  }
@@ -187,8 +191,32 @@ export function prettyJsonFailure(stdout) {
187
191
  return ev.result.trim();
188
192
  if (typeof ev.message === "string" && ev.message.trim())
189
193
  return ev.message.trim();
194
+ const errors = resultErrors(ev);
195
+ if (errors)
196
+ return errors;
190
197
  if (typeof ev.subtype === "string" && ev.subtype.trim() && ev.subtype !== "success")
191
198
  return ev.subtype.trim();
192
199
  return undefined;
193
200
  }
201
+ /**
202
+ * The `errors` array on a Claude result envelope. A failed `--resume` ends
203
+ * with `{"subtype":"error_during_execution","is_error":true,"errors":["No
204
+ * conversation found with session ID: …"]}` and no `result` text; without
205
+ * this the failure reads as the bare subtype, the stale-session check never
206
+ * matches, and every turn on that scope fails the same way.
207
+ */
208
+ function resultErrors(ev) {
209
+ if (!Array.isArray(ev.errors))
210
+ return undefined;
211
+ const parts = ev.errors.flatMap((e) => {
212
+ if (typeof e === "string")
213
+ return e.trim() ? [e.trim()] : [];
214
+ if (e && typeof e === "object" && typeof e.message === "string") {
215
+ const m = e.message.trim();
216
+ return m ? [m] : [];
217
+ }
218
+ return [];
219
+ });
220
+ return parts.length > 0 ? parts.join("; ") : undefined;
221
+ }
194
222
  //# sourceMappingURL=messages-stream.js.map
@@ -82,6 +82,7 @@ function canonicalHome(home, homedir) {
82
82
  }
83
83
  function handlerDeps(printJson) {
84
84
  return {
85
+ admitQueuedFromCtx: async () => [],
85
86
  automaticBalanceMoveHistoryReason: () => unused("automaticBalanceMoveHistoryReason"),
86
87
  ensureDir: (dir) => {
87
88
  fs.mkdirSync(dir, { recursive: true });
@@ -109,6 +109,10 @@ export interface PlanContext {
109
109
  syncRunnerProcesses: readonly SyncRunnerProcess[] | null;
110
110
  /** PID recorded in the watch-owner status, when it is readable. */
111
111
  syncRunnerOwnerPid: number | null;
112
+ /** The daemon's in-process lane watcher owns reconcile while its kill switch is on. */
113
+ lanesReconcileInProcess?: boolean;
114
+ /** The active hq-cloud sync runner owns post-sync QMD indexing. */
115
+ searchIndexOwnedBySyncRunner?: boolean;
112
116
  /** Sync runner children already started by this daemon before their lease is written. */
113
117
  daemonSyncRunnerPids?: ReadonlySet<number>;
114
118
  /** The protocol-event heartbeat watchdog timeout for the sync runner. */
@@ -227,14 +227,15 @@ function planService(service, ctx) {
227
227
  }),
228
228
  ];
229
229
  case "lanes-reconcile":
230
+ if (ctx.lanesReconcileInProcess)
231
+ return [];
230
232
  return [
231
233
  base(service, {
232
234
  kind: "scheduled",
233
235
  // Polls every 15 s so local keep-alive resumes are admitted on time (#1396).
234
236
  // Each pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS), so
235
- // desktops first check the lane store in-process
236
- // (lanes-reconcile-guard.ts) and fall back to the pre-#1396 60 s
237
- // cadence, or no run, when no lane can need the 15 s poll.
237
+ // desktops reconcile in-process on lane-store changes with a bounded
238
+ // timer for due keep-alive work, avoiding a fresh CLI process per poll.
238
239
  intervalSec: 15,
239
240
  initialDelaySec: 30,
240
241
  command: { type: "hq", args: ["lanes", "reconcile"] },
@@ -246,6 +247,8 @@ function planService(service, ctx) {
246
247
  }),
247
248
  ];
248
249
  case "search-index":
250
+ if (ctx.searchIndexOwnedBySyncRunner)
251
+ return [];
249
252
  return [
250
253
  base(service, {
251
254
  kind: "scheduled",
@@ -1,5 +1,7 @@
1
1
  export interface BoundedDaemonFlagMonitorOptions {
2
2
  read: () => Promise<boolean>;
3
+ /** Registry default, also used for absent rows and stale reads. */
4
+ fallback?: boolean;
3
5
  now?: () => number;
4
6
  onChange?: (enabled: boolean) => void;
5
7
  onReadError?: (error: unknown) => void;
@@ -15,7 +17,8 @@ export interface BoundedDaemonFlagMonitor {
15
17
  }
16
18
  /**
17
19
  * Refreshes a long-running daemon's flag independently of incoming requests.
18
- * A failed read keeps the last successful decision only until its 60s age limit.
20
+ * A failed read keeps a default-off setting only until its 60s age limit; a
21
+ * default-on kill switch immediately returns to its registered safe default.
19
22
  */
20
23
  export declare function createBoundedDaemonFlagMonitor(options: BoundedDaemonFlagMonitorOptions): BoundedDaemonFlagMonitor;
21
24
  //# sourceMappingURL=flag-monitor.d.ts.map
@@ -3,11 +3,13 @@ const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
3
3
  const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
4
4
  /**
5
5
  * Refreshes a long-running daemon's flag independently of incoming requests.
6
- * A failed read keeps the last successful decision only until its 60s age limit.
6
+ * A failed read keeps a default-off setting only until its 60s age limit; a
7
+ * default-on kill switch immediately returns to its registered safe default.
7
8
  */
8
9
  export function createBoundedDaemonFlagMonitor(options) {
9
10
  const now = options.now ?? (() => performance.now());
10
- let value;
11
+ const fallback = options.fallback ?? false;
12
+ let value = fallback ? true : undefined;
11
13
  let lastSuccessAt;
12
14
  let lastAttemptAt;
13
15
  let lastReadFailed = false;
@@ -23,7 +25,7 @@ export function createBoundedDaemonFlagMonitor(options) {
23
25
  };
24
26
  const expireIfStale = () => {
25
27
  if (!closed && lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
26
- publish(false);
28
+ publish(fallback);
27
29
  }
28
30
  };
29
31
  const refresh = () => {
@@ -44,13 +46,17 @@ export function createBoundedDaemonFlagMonitor(options) {
44
46
  // Expire from the elapsed timer itself, not a wall-clock comparison.
45
47
  expiryTimer = setTimeout(() => {
46
48
  if (!closed && successVersion === thisSuccessVersion)
47
- publish(false);
49
+ publish(fallback);
48
50
  }, HQ_ANYWHERE_FLAG_MAX_STALENESS_MS);
49
51
  expiryTimer.unref?.();
50
52
  return next;
51
53
  }).catch((error) => {
52
54
  lastReadFailed = true;
53
55
  options.onReadError?.(error);
56
+ if (fallback) {
57
+ publish(true);
58
+ return true;
59
+ }
54
60
  expireIfStale();
55
61
  return value ?? false;
56
62
  }).finally(() => {
@@ -69,7 +75,7 @@ export function createBoundedDaemonFlagMonitor(options) {
69
75
  expireIfStale();
70
76
  if (lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
71
77
  void refresh();
72
- return false;
78
+ return fallback;
73
79
  }
74
80
  if (value === undefined)
75
81
  return refresh();
@@ -82,7 +88,7 @@ export function createBoundedDaemonFlagMonitor(options) {
82
88
  const age = lastSuccessAt === undefined ? null : Math.max(0, now() - lastSuccessAt);
83
89
  const fresh = age !== null && age < HQ_ANYWHERE_FLAG_MAX_STALENESS_MS;
84
90
  return {
85
- enabled: value === true && fresh,
91
+ enabled: value === true && (fresh || fallback),
86
92
  lastSuccessfulReadAgeMs: age,
87
93
  heldThroughReadError: lastReadFailed && fresh,
88
94
  };
@@ -17,6 +17,8 @@ export interface DaemonFlagClient {
17
17
  }
18
18
  export interface ReadDaemonFlagOptions {
19
19
  env?: NodeJS.ProcessEnv;
20
+ /** Registry default for this key; new kill switches use true. */
21
+ fallback?: boolean;
20
22
  /** Controls how this read combines a local person setting with the registry value. */
21
23
  localOverrideMode?: "respect" | "off-only" | "ignore";
22
24
  createClient?: (options: FlagClientOptions) => DaemonFlagClient;
@@ -17,7 +17,7 @@ export async function readDaemonFlag(flagKey, options = {}) {
17
17
  const env = options.env ?? process.env;
18
18
  if (flagKey === HQ_ANYWHERE_RUNTIME_FLAG && hqAnywhereRuntimeLocallyDisabled(env))
19
19
  return false;
20
- const fallback = flagKey === HQ_ANYWHERE_RUNTIME_FLAG;
20
+ const fallback = options.fallback ?? flagKey === HQ_ANYWHERE_RUNTIME_FLAG;
21
21
  const override = readLocalOverride(flagKey, env);
22
22
  const localOverrideMode = options.localOverrideMode ?? "respect";
23
23
  if (override !== null && (localOverrideMode === "respect" || (localOverrideMode === "off-only" && !override)))
@@ -53,8 +53,11 @@ export async function readDaemonFlag(flagKey, options = {}) {
53
53
  onError(error);
54
54
  }
55
55
  const resolution = client.explain(flagKey, { fallback });
56
- if (failed && options.throwOnError && !fallback) {
57
- throw new Error("hq-flags registry read failed", { cause: reportedError });
56
+ if (failed) {
57
+ if (options.throwOnError && !fallback) {
58
+ throw new Error("hq-flags registry read failed", { cause: reportedError });
59
+ }
60
+ return fallback;
58
61
  }
59
62
  if (resolution.source === "snapshot")
60
63
  return resolution.value === true;
@@ -63,8 +66,11 @@ export async function readDaemonFlag(flagKey, options = {}) {
63
66
  catch (error) {
64
67
  if (!failed)
65
68
  onError(error);
66
- if (options.throwOnError && failed && !fallback) {
67
- throw new Error("hq-flags registry read failed", { cause: reportedError ?? error });
69
+ if (failed) {
70
+ if (options.throwOnError && !fallback) {
71
+ throw new Error("hq-flags registry read failed", { cause: reportedError ?? error });
72
+ }
73
+ return fallback;
68
74
  }
69
75
  return fallback;
70
76
  }
@@ -137,9 +137,11 @@ function relaySignal(signal) {
137
137
  throw new Error(`Unable to relay ${signal}`);
138
138
  }
139
139
  async function run() {
140
- const [prefix, version, ...args] = process.argv.slice(2);
140
+ const [prefix, version, ...rawArgs] = process.argv.slice(2);
141
141
  if (!prefix || !version)
142
142
  throw new Error("The daemon hq bootstrap arguments are invalid");
143
+ const watchHandoff = rawArgs[0] !== "--handoff-watch=off";
144
+ const args = watchHandoff ? rawArgs : rawArgs.slice(1);
143
145
  const lease = await acquireBootstrapLease(prefix, version);
144
146
  const token = randomBytes(16).toString("hex");
145
147
  const markerPathFor = (pid) => path.join(lease.directory, `handoff-${pid}-${token}.handoff`);
@@ -170,16 +172,54 @@ async function run() {
170
172
  const exit = await new Promise((resolve, reject) => {
171
173
  if (!child)
172
174
  return reject(new Error("The HQ CLI child did not start"));
173
- child.once("error", reject);
174
- child.once("exit", (code, signal) => resolve({ code, signal }));
175
- const poll = setInterval(() => {
175
+ let poll;
176
+ let watcher;
177
+ const closeHandoffWatch = () => {
178
+ if (poll)
179
+ clearInterval(poll);
180
+ poll = undefined;
181
+ watcher?.close();
182
+ watcher = undefined;
183
+ };
184
+ const releaseOnHandoff = () => {
176
185
  if (!handoffMarker || leaseReleased || !fs.existsSync(handoffMarker))
177
186
  return;
178
187
  fs.rmSync(handoffMarker, { force: true });
179
188
  releaseLease();
180
- }, 20);
181
- child.once("exit", () => clearInterval(poll));
182
- child.once("error", () => clearInterval(poll));
189
+ closeHandoffWatch();
190
+ };
191
+ const startHandoffPolling = () => {
192
+ if (!poll && !leaseReleased)
193
+ poll = setInterval(releaseOnHandoff, 20);
194
+ };
195
+ child.once("error", reject);
196
+ child.once("exit", (code, signal) => resolve({ code, signal }));
197
+ child.once("exit", closeHandoffWatch);
198
+ child.once("error", closeHandoffWatch);
199
+ if (watchHandoff && handoffMarker) {
200
+ try {
201
+ watcher = fs.watch(lease.directory, (_event, filename) => {
202
+ if (filename === null || filename.toString() === path.basename(handoffMarker))
203
+ releaseOnHandoff();
204
+ });
205
+ watcher.on("error", (error) => {
206
+ process.stderr.write(`hq daemon child bootstrap handoff watch failed (${error.code ?? error.name}); using the recovery poll\n`);
207
+ watcher?.close();
208
+ watcher = undefined;
209
+ startHandoffPolling();
210
+ });
211
+ // Close the create-before-watch race after the watcher is attached.
212
+ releaseOnHandoff();
213
+ }
214
+ catch (error) {
215
+ process.stderr.write(`hq daemon child bootstrap handoff watch unavailable (${error.code ?? "UnknownError"}); using the recovery poll\n`);
216
+ startHandoffPolling();
217
+ releaseOnHandoff();
218
+ }
219
+ }
220
+ else {
221
+ startHandoffPolling();
222
+ }
183
223
  });
184
224
  if (!leaseReleased)
185
225
  releaseLease();
@@ -0,0 +1,5 @@
1
+ export declare const DAEMON_CHILD_BOOTSTRAP_HANDOFF_WATCH_FLAG = "daemon.child-bootstrap-handoff-watch";
2
+ export declare const DAEMON_LANES_RECONCILE_FILE_WATCH_FLAG = "daemon.lanes-reconcile-file-watch";
3
+ export declare const DAEMON_SYNC_PROCESS_SCAN_ON_MISSING_FLAG = "daemon.sync-process-scan-on-missing";
4
+ export declare const DAEMON_SEARCH_INDEX_RUNNER_OWNS_INDEX_FLAG = "daemon.search-index-runner-owns-index";
5
+ //# sourceMappingURL=kill-switch-flags.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const DAEMON_CHILD_BOOTSTRAP_HANDOFF_WATCH_FLAG = "daemon.child-bootstrap-handoff-watch";
2
+ export const DAEMON_LANES_RECONCILE_FILE_WATCH_FLAG = "daemon.lanes-reconcile-file-watch";
3
+ export const DAEMON_SYNC_PROCESS_SCAN_ON_MISSING_FLAG = "daemon.sync-process-scan-on-missing";
4
+ export const DAEMON_SEARCH_INDEX_RUNNER_OWNS_INDEX_FLAG = "daemon.search-index-runner-owns-index";
5
+ //# sourceMappingURL=kill-switch-flags.js.map
@@ -0,0 +1,7 @@
1
+ export interface DaemonLanesReconcileRegistrationOptions {
2
+ hqRoot: () => string;
3
+ env: NodeJS.ProcessEnv;
4
+ }
5
+ /** Load the lazy lanes command root only when the daemon needs its reconciler. */
6
+ export declare function ensureDaemonLanesReconcileHandler(options: DaemonLanesReconcileRegistrationOptions): Promise<void>;
7
+ //# sourceMappingURL=lanes-reconcile-loader.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { daemonLanesReconcileHandler } from "../lanes/daemon-reconcile.js";
2
+ async function registerFromLanesCommand(options) {
3
+ const [{ Command }, { registerLanesCommand }] = await Promise.all([
4
+ import("commander"),
5
+ import("../../commands/lanes.js"),
6
+ ]);
7
+ const env = { ...options.env };
8
+ delete env.HQ_ROOT;
9
+ registerLanesCommand(new Command(), {
10
+ hqRoot: options.hqRoot,
11
+ cwd: options.hqRoot,
12
+ env,
13
+ });
14
+ }
15
+ /** Load the lazy lanes command root only when the daemon needs its reconciler. */
16
+ export async function ensureDaemonLanesReconcileHandler(options) {
17
+ if (daemonLanesReconcileHandler())
18
+ return;
19
+ await registerFromLanesCommand(options);
20
+ if (!daemonLanesReconcileHandler()) {
21
+ throw new Error("lanes command did not register its daemon reconcile handler");
22
+ }
23
+ }
24
+ //# sourceMappingURL=lanes-reconcile-loader.js.map
@@ -0,0 +1,17 @@
1
+ import * as fs from "node:fs";
2
+ type Watcher = Pick<fs.FSWatcher, "close">;
3
+ export interface LanesReconcileWatcher {
4
+ close(): void;
5
+ }
6
+ /** Reconcile at startup, on lane-store changes, and on a bounded in-process timer. */
7
+ export declare function createLanesReconcileWatcher(options: {
8
+ directories: readonly string[];
9
+ reconcile: () => Promise<void>;
10
+ debounceMs?: number;
11
+ intervalMs?: number;
12
+ watch?: (directory: string, listener: (event: string, filename: string | Buffer | null) => void) => Watcher;
13
+ onError?: (error: unknown) => void;
14
+ onWatchError?: (error: unknown) => void;
15
+ }): LanesReconcileWatcher;
16
+ export {};
17
+ //# sourceMappingURL=lanes-reconcile-watcher.d.ts.map