@indigoai-us/hq-cli 5.345.58 → 5.345.59

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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/command-catalog.generated.d.ts +8 -0
  3. package/dist/command-catalog.generated.js +10 -0
  4. package/dist/commands/agent-kit.js +20 -10
  5. package/dist/commands/billing.d.ts +13 -0
  6. package/dist/commands/billing.js +31 -0
  7. package/dist/commands/db-dump.js +2 -2
  8. package/dist/commands/db-remote-deps.js +6 -0
  9. package/dist/commands/lanes-loop.js +3 -1
  10. package/dist/commands/lanes-questions.js +16 -0
  11. package/dist/commands/lanes-session-start.js +19 -3
  12. package/dist/commands/lanes-shared.d.ts +1 -0
  13. package/dist/commands/lanes.d.ts +3 -1
  14. package/dist/commands/lanes.js +52 -2
  15. package/dist/lib/agent-kit/inbox-poll-backoff-flag.d.ts +19 -0
  16. package/dist/lib/agent-kit/inbox-poll-backoff-flag.js +70 -0
  17. package/dist/lib/agent-kit/run/inbox.d.ts +7 -3
  18. package/dist/lib/agent-kit/run/inbox.js +45 -6
  19. package/dist/lib/agent-kit/run/stop-signals.d.ts +14 -0
  20. package/dist/lib/agent-kit/run/stop-signals.js +26 -0
  21. package/dist/lib/agent-kit/services.d.ts +2 -2
  22. package/dist/lib/agent-kit/services.js +5 -3
  23. package/dist/lib/anywhere-runtime-flag-key.d.ts +1 -0
  24. package/dist/lib/anywhere-runtime-flag-key.js +1 -0
  25. package/dist/lib/anywhere-runtime-revocation-marker.d.ts +4 -0
  26. package/dist/lib/anywhere-runtime-revocation-marker.js +26 -0
  27. package/dist/lib/daemon/run.d.ts +2 -0
  28. package/dist/lib/daemon/run.js +17 -4
  29. package/dist/lib/daemon/server.js +7 -0
  30. package/dist/lib/db/control-plane.d.ts +12 -1
  31. package/dist/lib/db/usage-format.d.ts +2 -1
  32. package/dist/lib/db/usage-format.js +19 -0
  33. package/dist/lib/install/provider-wrappers.js +13 -0
  34. package/dist/lib/lanes/loop.d.ts +23 -1
  35. package/dist/lib/lanes/loop.js +134 -19
  36. package/dist/lib/lanes/questions.d.ts +5 -0
  37. package/dist/lib/lanes/reconcile.js +3 -0
  38. package/dist/lib/lanes/story-ledger.js +30 -20
  39. package/dist/lib/lanes/types.js +1 -1
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.59] — 2026-10-09
6
+ - Keep conduct-mode SessionStart injection out of unattended sessions unless `HQ_AUTO_CONDUCT` explicitly forces it on.
7
+ - Back off the external agent-kit inbox poller from 15 seconds to 30 seconds after repeated empty polls, reset to 15 seconds when a message is mirrored, and stop promptly on SIGTERM or SIGINT with a 10-second deadline for stalled shutdowns. The default-on `agents.inbox-poll-backoff-agent-kit` hq-flags kill switch preserves the regular cadence when disabled; installed services retain a configured `HQ_FLAGS_API_URL`.
8
+
9
+ - Fix loop-lane recovery: retries receive the fixed per-attempt phase budget; withdrawn and stalled IDs can be reused; `interrupt --story/--phase` targets matching work; and stall answers resume through the controller.
10
+ - `hq db usage --app` says when a deleted app's database was removed, how long its data is kept, and that it is not billed, instead of showing usage and an estimate.
11
+ - `hq db sql --app` and `hq db dump --app` say a deleted app's database was removed, or that the app has no database, instead of "not available yet".
12
+ - `hq billing status` lists database apps: how many are active, the $10 monthly price, overage per app this month, whether the company is exempt, and which subscription carries the charge.
13
+ - Installed Claude and Codex provider wrappers now bypass HQ when the daemon's refreshed runtime flag or person setting is revoked, using a per-user marker under the HQ registry directory.
14
+ - Lane updates preserve completed stories and lead-set states while applying lane progress in order.
15
+ - `hq lanes resume --story` can clear a prior unacknowledged story-switch note.
16
+
5
17
  ## [5.345.58] — 2026-10-09
6
18
  - hq access now accepts `--as <member>` to view another member's access, read-only.
7
19
 
@@ -12935,6 +12935,14 @@ export declare const COMMAND_CATALOG: readonly [{
12935
12935
  readonly flags: "--reason <text>";
12936
12936
  readonly description: "Record the reason in the lane's stop history";
12937
12937
  readonly hidden: false;
12938
+ }, {
12939
+ readonly flags: "--story <id>";
12940
+ readonly description: "Withdraw only this story's queued phases (loop lanes only)";
12941
+ readonly hidden: false;
12942
+ }, {
12943
+ readonly flags: "--phase <name>";
12944
+ readonly description: "Limit --story interruption to one phase";
12945
+ readonly hidden: false;
12938
12946
  }, {
12939
12947
  readonly flags: "--json";
12940
12948
  readonly description: "JSON on stdout (always)";
@@ -15741,6 +15741,16 @@ export const COMMAND_CATALOG = [
15741
15741
  "description": "Record the reason in the lane's stop history",
15742
15742
  "hidden": false
15743
15743
  },
15744
+ {
15745
+ "flags": "--story <id>",
15746
+ "description": "Withdraw only this story's queued phases (loop lanes only)",
15747
+ "hidden": false
15748
+ },
15749
+ {
15750
+ "flags": "--phase <name>",
15751
+ "description": "Limit --story interruption to one phase",
15752
+ "hidden": false
15753
+ },
15744
15754
  {
15745
15755
  "flags": "--json",
15746
15756
  "description": "JSON on stdout (always)",
@@ -30,6 +30,7 @@ import { isKitService, KIT_SERVICES, enabledKitServices, kitServiceSpec, kitServ
30
30
  import { writeKitSkills } from "../lib/agent-kit/skills.js";
31
31
  import { runHeartbeatLoop } from "../lib/agent-kit/run/heartbeat.js";
32
32
  import { runInboxLoop } from "../lib/agent-kit/run/inbox.js";
33
+ import { createKitStopController } from "../lib/agent-kit/run/stop-signals.js";
33
34
  import { superviseKitServices } from "../lib/agent-kit/run/supervisor.js";
34
35
  import { fallbackStatus, rebootCrontabLine, ensureRebootCrontab, shouldUseFallback, startFallback, stopFallback, } from "../lib/agent-kit/fallback.js";
35
36
  import { serviceLogPath } from "../lib/agent-kit/paths.js";
@@ -182,17 +183,26 @@ export async function runKitService(service, rt) {
182
183
  credsSource: rt.credsSource ?? "default",
183
184
  });
184
185
  return;
185
- case "inbox":
186
- await runInboxLoop({
187
- paths: rt.paths,
188
- agentUid: rt.creds.entityUid,
189
- apiBaseUrl: rt.creds.apiBaseUrl,
190
- pollMs: rt.config.inboxPollMs,
191
- ack: rt.config.inboxAck,
192
- getToken: rt.getToken,
193
- log: rt.log,
194
- });
186
+ case "inbox": {
187
+ const stop = createKitStopController();
188
+ try {
189
+ await runInboxLoop({
190
+ paths: rt.paths,
191
+ agentUid: rt.creds.entityUid,
192
+ apiBaseUrl: rt.creds.apiBaseUrl,
193
+ pollMs: rt.config.inboxPollMs,
194
+ ack: rt.config.inboxAck,
195
+ getToken: rt.getToken,
196
+ log: rt.log,
197
+ companySlug: rt.creds.companySlug,
198
+ signal: stop.signal,
199
+ });
200
+ }
201
+ finally {
202
+ stop.dispose();
203
+ }
195
204
  return;
205
+ }
196
206
  }
197
207
  }
198
208
  /** `kit run all`: supervise the kit services as child processes with restart backoff. */
@@ -16,5 +16,18 @@
16
16
  * answer such a request
17
17
  */
18
18
  import { Command } from "commander";
19
+ interface DatabaseBilling {
20
+ period: string;
21
+ activeApps: number;
22
+ pricePerAppCents: number;
23
+ exempt: boolean;
24
+ chargedOn: "team" | "dedicated" | "existing" | null;
25
+ apps: Array<{
26
+ appId: string;
27
+ overageCents: number;
28
+ }>;
29
+ }
30
+ export declare function formatDatabaseBilling(db: DatabaseBilling | null | undefined): string[];
19
31
  export declare function registerBillingCommand(program: Command): void;
32
+ export {};
20
33
  //# sourceMappingURL=billing.d.ts.map
@@ -21,6 +21,35 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
21
21
  import { mintPaymentLink } from "../utils/billing-gate.js";
22
22
  import { upgradeToTeam } from "../utils/team-upgrade.js";
23
23
  import { registerBillingSponsorCommands } from "./billing-sponsor.js";
24
+ const CHARGED_ON = {
25
+ team: "the Team subscription",
26
+ dedicated: "the separate HQ database apps subscription",
27
+ existing: "the company's existing subscription",
28
+ };
29
+ function dollars(cents) {
30
+ return `$${(cents / 100).toFixed(2)}`;
31
+ }
32
+ export function formatDatabaseBilling(db) {
33
+ if (db === undefined)
34
+ return [];
35
+ if (db === null)
36
+ return [`${chalk.bold("Database apps")}: could not be read right now`];
37
+ const lines = [
38
+ `${chalk.bold("Database apps")}: ${db.activeApps} active, ${dollars(db.pricePerAppCents)} a month each`,
39
+ ];
40
+ if (db.exempt) {
41
+ lines.push(" Not billed: this company is exempt from database app charges.");
42
+ }
43
+ else if (db.activeApps > 0) {
44
+ lines.push(db.chargedOn
45
+ ? ` Charged on ${CHARGED_ON[db.chargedOn]}.`
46
+ : " Not on a subscription yet. The charge is added on the next billing sync.");
47
+ }
48
+ for (const app of db.apps) {
49
+ lines.push(` ${app.appId}: ${dollars(app.overageCents)} overage so far in ${db.period}`);
50
+ }
51
+ return lines;
52
+ }
24
53
  function fail(err) {
25
54
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
26
55
  process.exit(1);
@@ -69,6 +98,8 @@ export function registerBillingCommand(program) {
69
98
  console.log(`${chalk.bold("Card")}: ${chalk.yellow("no card on file")} — ` +
70
99
  `run \`hq billing checkout\` to add one before provisioning paid resources.`);
71
100
  }
101
+ for (const line of formatDatabaseBilling(summary.databases))
102
+ console.log(line);
72
103
  }
73
104
  catch (err) {
74
105
  fail(err);
@@ -8,8 +8,8 @@ import { dumpRemoteSchema, openRemoteAppConnection } from "../lib/db/remote-engi
8
8
  import { isPlanGateError } from "../utils/plan-gate-error.js";
9
9
  import { defaultRemoteDeps, describeRemoteError, remoteEngineDeps, resolveAppId, } from "./db-remote-deps.js";
10
10
  export function describeSessionGap(error) {
11
- const status = error.status;
12
- if (status === 404 || status === 405) {
11
+ const { status, code } = error;
12
+ if ((status === 404 || status === 405) && !code) {
13
13
  return "Remote database access for apps is not available yet. The HQ API has no database session route.";
14
14
  }
15
15
  return null;
@@ -71,6 +71,12 @@ export function describeRemoteError(error) {
71
71
  if (status === 404 && code === "DB_USAGE_NOT_PROVISIONED") {
72
72
  return "This company has no remote database yet. Run hq db provision first.";
73
73
  }
74
+ if (status === 404 && code === "DB_APP_REMOVED") {
75
+ return "This app's database was removed, so it can't be opened. Run hq db usage --app <app> to see how long its data is kept.";
76
+ }
77
+ if (status === 404 && code === "DB_APP_NOT_FOUND") {
78
+ return "This app has no database in this company. Check the app name, or deploy it with database enabled.";
79
+ }
74
80
  if (status === 404 && code === "DB_USAGE_APP_NOT_FOUND") {
75
81
  return "No database found for that app in this company. Check the app name, or deploy it with database enabled.";
76
82
  }
@@ -176,12 +176,13 @@ export async function runLaneLoopController(ctx, laneId, deps) {
176
176
  signal.removeEventListener("abort", abortPhase);
177
177
  }
178
178
  },
179
- raiseQuestion: ({ envelope, envelopeId, attempt, error }) => {
179
+ raiseQuestion: ({ envelope, envelopeId, attempt, error, questionId }) => {
180
180
  const current = readLane(hqRoot, laneId);
181
181
  const data = envelope;
182
182
  raiseLaneQuestion({
183
183
  hqRoot,
184
184
  lane: current,
185
+ questionId,
185
186
  raisedVia: "cli",
186
187
  at: ctx.now().toISOString(),
187
188
  body: {
@@ -189,6 +190,7 @@ export async function runLaneLoopController(ctx, laneId, deps) {
189
190
  context: `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
190
191
  options: ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
191
192
  recommended: "Retry with a longer deadline",
193
+ loop_stall: { lane_id: laneId, envelope_id: envelopeId, attempt },
192
194
  },
193
195
  });
194
196
  },
@@ -1,9 +1,11 @@
1
+ import * as path from "node:path";
1
2
  import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
2
3
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
3
4
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
4
5
  import { appendCappedHistory, createQuestion, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
5
6
  import { resolveQuestionCompanyWithFallback } from "../lib/lanes/questions.js";
6
7
  import { LanesError } from "../lib/lanes/errors.js";
8
+ import { applyLoopStallAnswer } from "../lib/lanes/loop.js";
7
9
  export function resolveAnswerChoice(choice, options, freeform) {
8
10
  const trimmed = choice.trim();
9
11
  if (/^[0-9]+$/.test(trimmed)) {
@@ -854,6 +856,20 @@ export function createLaneQuestionsHandlers(deps) {
854
856
  },
855
857
  };
856
858
  });
859
+ if (body.loop_stall?.lane_id === askingLane.lane_id && askingLane.loop_mode === true) {
860
+ const resolved = applyLoopStallAnswer({
861
+ runDir: path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id),
862
+ questionId,
863
+ choice,
864
+ ...(opts.notes ? { notes: opts.notes } : {}),
865
+ at,
866
+ });
867
+ if (!resolved)
868
+ throw new LanesError("loop_stall_result_missing", `Loop stall result for question ${questionId} is missing`, { question_id: questionId, lane_id: askingLane.lane_id });
869
+ const controllerPid = await deps.restartLoopController(ctx, askingLane.lane_id);
870
+ printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: resolved.action, controller_pid: controllerPid });
871
+ return;
872
+ }
857
873
  const latestAskingLane = readLane(hqRoot, askingLane.lane_id);
858
874
  if (latestAskingLane.state === "awaiting_input") {
859
875
  let wake;
@@ -16,6 +16,18 @@ function enabledFromSettings(root) {
16
16
  const value = /^\s+default_enabled:\s*["']?(true|false|yes|no|on|off|1|0)["']?\s*(?:#.*)?$/mi.exec(block)?.[1]?.toLowerCase();
17
17
  return value === "true" || value === "yes" || value === "on" || value === "1";
18
18
  }
19
+ function isUnattended(env) {
20
+ const flags = `${env.HQ_UNATTENDED ?? ""}${env.HQ_SESSION_UNATTENDED ?? ""}${env.CLAUDE_HEADLESS ?? ""}`;
21
+ if (["1", "true", "TRUE", "yes", "YES", "on", "ON"].some((marker) => flags.includes(marker)))
22
+ return true;
23
+ return [
24
+ env.HQ_BOT_AGENT_UID,
25
+ env.HQ_MACHINE_CREDS_FILE,
26
+ env.HQ_AGENT_CLAUDE_TASKFILE,
27
+ env.HQ_AGENT_COMPANY_DIR,
28
+ env.HQ_AGENT_STATUS_FILE,
29
+ ].some((marker) => Boolean(marker));
30
+ }
19
31
  function sessionWasDisabled(root, env, payloadSessionId) {
20
32
  const sessionId = payloadSessionId || env.HQ_SESSION_ID;
21
33
  if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId))
@@ -169,11 +181,15 @@ export async function runLaneSessionStart(options = {}) {
169
181
  (options.startSweep ?? ((r, e) => startSessionStartOrphanSweep(r, e, spawn)))(root, env);
170
182
  if (sessionWasDisabled(root, env, payloadSessionId))
171
183
  return;
184
+ const autoConduct = env.HQ_AUTO_CONDUCT ?? "";
185
+ const forceOn = /^(1|true|on|yes)$/i.test(autoConduct);
186
+ if (/^(0|false|off|no)$/i.test(autoConduct))
187
+ return;
188
+ if (!forceOn && isUnattended(env))
189
+ return;
172
190
  let enabled = enabledFromSettings(root);
173
- if (/^(1|true|on|yes)$/i.test(env.HQ_AUTO_CONDUCT ?? ""))
191
+ if (forceOn)
174
192
  enabled = true;
175
- else if (/^(0|false|off|no)$/i.test(env.HQ_AUTO_CONDUCT ?? ""))
176
- enabled = false;
177
193
  if (!enabled)
178
194
  return;
179
195
  const core = path.join(root, ".claude", "skills", "conduct", "conductor-core.md");
@@ -474,6 +474,7 @@ export interface LaneQuestionsHandlerDeps {
474
474
  printJson(value: unknown): void;
475
475
  readSessionMeta(hqRoot: string, sessionId: string, ctx: Ctx): Record<string, unknown> | null;
476
476
  reconcileHostFromCtx(ctx: Ctx): ReconcileHost;
477
+ restartLoopController(ctx: Ctx, laneId: string): Promise<number>;
477
478
  resolveActiveQuestionCompany(ctx: Ctx, message?: string): Promise<LaneCompanyRef>;
478
479
  resolveAudience(hqRoot: string, question: QuestionRecord, ctx: Ctx): SeniorRef;
479
480
  sameQuestionAnswer(left: QuestionAnswer | undefined, right: QuestionAnswer): boolean;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { Command } from "commander";
9
9
  import type { MonitorLaneMessagePayload, MonitorLaneMessageResult } from "./lanes-message.js";
10
- import { type LanesMesh, type OutpostRouteIdentity, type SpawnLoginResult } from "./lanes-shared.js";
10
+ import { type Ctx, type LanesMesh, type OutpostRouteIdentity, type SpawnLoginResult } from "./lanes-shared.js";
11
11
  export type { LanesMesh, SpawnLoginResult } from "./lanes-shared.js";
12
12
  export { CLAUDE_ACCOUNT_BALANCE_SIGNALS_FLAG, LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
13
13
  export { resolveAnswerChoice } from "./lanes-questions.js";
@@ -36,6 +36,8 @@ export interface LanesCommandDeps {
36
36
  onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
37
37
  /** Test seam for failures after a message is durably queued. */
38
38
  onLaneMessageQueued?: (laneId: string, path: "admission" | "account_move" | "quota_hold") => void;
39
+ /** Test seam for routing loop stall answers back through their controller. */
40
+ restartLoopController?: (ctx: Ctx, laneId: string) => Promise<number>;
39
41
  hqRoot?: () => string;
40
42
  /** Start HQ-root walk here instead of process.cwd(). Tests only. */
41
43
  cwd?: () => string;
@@ -43,7 +43,7 @@ import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFoc
43
43
  import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
44
44
  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, } from "../lib/lanes/flag-keys.js";
45
45
  import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
46
- import { enqueueLoopEnvelope, interruptLoopQueue, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
46
+ import { enqueueLoopEnvelope, interruptLoopQueue, interruptLoopQueueForStory, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
47
47
  import { runLaneLoopController, startLaneLoopController } from "./lanes-loop.js";
48
48
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
49
49
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
@@ -580,6 +580,13 @@ export function parseQuestionBody(raw) {
580
580
  return null;
581
581
  if (obj.notes !== undefined && typeof obj.notes !== "string")
582
582
  return null;
583
+ if (obj.loop_stall !== undefined && (typeof obj.loop_stall !== "object" || obj.loop_stall === null || Array.isArray(obj.loop_stall) ||
584
+ typeof obj.loop_stall.lane_id !== "string" ||
585
+ typeof obj.loop_stall.envelope_id !== "string" ||
586
+ typeof obj.loop_stall.attempt !== "number" ||
587
+ !Number.isSafeInteger(obj.loop_stall.attempt) ||
588
+ obj.loop_stall.attempt < 1))
589
+ return null;
583
590
  const options = obj.options.filter((item) => typeof item === "string");
584
591
  const body = {
585
592
  question: obj.question,
@@ -590,6 +597,8 @@ export function parseQuestionBody(raw) {
590
597
  body.recommended = obj.recommended;
591
598
  if (typeof obj.notes === "string")
592
599
  body.notes = obj.notes;
600
+ if (obj.loop_stall !== undefined)
601
+ body.loop_stall = obj.loop_stall;
593
602
  return body;
594
603
  }
595
604
  function readQuestionBodyFile(hqRoot, question) {
@@ -8614,6 +8623,29 @@ async function runInterrupt(ctx, laneId, reason, options = {}) {
8614
8623
  throw new LanesError("interrupt_remote_unsupported", `Lane ${laneId} runs on Outpost ${lane.host.id}; run hq lanes interrupt on that Outpost`, { lane_id: laneId, outpost_id: lane.host.id });
8615
8624
  }
8616
8625
  assertLaneRunsHereForOperatorMutation(ctx, lane, "interrupt");
8626
+ if (options.story !== undefined && lane.loop_mode !== true) {
8627
+ throw new LanesError("usage_error", "--story is only supported for loop lanes", { lane_id: laneId });
8628
+ }
8629
+ if (options.phase !== undefined && options.story === undefined) {
8630
+ throw new LanesError("usage_error", "--phase requires --story", { lane_id: laneId });
8631
+ }
8632
+ if (lane.loop_mode === true && options.story !== undefined) {
8633
+ const scoped = interruptLoopQueueForStory(runDir(hqRoot, lane.lane_id), options.story, options.phase);
8634
+ if (scoped.already_picked_up.length === 0 || lane.state !== "running") {
8635
+ if (!options.quiet)
8636
+ printJson({ ok: true, action: "interrupt", lane_id: lane.lane_id, terminal: false, resumable: true, ...scoped });
8637
+ return;
8638
+ }
8639
+ options = { ...options, preserveLoopQueue: true };
8640
+ // The matching active phase uses the same provider interruption path as a deadline abort.
8641
+ if (!reason)
8642
+ reason = "story-scoped loop phase interrupt";
8643
+ const remaining = scoped;
8644
+ await runInterrupt(ctx, laneId, reason, { preserveLoopQueue: true, quiet: true });
8645
+ if (!options.quiet)
8646
+ printJson({ ok: true, action: "interrupt", lane_id: lane.lane_id, terminal: false, resumable: true, ...remaining });
8647
+ return;
8648
+ }
8617
8649
  const queueInterrupt = lane.loop_mode === true && options.preserveLoopQueue !== true
8618
8650
  ? interruptLoopQueue(runDir(hqRoot, lane.lane_id))
8619
8651
  : undefined;
@@ -9390,6 +9422,22 @@ export function registerLanesCommand(parent, deps = {}) {
9390
9422
  printJson,
9391
9423
  readSessionMeta,
9392
9424
  reconcileHostFromCtx,
9425
+ restartLoopController: async (handlerCtx, laneId) => {
9426
+ if (deps.restartLoopController)
9427
+ return deps.restartLoopController(handlerCtx, laneId);
9428
+ const current = readLane(handlerCtx.hqRoot(), laneId);
9429
+ if (current.state === "running") {
9430
+ await runInterrupt(handlerCtx, laneId, "loop stall question answered; restarting controller", { preserveLoopQueue: true, quiet: true });
9431
+ }
9432
+ const runDirPath = runDir(handlerCtx.hqRoot(), laneId);
9433
+ if (loopControllerIsAlive(runDirPath)) {
9434
+ const pid = loopStatus(runDirPath, laneId).pid;
9435
+ if (pid === undefined)
9436
+ throw new LanesError("loop_controller_pid_missing", `Loop controller for ${laneId} is alive without a recorded pid`, { lane_id: laneId });
9437
+ return pid;
9438
+ }
9439
+ return startLaneLoopController(handlerCtx, laneId);
9440
+ },
9393
9441
  resolveActiveQuestionCompany,
9394
9442
  resolveAudience,
9395
9443
  sameQuestionAnswer,
@@ -10034,8 +10082,10 @@ export function registerLanesCommand(parent, deps = {}) {
10034
10082
  .description("Stop a running lane's turn and keep it resumable with hq lanes resume")
10035
10083
  .argument("<lane-id>", "Lane id")
10036
10084
  .option("--reason <text>", "Record the reason in the lane's stop history")
10085
+ .option("--story <id>", "Withdraw only this story's queued phases (loop lanes only)")
10086
+ .option("--phase <name>", "Limit --story interruption to one phase")
10037
10087
  .action(wrap(async (laneId, options) => {
10038
- await runInterrupt(ctx, laneId, options.reason);
10088
+ await runInterrupt(ctx, laneId, options.reason, options);
10039
10089
  }));
10040
10090
  lanes
10041
10091
  .command("approve")
@@ -0,0 +1,19 @@
1
+ import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ export declare const AGENT_KIT_INBOX_BACKOFF_FLAG = "agents.inbox-poll-backoff-agent-kit";
3
+ export declare const AGENT_KIT_INBOX_BACKOFF_DEFAULT = true;
4
+ export declare const AGENT_KIT_INBOX_BACKOFF_CACHE_MS: number;
5
+ export declare const AGENT_KIT_INBOX_BACKOFF_TIMEOUT_MS = 2000;
6
+ type InboxBackoffFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
7
+ export interface AgentKitInboxBackoffFlagDependencies {
8
+ companySlug?: string;
9
+ getToken: FlagClientOptions["getToken"];
10
+ createClient?: (options: FlagClientOptions) => InboxBackoffFlagClient;
11
+ endpoint?: string;
12
+ fetch?: typeof fetch;
13
+ now?: () => number;
14
+ reportFailure?: (error: unknown) => void;
15
+ }
16
+ /** Read the default-on kill switch; registry outages preserve idle backoff. */
17
+ export declare function createAgentKitInboxBackoffFlagReader(dependencies: AgentKitInboxBackoffFlagDependencies): () => Promise<boolean>;
18
+ export {};
19
+ //# sourceMappingURL=inbox-poll-backoff-flag.d.ts.map
@@ -0,0 +1,70 @@
1
+ import { createFlagClient, } from "@indigoai-us/hq-flags-client";
2
+ import { resolveFlagGate } from "../flag-registry.js";
3
+ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
4
+ export const AGENT_KIT_INBOX_BACKOFF_FLAG = "agents.inbox-poll-backoff-agent-kit";
5
+ export const AGENT_KIT_INBOX_BACKOFF_DEFAULT = true;
6
+ export const AGENT_KIT_INBOX_BACKOFF_CACHE_MS = 3 * 60_000;
7
+ export const AGENT_KIT_INBOX_BACKOFF_TIMEOUT_MS = 2_000;
8
+ /** Read the default-on kill switch; registry outages preserve idle backoff. */
9
+ export function createAgentKitInboxBackoffFlagReader(dependencies) {
10
+ const createClient = dependencies.createClient ?? createFlagClient;
11
+ const now = dependencies.now ?? Date.now;
12
+ const report = dependencies.reportFailure ?? ((error) => {
13
+ const name = error instanceof Error ? error.name : "UnknownError";
14
+ process.stderr.write(`HQ agent-kit inbox backoff flag lookup failed (${name}); keeping the default-on behavior.\n`);
15
+ });
16
+ let cachedValue = AGENT_KIT_INBOX_BACKOFF_DEFAULT;
17
+ let lastReadAt = Number.NEGATIVE_INFINITY;
18
+ let pending = null;
19
+ const read = async () => {
20
+ let client;
21
+ let failed = false;
22
+ const reportOnce = (error) => {
23
+ if (failed)
24
+ return;
25
+ failed = true;
26
+ report(error);
27
+ };
28
+ try {
29
+ client = createClient({
30
+ endpoint: resolveFlagRegistryEndpoint(dependencies.endpoint),
31
+ ...(dependencies.companySlug ? { companyIdentifiers: [dependencies.companySlug] } : {}),
32
+ env: {},
33
+ getToken: dependencies.getToken,
34
+ ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}),
35
+ refreshIntervalMs: 0,
36
+ requestTimeoutMs: AGENT_KIT_INBOX_BACKOFF_TIMEOUT_MS,
37
+ onError: reportOnce,
38
+ });
39
+ await client.ready();
40
+ cachedValue = failed
41
+ ? AGENT_KIT_INBOX_BACKOFF_DEFAULT
42
+ : resolveFlagGate(client, AGENT_KIT_INBOX_BACKOFF_FLAG, { fallback: AGENT_KIT_INBOX_BACKOFF_DEFAULT }, () => AGENT_KIT_INBOX_BACKOFF_DEFAULT);
43
+ }
44
+ catch (error) {
45
+ reportOnce(error);
46
+ cachedValue = AGENT_KIT_INBOX_BACKOFF_DEFAULT;
47
+ }
48
+ finally {
49
+ try {
50
+ client?.close();
51
+ }
52
+ catch (error) {
53
+ reportOnce(error);
54
+ cachedValue = AGENT_KIT_INBOX_BACKOFF_DEFAULT;
55
+ }
56
+ }
57
+ return cachedValue;
58
+ };
59
+ return () => {
60
+ const currentTime = now();
61
+ if (pending)
62
+ return pending;
63
+ if (currentTime - lastReadAt < AGENT_KIT_INBOX_BACKOFF_CACHE_MS)
64
+ return Promise.resolve(cachedValue);
65
+ lastReadAt = currentTime;
66
+ pending = read().finally(() => { pending = null; });
67
+ return pending;
68
+ };
69
+ }
70
+ //# sourceMappingURL=inbox-poll-backoff-flag.js.map
@@ -21,6 +21,7 @@ import { type WakeConfig, type WakeDeps, type WakeNotice, type WakeResult } from
21
21
  export declare const SEEN_IDS_NAME = "seen-ids.json";
22
22
  export declare const INBOX_JSONL_NAME = "inbox.jsonl";
23
23
  export declare const MAX_SEEN_IDS = 5000;
24
+ export declare const INBOX_POLL_BACKOFF_MAX_MS = 30000;
24
25
  export interface InboxItem {
25
26
  id: string;
26
27
  raw: Record<string, unknown>;
@@ -38,10 +39,13 @@ export interface InboxDeps {
38
39
  ack: boolean;
39
40
  getToken: () => Promise<string>;
40
41
  log: KitLogger;
41
- fetchInbox?: (token: string) => Promise<InboxFetchResult>;
42
+ fetchInbox?: (token: string, signal?: AbortSignal) => Promise<InboxFetchResult>;
42
43
  ackItem?: (token: string, id: string) => Promise<number>;
43
44
  warmCache?: (token: string, agentUid: string) => Promise<unknown>;
44
- sleep?: (ms: number) => Promise<void>;
45
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
46
+ readInboxBackoffEnabled?: () => Promise<boolean>;
47
+ signal?: AbortSignal;
48
+ companySlug?: string;
45
49
  maxPolls?: number;
46
50
  /** Override wake config lookup (tests). `null` disables the wake. */
47
51
  wakeConfig?: WakeConfig | null;
@@ -66,7 +70,7 @@ export interface InboxFetchResult {
66
70
  export declare function agentApiUrl(apiBaseUrl: string, route: string): string;
67
71
  export declare function agentInboxUrl(apiBaseUrl: string, agentUid: string): string;
68
72
  export declare function truncateBody(text: string, max?: number): string;
69
- export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string) => Promise<InboxFetchResult>;
73
+ export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string, signal?: AbortSignal) => Promise<InboxFetchResult>;
70
74
  export declare function defaultAckItem(agentUid: string, apiBaseUrl: string): (token: string, id: string) => Promise<number>;
71
75
  export declare function pollInboxOnce(deps: InboxDeps): Promise<{
72
76
  ok: boolean;
@@ -21,10 +21,12 @@ import { vaultApiFetch } from "../../../utils/vault-api.js";
21
21
  import { writeComponentStatus } from "../creds.js";
22
22
  import { pendingInbox } from "../inbox-state.js";
23
23
  import { writePrivateFile } from "./../private-file.js";
24
+ import { createAgentKitInboxBackoffFlagReader } from "../inbox-poll-backoff-flag.js";
24
25
  import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
25
26
  export const SEEN_IDS_NAME = "seen-ids.json";
26
27
  export const INBOX_JSONL_NAME = "inbox.jsonl";
27
28
  export const MAX_SEEN_IDS = 5000;
29
+ export const INBOX_POLL_BACKOFF_MAX_MS = 30_000;
28
30
  /** Pull `{id}` out of each server item; items without a string id are dropped. */
29
31
  export function extractInboxItems(body) {
30
32
  const rec = body && typeof body === "object" ? body : {};
@@ -107,12 +109,13 @@ export function truncateBody(text, max = INBOX_ERROR_BODY_MAX) {
107
109
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
108
110
  }
109
111
  export function defaultFetchInbox(agentUid, apiBaseUrl) {
110
- return async (token) => {
112
+ return async (token, signal) => {
111
113
  const url = new URL(agentInboxUrl(apiBaseUrl, agentUid));
112
114
  const res = await vaultApiFetch({
113
115
  token,
114
116
  baseUrl: url.origin,
115
117
  path: url.pathname,
118
+ ...(signal ? { signal } : {}),
116
119
  });
117
120
  const text = await res.text().catch(() => "");
118
121
  let body;
@@ -137,6 +140,8 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
137
140
  };
138
141
  }
139
142
  export async function pollInboxOnce(deps) {
143
+ if (deps.signal?.aborted)
144
+ return { ok: false, mirrored: 0 };
140
145
  const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
141
146
  const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
142
147
  // No default cache warm: the human conversation routes 403 for agent callers.
@@ -152,9 +157,11 @@ export async function pollInboxOnce(deps) {
152
157
  }
153
158
  let res;
154
159
  try {
155
- res = await fetchInbox(token);
160
+ res = await fetchInbox(token, deps.signal);
156
161
  }
157
162
  catch (err) {
163
+ if (deps.signal?.aborted)
164
+ return { ok: false, mirrored: 0 };
158
165
  writeComponentStatus(deps.paths, "inbox", "error");
159
166
  deps.log("error", `inbox fetch failed: GET ${agentInboxUrl(deps.apiBaseUrl, deps.agentUid)}: ${err instanceof Error ? err.message : String(err)}`);
160
167
  return { ok: false, mirrored: 0 };
@@ -218,14 +225,46 @@ async function maybeWake(deps, freshCount) {
218
225
  }
219
226
  }
220
227
  export async function runInboxLoop(deps) {
221
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
228
+ const sleep = deps.sleep ?? ((ms, signal) => new Promise((resolve) => {
229
+ if (signal?.aborted)
230
+ return resolve();
231
+ const timer = setTimeout(done, ms);
232
+ const abort = () => {
233
+ clearTimeout(timer);
234
+ done();
235
+ };
236
+ function done() {
237
+ signal?.removeEventListener("abort", abort);
238
+ resolve();
239
+ }
240
+ signal?.addEventListener("abort", abort, { once: true });
241
+ }));
242
+ const readBackoffEnabled = deps.readInboxBackoffEnabled ?? createAgentKitInboxBackoffFlagReader({
243
+ companySlug: deps.companySlug,
244
+ getToken: deps.getToken,
245
+ });
222
246
  let polls = 0;
223
- for (;;) {
224
- await pollInboxOnce(deps);
247
+ let consecutiveEmptyPolls = 0;
248
+ while (!deps.signal?.aborted) {
249
+ const result = await pollInboxOnce(deps);
225
250
  polls += 1;
226
251
  if (deps.maxPolls !== undefined && polls >= deps.maxPolls)
227
252
  return;
228
- await sleep(deps.pollMs);
253
+ if (deps.signal?.aborted)
254
+ return;
255
+ if (!result.ok || result.mirrored > 0)
256
+ consecutiveEmptyPolls = 0;
257
+ const enabled = await readBackoffEnabled();
258
+ const delay = enabled && result.ok && result.mirrored === 0 && consecutiveEmptyPolls > 0
259
+ ? Math.min(deps.pollMs * 2 ** consecutiveEmptyPolls, Math.max(deps.pollMs, INBOX_POLL_BACKOFF_MAX_MS))
260
+ : deps.pollMs;
261
+ if (enabled && result.ok && result.mirrored === 0)
262
+ consecutiveEmptyPolls += 1;
263
+ else
264
+ consecutiveEmptyPolls = 0;
265
+ if (deps.signal?.aborted)
266
+ return;
267
+ await sleep(delay, deps.signal);
229
268
  }
230
269
  }
231
270
  //# sourceMappingURL=inbox.js.map