@indigoai-us/hq-cli 5.345.128 → 5.345.130

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 (63) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/scaffold/core/scripts/lib/codex-output-schema.mjs +304 -0
  3. package/assets/scaffold/core/scripts/workflow-runner.mjs +2890 -0
  4. package/dist/command-catalog.generated.d.ts +29 -0
  5. package/dist/command-catalog.generated.js +34 -0
  6. package/dist/commands/core-bucket-c-work-mesh-offer-native.js +8 -2
  7. package/dist/commands/core-pipeline-workflow-runner-native.d.ts +175 -0
  8. package/dist/commands/core-pipeline-workflow-runner-native.js +3237 -0
  9. package/dist/commands/core-workflow-run-command.d.ts +4 -0
  10. package/dist/commands/core-workflow-run-command.js +41 -0
  11. package/dist/commands/core.js +4 -0
  12. package/dist/commands/daemon.js +2 -0
  13. package/dist/commands/lanes-accounts.js +0 -1
  14. package/dist/commands/lanes.js +26 -14
  15. package/dist/commands/mesh.js +32 -19
  16. package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +3 -5
  17. package/dist/lib/bot/inbox-poll-backoff-flag.js +20 -12
  18. package/dist/lib/bot/inbox-wake.d.ts +18 -0
  19. package/dist/lib/bot/inbox-wake.js +125 -0
  20. package/dist/lib/bot/run.d.ts +8 -0
  21. package/dist/lib/bot/run.js +51 -7
  22. package/dist/lib/bot/runtime/messages-stream.js +31 -3
  23. package/dist/lib/core-utils/workflow-runner-schema.d.ts +7 -0
  24. package/dist/lib/core-utils/workflow-runner-schema.js +259 -0
  25. package/dist/lib/daemon/catalog.d.ts +4 -0
  26. package/dist/lib/daemon/catalog.js +6 -3
  27. package/dist/lib/daemon/flag-monitor.d.ts +4 -1
  28. package/dist/lib/daemon/flag-monitor.js +12 -6
  29. package/dist/lib/daemon/flag.d.ts +2 -0
  30. package/dist/lib/daemon/flag.js +11 -5
  31. package/dist/lib/daemon/hq-child-bootstrap.mjs +47 -7
  32. package/dist/lib/daemon/kill-switch-flags.d.ts +5 -0
  33. package/dist/lib/daemon/kill-switch-flags.js +5 -0
  34. package/dist/lib/daemon/lanes-reconcile-loader.d.ts +7 -0
  35. package/dist/lib/daemon/lanes-reconcile-loader.js +24 -0
  36. package/dist/lib/daemon/lanes-reconcile-watcher.d.ts +17 -0
  37. package/dist/lib/daemon/lanes-reconcile-watcher.js +80 -0
  38. package/dist/lib/daemon/remote-control.d.ts +8 -5
  39. package/dist/lib/daemon/remote-control.js +8 -7
  40. package/dist/lib/daemon/run.d.ts +4 -1
  41. package/dist/lib/daemon/run.js +133 -20
  42. package/dist/lib/doctor/json-output.d.ts +15 -3
  43. package/dist/lib/doctor/json-output.js +13 -1
  44. package/dist/lib/lanes/config.d.ts +8 -4
  45. package/dist/lib/lanes/config.js +30 -36
  46. package/dist/lib/lanes/daemon-reconcile.d.ts +3 -0
  47. package/dist/lib/lanes/daemon-reconcile.js +8 -0
  48. package/dist/lib/lanes/index.d.ts +1 -1
  49. package/dist/lib/lanes/index.js +1 -1
  50. package/dist/lib/lanes/reconcile-result.d.ts +10 -0
  51. package/dist/lib/lanes/reconcile-result.js +11 -0
  52. package/dist/lib/lanes/reconcile.js +3 -26
  53. package/dist/lib/lanes/throttle.js +5 -9
  54. package/dist/lib/lanes/types.d.ts +4 -5
  55. package/dist/lib/lanes/types.js +1 -0
  56. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -1
  57. package/dist/lib/mesh/live/daemon/work-subscribe.js +8 -1
  58. package/dist/lib/mesh/live/paths.d.ts +2 -0
  59. package/dist/lib/mesh/live/paths.js +2 -0
  60. package/dist/lib/mesh/story-status.d.ts +2 -2
  61. package/dist/lib/mesh/story-status.js +2 -1
  62. package/dist/utils/resident-package-preload.js +6 -0
  63. package/package.json +1 -1
@@ -0,0 +1,4 @@
1
+ import type { Command } from "commander";
2
+ export declare const WORKFLOW_RUNNER_ASSET = "core/scripts/workflow-runner.mjs";
3
+ export declare function registerCoreWorkflowRunCommand(core: Command): void;
4
+ //# sourceMappingURL=core-workflow-run-command.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { resolveLiveRoot } from "../utils/hq-roots.js";
2
+ import { runBundledScript } from "../utils/run-bundled-script.js";
3
+ import { describeCoreCommand } from "./core-command-catalog.js";
4
+ import { coreNativePipelineEnabled } from "./core-native-pipeline-flag.js";
5
+ export const WORKFLOW_RUNNER_ASSET = "core/scripts/workflow-runner.mjs";
6
+ export function registerCoreWorkflowRunCommand(core) {
7
+ const workflow = core.command("workflow").description("Workflow orchestration");
8
+ const command = workflow.command("run")
9
+ .description("Run a workflow script or pipeline lane")
10
+ .helpOption(false)
11
+ .allowUnknownOption()
12
+ .allowExcessArguments(true)
13
+ .argument("[args...]")
14
+ .action(async (args, _options, current) => {
15
+ const argv = current.args.length > 0 ? current.args : args;
16
+ const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
17
+ if (!await coreNativePipelineEnabled()) {
18
+ const result = runBundledScript({ asset: WORKFLOW_RUNNER_ASSET, args: argv, cwd: process.cwd(), hqRoot: root });
19
+ if (result.code !== 0)
20
+ process.exit(result.code);
21
+ return;
22
+ }
23
+ const previousRoot = process.env.HQ_ROOT;
24
+ process.env.HQ_ROOT = root;
25
+ try {
26
+ const native = await import("./core-pipeline-workflow-runner-native.js");
27
+ const runner = native;
28
+ const code = await runner.runWorkflowRunnerNative(argv, root);
29
+ if (code !== 0)
30
+ process.exit(code);
31
+ }
32
+ finally {
33
+ if (previousRoot === undefined)
34
+ delete process.env.HQ_ROOT;
35
+ else
36
+ process.env.HQ_ROOT = previousRoot;
37
+ }
38
+ });
39
+ describeCoreCommand(command, { execution: "native", asset: WORKFLOW_RUNNER_ASSET, root: "live" });
40
+ }
41
+ //# sourceMappingURL=core-workflow-run-command.js.map
@@ -52,6 +52,7 @@ import { registerCoreHookStateHelpers } from "./core-hook-state-helpers-native.j
52
52
  import { registerCoreHookBatchCommand } from "./core-hook-batch-command.js";
53
53
  import { registerCorePipelineWorkerTableCommand } from "./core-pipeline-workers-command.js";
54
54
  import { registerCorePipelineRuntimeCommands } from "./core-pipeline-conductor-command.js";
55
+ import { registerCoreWorkflowRunCommand } from "./core-workflow-run-command.js";
55
56
  import { resolveLiveRoot } from "../utils/hq-roots.js";
56
57
  import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
57
58
  import { CompanyIndexUserError, renderIndexTarget } from "../lib/index-render/index.js";
@@ -304,6 +305,8 @@ export const SCAFFOLD_ONLY_ASSETS = [
304
305
  { asset: "core/scripts/session-title.sh", root: "live", summary: "Rollback companion for native session title" },
305
306
  { asset: "core/scripts/session-title-config.sh", root: "live", summary: "Rollback companion for native session-title settings" },
306
307
  { asset: "core/scripts/pipeline-worker-table.sh", root: "live", summary: "Rollback companion for native pipeline worker-table" },
308
+ { asset: "core/scripts/workflow-runner.mjs", root: "live", summary: "Rollback companion for native workflow runner" },
309
+ { asset: "core/scripts/lib/codex-output-schema.mjs", root: "live", summary: "Schema helper required by the bundled workflow-runner rollback companion" },
307
310
  { asset: "core/scripts/share-suggestion-state.sh", root: "live", summary: "Rollback shell for native share-suggestion-state" },
308
311
  { asset: "core/scripts/repo-run-registry.sh", root: "live", summary: "Rollback shell for native repo-run-registry" },
309
312
  { asset: "core/scripts/detect-stale-review-base.sh", root: "live", summary: "Rollback shell for native detect-stale-review-base" },
@@ -456,6 +459,7 @@ export function registerCoreCommands(program) {
456
459
  registerCoreHookStateHelpers(core);
457
460
  registerCoreHookBatchCommand(core);
458
461
  registerCorePipelineRuntimeCommands(core);
462
+ registerCoreWorkflowRunCommand(core);
459
463
  const sessionHelperCommands = [
460
464
  {
461
465
  name: "session-journal", asset: "core/scripts/session-journal.sh", root: "live",
@@ -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,
@@ -290,7 +290,6 @@ export function createLaneAccountsHandlers(deps) {
290
290
  balance_gate_enabled: balanceGateEnabled,
291
291
  accounts,
292
292
  selections,
293
- lead_reserve_per_host: config.lead_reserve_per_host,
294
293
  queued_leads: loaded.snapshots.flatMap((host) => {
295
294
  const leads = leadLaneIds(host.lanes);
296
295
  return host.lanes
@@ -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"