@indigoai-us/hq-cli 5.345.128 → 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 (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/core-bucket-c-work-mesh-offer-native.js +8 -2
  3. package/dist/commands/daemon.js +2 -0
  4. package/dist/commands/lanes.js +26 -14
  5. package/dist/commands/mesh.js +32 -19
  6. package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +3 -5
  7. package/dist/lib/bot/inbox-poll-backoff-flag.js +20 -12
  8. package/dist/lib/bot/inbox-wake.d.ts +18 -0
  9. package/dist/lib/bot/inbox-wake.js +125 -0
  10. package/dist/lib/bot/run.d.ts +8 -0
  11. package/dist/lib/bot/run.js +51 -7
  12. package/dist/lib/bot/runtime/messages-stream.js +31 -3
  13. package/dist/lib/daemon/catalog.d.ts +4 -0
  14. package/dist/lib/daemon/catalog.js +6 -3
  15. package/dist/lib/daemon/flag-monitor.d.ts +4 -1
  16. package/dist/lib/daemon/flag-monitor.js +12 -6
  17. package/dist/lib/daemon/flag.d.ts +2 -0
  18. package/dist/lib/daemon/flag.js +11 -5
  19. package/dist/lib/daemon/hq-child-bootstrap.mjs +47 -7
  20. package/dist/lib/daemon/kill-switch-flags.d.ts +5 -0
  21. package/dist/lib/daemon/kill-switch-flags.js +5 -0
  22. package/dist/lib/daemon/lanes-reconcile-loader.d.ts +7 -0
  23. package/dist/lib/daemon/lanes-reconcile-loader.js +24 -0
  24. package/dist/lib/daemon/lanes-reconcile-watcher.d.ts +17 -0
  25. package/dist/lib/daemon/lanes-reconcile-watcher.js +80 -0
  26. package/dist/lib/daemon/remote-control.d.ts +8 -5
  27. package/dist/lib/daemon/remote-control.js +8 -7
  28. package/dist/lib/daemon/run.d.ts +4 -1
  29. package/dist/lib/daemon/run.js +133 -20
  30. package/dist/lib/doctor/json-output.d.ts +15 -3
  31. package/dist/lib/doctor/json-output.js +13 -1
  32. package/dist/lib/lanes/daemon-reconcile.d.ts +3 -0
  33. package/dist/lib/lanes/daemon-reconcile.js +8 -0
  34. package/dist/lib/lanes/reconcile-result.d.ts +10 -0
  35. package/dist/lib/lanes/reconcile-result.js +11 -0
  36. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -1
  37. package/dist/lib/mesh/live/daemon/work-subscribe.js +8 -1
  38. package/dist/lib/mesh/live/paths.d.ts +2 -0
  39. package/dist/lib/mesh/live/paths.js +2 -0
  40. package/dist/lib/mesh/story-status.d.ts +2 -2
  41. package/dist/lib/mesh/story-status.js +2 -1
  42. package/dist/utils/resident-package-preload.js +6 -0
  43. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.129] — 2026-10-14
6
+
7
+ - Claude Remote Control account pools now stay connected across Claude CLI updates. The daemon still restarts a pool if its host exits, but no longer kills busy pool sessions after the two-hour update deadline.
8
+
9
+ - The resident daemon now preloads the lane reconcile module that its in-process lane watcher loads. Main CI had been failing the resident preload coverage test since the idle-wakeup change (#1920).
10
+ - Session-start health no longer files a stale personal sync-manifest warning (`sync.manifest.personal`) as an unrepaired problem after `hq doctor --fix`. `--fix` cannot upload personal manifests, so `hq doctor --json` and `--client-health-report` now omit that WARN the same way they omit the leftover personal-journal WARN. A FAIL on that row still shows, and plain `hq doctor` still prints the WARN.
11
+ - Local bots now recover on their own when a saved Claude conversation no longer
12
+ exists. Before, every message in that chat failed with
13
+ `error_during_execution` until the bot's session file was removed by hand.
14
+ - Fix mesh project ensure dropping PRD stories in the queued state. PRD files without a userStories array now fail before Board writes, and unsupported ledger states cannot produce a partial projection.
15
+ - Idle local bots now wake from Work Mesh inbox notifications with a 60-second recovery pull. The daemon watches lane and child-handoff files, scans for sync processes only when its unit is missing, and leaves post-sync reindexing to compatible hq-cloud 6.18.75 or later runners.
16
+
5
17
  ## [5.345.128] — 2026-10-14
6
18
  - `hq auth refresh` now says which identity it ensured and where its token file is, for example `HQ session ensured (machine identity <name>; token file: <path>)` or `HQ session ensured (person session; token file: <path>)`. The identity is read after the refresh, so a fallback to the machine identity is reported as machine. Token values are never printed.
7
19
  - `hq auth refresh --json` prints `{refreshed, tokenSource, tokenFile, reason?}` on stdout and exits 1 when no session was refreshed.
@@ -37,8 +37,14 @@ function cloudUid(root, company) {
37
37
  }
38
38
  if (inCompany && /^ {2}[^\s#]/.test(line))
39
39
  break;
40
- if (inCompany && /^\s+cloud_uid:/.test(line))
41
- return (line.split(":")[1] ?? "").trim().replace(/["']/g, "");
40
+ if (inCompany) {
41
+ // Match the shell's awk field contract: `$1 == "cloud_uid:"` and use
42
+ // only `$2`. A value must be separated from the key by whitespace;
43
+ // trailing comments and later fields do not become part of the UID.
44
+ const [key, value] = line.trim().split(/\s+/);
45
+ if (key === "cloud_uid:")
46
+ return (value ?? "").replace(/["']/g, "");
47
+ }
42
48
  }
43
49
  return "";
44
50
  }
@@ -907,6 +907,7 @@ async function runTmuxService(d, opts) {
907
907
  cwd: hqRoot,
908
908
  argv: opts.argv,
909
909
  label: opts.label,
910
+ restartOnToolUpdate: opts.restartOnToolUpdate,
910
911
  // Only a new version of the tool restarts the session: not a same-version
911
912
  // reinstall, and not an hq-cli release (the host's own code rarely matters).
912
913
  binaryFingerprint: () => toolFingerprint(opts.watch),
@@ -1094,6 +1095,7 @@ async function runServiceRemoteControlPoolHost(d, accountId) {
1094
1095
  argv: [claude, ...remoteControlPoolArgs(pool)],
1095
1096
  label: `claude remote-control pool ${accountId}`,
1096
1097
  watch: claude,
1098
+ restartOnToolUpdate: false,
1097
1099
  signal: controller.signal,
1098
1100
  sessionEnv: {
1099
1101
  HOME: d.home,
@@ -24,6 +24,7 @@ export { resolveAnswerChoice } from "./lanes-questions.js";
24
24
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
25
25
  import { registerLaneKpisCommand } from "./lanes-kpis.js";
26
26
  import { registerLaneKeepAliveAuditCommand } from "./lanes-keep-alive-audit.js";
27
+ import { finishReconcilePass } from "../lib/lanes/reconcile-result.js";
27
28
  import { registerLaneAutopilotReportCommand } from "./lanes-autopilot.js";
28
29
  import { registerLaneChecksCommand } from "./lanes-checks.js";
29
30
  import { registerLaneDropboxRepairCommand } from "./lanes-dropbox-repair.js";
@@ -41,6 +42,7 @@ import { runResidentCommandWithPackageLease } from "../utils/resident-package-pr
41
42
  import { codexEnvEvidence, grokEnvEvidence } from "../lib/doctor/platform.js";
42
43
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
43
44
  import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
45
+ import { registerDaemonLanesReconcileHandler } from "../lib/lanes/daemon-reconcile.js";
44
46
  import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
45
47
  import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
46
48
  import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "../lib/lanes/flag-keys.js";
@@ -9538,21 +9540,21 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
9538
9540
  summary.errors.push({ code: error instanceof LanesError ? error.error : "lead_autopilot_failed", message });
9539
9541
  process.stderr.write(`hq lanes autopilot: ${message}\n`);
9540
9542
  }
9541
- printJson({
9542
- ...summary,
9543
- ...(autopilotRegistrySync ? { autopilot_registry_sync: autopilotRegistrySync } : {}),
9544
- ...(autopilot ? { autopilot } : {}),
9545
- ...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
9546
- ...(mirrorRefresh.unverifiedRemote.size > 0
9547
- ? {
9548
- unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()]
9549
- .filter((id) => !terminalizedRemoteIds.has(id)),
9550
- }
9551
- : {}),
9552
- });
9553
- if (!summary.ok) {
9554
- process.exit(1);
9543
+ if (!opts.inProcess) {
9544
+ printJson({
9545
+ ...summary,
9546
+ ...(autopilotRegistrySync ? { autopilot_registry_sync: autopilotRegistrySync } : {}),
9547
+ ...(autopilot ? { autopilot } : {}),
9548
+ ...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
9549
+ ...(mirrorRefresh.unverifiedRemote.size > 0
9550
+ ? {
9551
+ unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()]
9552
+ .filter((id) => !terminalizedRemoteIds.has(id)),
9553
+ }
9554
+ : {}),
9555
+ });
9555
9556
  }
9557
+ finishReconcilePass(summary, opts.inProcess ? "daemon" : "cli");
9556
9558
  }
9557
9559
  async function runReconcileInstall(ctx, opts) {
9558
9560
  // `hq daemon` runs lanes-reconcile on this machine; a separate timer would run it twice.
@@ -9757,6 +9759,16 @@ export function registerLanesCommand(parent, deps = {}) {
9757
9759
  withResumeHireAdmission,
9758
9760
  };
9759
9761
  const messageHandlers = createLaneMessageHandlers(messageHandlerDeps);
9762
+ const daemonReconcileCtx = { ...ctx, env: { ...ctx.env } };
9763
+ registerDaemonLanesReconcileHandler(async () => {
9764
+ const hqRoot = daemonReconcileCtx.hqRoot();
9765
+ daemonReconcileCtx.env.HQ_ROOT = hqRoot;
9766
+ daemonReconcileCtx.cwd = () => hqRoot;
9767
+ daemonReconcileCtx.callerCwd = () => hqRoot;
9768
+ await runReconcile(daemonReconcileCtx, { inProcess: true }, async (lane) => {
9769
+ await messageHandlers.recoverQueuedMessages(daemonReconcileCtx, lane.lane_id);
9770
+ });
9771
+ });
9760
9772
  monitorLaneMessageApis.set(parent, (laneId, payload) => messageHandlers.deliverMonitorLaneMessage(ctx, laneId, payload));
9761
9773
  const resumeHandlers = createLaneResumeHandlers({
9762
9774
  onLaneWorkerStarted: deps.onLaneWorkerStarted,
@@ -307,16 +307,21 @@ function parseProjectReposJson(raw, source) {
307
307
  throw err;
308
308
  }
309
309
  }
310
- function projectStoriesFromJson(value, source) {
310
+ function projectStoriesFromJson(value, source, options = {}) {
311
311
  const plan = Array.isArray(value) ? null : record(value);
312
- const rawStories = Array.isArray(value)
313
- ? value
314
- : Array.isArray(plan?.userStories)
315
- ? plan.userStories
316
- : Array.isArray(plan?.stories)
317
- ? plan.stories
318
- : null;
319
- if (!rawStories) {
312
+ if (options.requireUserStories && (!plan || !Array.isArray(plan.userStories))) {
313
+ fail(`${source} must be a PRD object with a userStories array.`);
314
+ }
315
+ const rawStories = options.requireUserStories
316
+ ? plan?.userStories ?? null
317
+ : Array.isArray(value)
318
+ ? value
319
+ : Array.isArray(plan?.userStories)
320
+ ? plan.userStories
321
+ : Array.isArray(plan?.stories)
322
+ ? plan.stories
323
+ : null;
324
+ if (!Array.isArray(rawStories)) {
320
325
  fail(`${source} must be a JSON story array or an object with userStories.`);
321
326
  }
322
327
  const rejectedStories = [];
@@ -398,7 +403,7 @@ function failProjectStoryStates(rejected, json) {
398
403
  }
399
404
  function parseProjectPlan(raw, source, options = {}) {
400
405
  try {
401
- const plan = projectStoriesFromJson(JSON.parse(raw), source);
406
+ const plan = projectStoriesFromJson(JSON.parse(raw), source, options);
402
407
  if (plan.rejectedStories.length && !options.allowRejectedStories) {
403
408
  throw new RejectedProjectStoryStatesError(plan.rejectedStories);
404
409
  }
@@ -431,7 +436,7 @@ async function runProjectSet(projectId, opts) {
431
436
  fail("Pass only one of --repos-json or --repo.");
432
437
  }
433
438
  const source = opts.storiesFile
434
- ? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile)
439
+ ? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true })
435
440
  : opts.storiesJson
436
441
  ? parseProjectPlan(opts.storiesJson, "--stories-json")
437
442
  : undefined;
@@ -588,12 +593,17 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
588
593
  async function runProjectEnsure(projectId, opts) {
589
594
  if (!opts.storiesFile?.trim())
590
595
  fail("`--stories-file <prd.json>` is required.");
591
- const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { allowRejectedStories: true });
592
- const { token, company } = await withCompany(opts);
593
- if (source.rejectedStories.length && source.stories.length === 0) {
594
- failProjectStoryStates(source.rejectedStories, Boolean(opts.json));
596
+ let source;
597
+ try {
598
+ source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
599
+ }
600
+ catch (err) {
601
+ if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
602
+ throw err;
603
+ failProjectStoryStates(err.rejected, true);
595
604
  return;
596
605
  }
606
+ const { token, company } = await withCompany(opts);
597
607
  let current;
598
608
  try {
599
609
  current = await getProjectView(token, company.companyUid, projectId);
@@ -738,7 +748,10 @@ async function runProjectEnsure(projectId, opts) {
738
748
  async function runProjectReconcile(projectId, opts) {
739
749
  if (!opts.storiesFile?.trim())
740
750
  fail("`--stories-file <prd.json>` is required.");
741
- const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { allowRejectedStories: true });
751
+ const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, {
752
+ requireUserStories: true,
753
+ allowRejectedStories: true,
754
+ });
742
755
  const { token, company } = await withCompany(opts);
743
756
  let current;
744
757
  try {
@@ -852,7 +865,7 @@ async function runPrdSync(opts) {
852
865
  return;
853
866
  }
854
867
  try {
855
- const source = parseProjectPlan(readFileSync(file, "utf8"), file);
868
+ const source = parseProjectPlan(readFileSync(file, "utf8"), file, { requireUserStories: true });
856
869
  await ensureMissingBoardStories({
857
870
  token,
858
871
  company: {
@@ -1091,7 +1104,7 @@ async function runBindProject(target, opts) {
1091
1104
  }
1092
1105
  if (!current.companyUid)
1093
1106
  fail(`Session ${sessionId} has no company. Bind a company first.`);
1094
- const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
1107
+ const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
1095
1108
  const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
1096
1109
  await ensureProjectBoard({
1097
1110
  token,
@@ -1234,7 +1247,7 @@ async function runContextReconcile(opts) {
1234
1247
  validateMembership,
1235
1248
  ensureProjectBoard: token
1236
1249
  ? async ({ companyUid, projectId, prdPath }) => {
1237
- const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
1250
+ const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
1238
1251
  await ensureProjectBoard({
1239
1252
  token,
1240
1253
  company: { companyUid, companySlug: companyUid },
@@ -2,6 +2,8 @@ import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-c
2
2
  import { type BotConfig } from "./config.js";
3
3
  export declare const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
4
4
  export declare const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
5
+ export declare const INBOX_MQTT_WAKE_FLAG = "agents.bot-inbox-mqtt-wake";
6
+ export declare const INBOX_MQTT_WAKE_FLAG_FALLBACK = true;
5
7
  export declare const INBOX_POLL_BACKOFF_FLAG_TIMEOUT_MS = 2000;
6
8
  export declare const INBOX_POLL_BACKOFF_FLAG_CACHE_MS: number;
7
9
  type InboxPollBackoffFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
@@ -14,11 +16,7 @@ export interface InboxPollBackoffFlagReaderDependencies {
14
16
  reportFailure?: (error: unknown) => void;
15
17
  }
16
18
  export declare function safeErrorClass(error: unknown): string;
17
- /**
18
- * Create a per-bot reader. The flag client is opened at most once per three
19
- * minutes; a missing identity, failed request, or malformed snapshot keeps
20
- * idle polling at its existing two-second interval.
21
- */
22
19
  export declare function createInboxPollBackoffFlagReader(config: Pick<BotConfig, "kind" | "workerId" | "companies" | "companySlug" | "hqRoot">, dependencies?: InboxPollBackoffFlagReaderDependencies): () => Promise<boolean>;
20
+ export declare function createInboxMqttWakeFlagReader(config: Pick<BotConfig, "kind" | "workerId" | "companies" | "companySlug" | "hqRoot">, dependencies?: InboxPollBackoffFlagReaderDependencies): () => Promise<boolean>;
23
21
  export {};
24
22
  //# sourceMappingURL=inbox-poll-backoff-flag.d.ts.map
@@ -5,6 +5,8 @@ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
5
5
  import { effectiveBotCompanies, effectiveBotKind } from "./config.js";
6
6
  export const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
7
7
  export const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
8
+ export const INBOX_MQTT_WAKE_FLAG = "agents.bot-inbox-mqtt-wake";
9
+ export const INBOX_MQTT_WAKE_FLAG_FALLBACK = true;
8
10
  // One read every three minutes on a fresh connection. From a home network the
9
11
  // TCP and TLS handshakes plus the resolve call take about 250-400 ms, so a
10
12
  // 150 ms budget timed out on every read and kept the gate off.
@@ -15,8 +17,8 @@ export function safeErrorClass(error) {
15
17
  const name = error instanceof Error ? error.name : "UnknownError";
16
18
  return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
17
19
  }
18
- function reportFlagFailure(error) {
19
- process.stderr.write(`HQ local bot inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default off.\n`);
20
+ function reportFlagFailure(error, fallback) {
21
+ process.stderr.write(`HQ local bot inbox flag lookup failed (${safeErrorClass(error)}); using default ${fallback ? "on" : "off"}.\n`);
20
22
  }
21
23
  function companyContexts(config, env) {
22
24
  if (effectiveBotKind(config) !== "company")
@@ -44,9 +46,9 @@ function companyContexts(config, env) {
44
46
  /**
45
47
  * Create a per-bot reader. The flag client is opened at most once per three
46
48
  * minutes; a missing identity, failed request, or malformed snapshot keeps
47
- * idle polling at its existing two-second interval.
49
+ * registered fallback.
48
50
  */
49
- export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
51
+ function createInboxFlagReader(config, flagKey, fallback, dependencies = {}) {
50
52
  const env = dependencies.env ?? process.env;
51
53
  const contexts = companyContexts(config, env);
52
54
  const endpoint = resolveFlagRegistryEndpoint(undefined, env);
@@ -54,15 +56,15 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
54
56
  const createClient = dependencies.createClient ?? createFlagClient;
55
57
  const getToken = dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? "");
56
58
  const doFetch = dependencies.fetch ?? globalThis.fetch;
57
- const report = dependencies.reportFailure ?? reportFlagFailure;
58
- let cachedValue = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
59
+ const report = dependencies.reportFailure ?? ((error) => reportFlagFailure(error, fallback));
60
+ let cachedValue = fallback;
59
61
  let lastReadAt = Number.NEGATIVE_INFINITY;
60
62
  let pending = null;
61
63
  const readContext = async (context) => {
62
64
  let client;
63
65
  let failed = false;
64
66
  let failureReported = false;
65
- let enabled = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
67
+ let enabled = fallback;
66
68
  const reportFailureOnce = (error) => {
67
69
  failed = true;
68
70
  if (failureReported)
@@ -84,7 +86,7 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
84
86
  });
85
87
  await client.ready();
86
88
  if (!failed) {
87
- enabled = resolveFlagGate(client, INBOX_POLL_BACKOFF_FLAG, { fallback: INBOX_POLL_BACKOFF_FLAG_FALLBACK }, () => INBOX_POLL_BACKOFF_FLAG_FALLBACK);
89
+ enabled = resolveFlagGate(client, flagKey, { fallback }, () => fallback);
88
90
  }
89
91
  }
90
92
  catch (error) {
@@ -98,12 +100,12 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
98
100
  reportFailureOnce(error);
99
101
  }
100
102
  }
101
- return failed ? INBOX_POLL_BACKOFF_FLAG_FALLBACK : enabled;
103
+ return failed ? fallback : enabled;
102
104
  };
103
105
  const read = async () => {
104
106
  if (!contexts || contexts.length === 0) {
105
- report(new Error("local bot inbox poll backoff flag has no resolvable company identity"));
106
- return INBOX_POLL_BACKOFF_FLAG_FALLBACK;
107
+ report(new Error("local bot inbox flag has no resolvable company identity"));
108
+ return fallback;
107
109
  }
108
110
  const values = await Promise.all(contexts.map(readContext));
109
111
  return values.every(Boolean);
@@ -123,7 +125,7 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
123
125
  })
124
126
  .catch((error) => {
125
127
  report(error);
126
- cachedValue = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
128
+ cachedValue = fallback;
127
129
  return cachedValue;
128
130
  })
129
131
  .finally(() => {
@@ -132,4 +134,10 @@ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
132
134
  return pending;
133
135
  };
134
136
  }
137
+ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
138
+ return createInboxFlagReader(config, INBOX_POLL_BACKOFF_FLAG, INBOX_POLL_BACKOFF_FLAG_FALLBACK, dependencies);
139
+ }
140
+ export function createInboxMqttWakeFlagReader(config, dependencies = {}) {
141
+ return createInboxFlagReader(config, INBOX_MQTT_WAKE_FLAG, INBOX_MQTT_WAKE_FLAG_FALLBACK, dependencies);
142
+ }
135
143
  //# sourceMappingURL=inbox-poll-backoff-flag.js.map
@@ -0,0 +1,18 @@
1
+ import * as fs from "node:fs";
2
+ export { AGENT_INBOX_PUSH_STATE_BASENAME } from "../mesh/live/paths.js";
3
+ type WakeEvent = "wake" | "timeout" | "aborted";
4
+ type Watcher = Pick<fs.FSWatcher, "close"> & {
5
+ on?: (event: "error", listener: (error: Error) => void) => unknown;
6
+ };
7
+ export interface InboxWakeWatcher {
8
+ wait(delayMs: number, signal?: AbortSignal): Promise<WakeEvent>;
9
+ isLiveFor?(agentUid: string): boolean;
10
+ close(): void;
11
+ }
12
+ /** Watches the content-free Work Mesh inbox sequence, with a bounded poll fallback. */
13
+ export declare function createInboxWakeWatcher(root: string, options?: {
14
+ watch?: (root: string, listener: (event: string, filename: string | Buffer | null) => void) => Watcher;
15
+ onError?: (error: unknown) => void;
16
+ now?: () => number;
17
+ }): InboxWakeWatcher;
18
+ //# sourceMappingURL=inbox-wake.d.ts.map
@@ -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);