@indigoai-us/hq-cli 5.345.170 → 5.345.171

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 (111) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +132 -5
  4. package/dist/command-catalog.generated.js +164 -6
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +112 -3
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +137 -14
  12. package/dist/commands/cloud.d.ts +58 -1
  13. package/dist/commands/cloud.js +219 -5
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/integrations-api.d.ts +4 -0
  23. package/dist/commands/integrations-api.js +18 -2
  24. package/dist/commands/integrations-connect.d.ts +10 -3
  25. package/dist/commands/integrations-connect.js +70 -6
  26. package/dist/commands/integrations-core.d.ts +23 -0
  27. package/dist/commands/integrations-core.js +100 -0
  28. package/dist/commands/integrations-manage.js +8 -1
  29. package/dist/commands/integrations.js +49 -5
  30. package/dist/commands/lanes-loop.d.ts +20 -0
  31. package/dist/commands/lanes-loop.js +69 -11
  32. package/dist/commands/lanes.js +9 -1
  33. package/dist/commands/rescue.d.ts +9 -0
  34. package/dist/commands/rescue.js +31 -0
  35. package/dist/commands/run.js +115 -40
  36. package/dist/commands/scan.d.ts +5 -1
  37. package/dist/commands/scan.js +16 -4
  38. package/dist/commands/secrets.js +50 -9
  39. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  40. package/dist/hq-cloud-sync-runner/package.json +1 -1
  41. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  42. package/dist/lib/agent-kit/inbox-state.js +41 -20
  43. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  44. package/dist/lib/agent-kit/kit-config.js +6 -0
  45. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  46. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  47. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  48. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  49. package/dist/lib/agent-kit/run/inbox.js +42 -10
  50. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  51. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  52. package/dist/lib/bot/api.d.ts +1 -1
  53. package/dist/lib/bot/config.d.ts +10 -0
  54. package/dist/lib/bot/config.js +17 -1
  55. package/dist/lib/bot/fleet-local.d.ts +127 -0
  56. package/dist/lib/bot/fleet-local.js +638 -0
  57. package/dist/lib/bot/index.d.ts +1 -0
  58. package/dist/lib/bot/index.js +1 -0
  59. package/dist/lib/bot/local-config.d.ts +3 -2
  60. package/dist/lib/bot/local-config.js +5 -2
  61. package/dist/lib/bot/promote.d.ts +10 -0
  62. package/dist/lib/bot/promote.js +3 -2
  63. package/dist/lib/bot/runtime/index.js +8 -4
  64. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  65. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  66. package/dist/lib/caller-principal.d.ts +4 -0
  67. package/dist/lib/caller-principal.js +40 -0
  68. package/dist/lib/capacity/collectors/grok.js +8 -2
  69. package/dist/lib/cli-flag-keys.d.ts +2 -0
  70. package/dist/lib/cli-flag-keys.js +2 -0
  71. package/dist/lib/daemon/catalog.js +4 -1
  72. package/dist/lib/daemon/sync-core-canary.d.ts +3 -0
  73. package/dist/lib/daemon/sync-core-canary.js +8 -3
  74. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  75. package/dist/lib/doctor/checks/release-links.js +188 -2
  76. package/dist/lib/doctor/fix/apply.js +54 -0
  77. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  78. package/dist/lib/doctor/fix/remediation.js +22 -0
  79. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  80. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  81. package/dist/lib/index-render/index.d.ts +9 -0
  82. package/dist/lib/index-render/index.js +65 -5
  83. package/dist/lib/index-render/session-scope.d.ts +69 -0
  84. package/dist/lib/index-render/session-scope.js +279 -0
  85. package/dist/lib/lanes/admission.js +7 -20
  86. package/dist/lib/lanes/reconcile.js +13 -15
  87. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  88. package/dist/lib/lanes/stale-lock.js +105 -0
  89. package/dist/lib/lanes/store.js +11 -76
  90. package/dist/lib/notify-recipient.d.ts +10 -4
  91. package/dist/lib/notify-recipient.js +16 -4
  92. package/dist/lib/secret-input-link.d.ts +42 -0
  93. package/dist/lib/secret-input-link.js +93 -0
  94. package/dist/lib/secret-scan/range.d.ts +57 -0
  95. package/dist/lib/secret-scan/range.js +266 -0
  96. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  97. package/dist/lib/secret-scan/repo-config.js +135 -2
  98. package/dist/run/script-bound-schema.d.ts +27 -0
  99. package/dist/run/script-bound-schema.js +97 -0
  100. package/dist/secrets-env-action.d.ts +2 -0
  101. package/dist/secrets-env-action.js +7 -2
  102. package/dist/utils/cognito-session.d.ts +3 -30
  103. package/dist/utils/cognito-session.js +2 -100
  104. package/dist/utils/machine-principal-state.d.ts +38 -0
  105. package/dist/utils/machine-principal-state.js +110 -0
  106. package/dist/utils/safe-link.js +9 -0
  107. package/dist/utils/script-bound-exec.d.ts +158 -0
  108. package/dist/utils/script-bound-exec.js +568 -0
  109. package/dist/utils/sync-watchdog.d.ts +92 -0
  110. package/dist/utils/sync-watchdog.js +250 -0
  111. package/package.json +8 -8
@@ -397,6 +397,9 @@ export async function fetchAdminSurface(token, companyUid) {
397
397
  return {
398
398
  companyUid: data.companyUid ?? companyUid,
399
399
  ...(data.factoryEnabled !== undefined ? { factoryEnabled: data.factoryEnabled } : {}),
400
+ ...(data.capabilities && typeof data.capabilities === "object"
401
+ ? { capabilities: data.capabilities }
402
+ : {}),
400
403
  viewer: data.viewer ?? {
401
404
  personUid: "",
402
405
  role: "member",
@@ -717,6 +720,9 @@ export async function callGateway(token, params, idempotencyKey, method = "tools
717
720
  throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status), status: res.status, gatewaySubcommand });
718
721
  }
719
722
  if (terminalMessage?.error) {
723
+ const accountError = accountSelectionError(terminalMessage.error, typeof params.account === "string" ? params.account : undefined);
724
+ if (accountError)
725
+ throw accountError;
720
726
  const gatewayError = gatewayErrorDetails(terminalMessage.error);
721
727
  const invalidEnvelope = gatewayError.rpcCode === undefined && !gatewayError.message;
722
728
  const gatewayFailureCode = invalidEnvelope
@@ -890,4 +896,98 @@ export function printHealthNotice(connection) {
890
896
  if (notice.remediation)
891
897
  console.log(chalk.yellow(` ${redactUrlsInText(notice.remediation)}`));
892
898
  }
899
+ const OWNER_LABELS = {
900
+ person: "Personal",
901
+ company: "Company",
902
+ legacy: "Shared (legacy)",
903
+ invalid: "Unknown owner",
904
+ };
905
+ export function connectionOwnerLabel(connection) {
906
+ const ownerType = connection.ownerType;
907
+ return ownerType && Object.hasOwn(OWNER_LABELS, ownerType) ? OWNER_LABELS[ownerType] : undefined;
908
+ }
909
+ export function connectionAccountLabel(connection) {
910
+ if (typeof connection.accountLabel !== "string")
911
+ return undefined;
912
+ const cleaned = safeAppName(connection.accountLabel, "");
913
+ return cleaned ? redactUrlsInText(cleaned) : undefined;
914
+ }
915
+ export const ACCOUNT_REQUIRED_CODE = "AccountRequired";
916
+ export const ACCOUNT_NOT_FOUND_CODE = "IntegrationAccountNotFound";
917
+ function accountCandidatesFrom(value) {
918
+ if (!Array.isArray(value))
919
+ return [];
920
+ const candidates = [];
921
+ for (const item of value) {
922
+ const record = recordValue(item);
923
+ if (!record || typeof record.connectionId !== "string" || typeof record.provider !== "string")
924
+ continue;
925
+ candidates.push({
926
+ connectionId: record.connectionId,
927
+ provider: record.provider,
928
+ ...(typeof record.ownerType === "string" ? { ownerType: record.ownerType } : {}),
929
+ ...(typeof record.accountLabel === "string" ? { accountLabel: record.accountLabel } : {}),
930
+ });
931
+ }
932
+ return candidates;
933
+ }
934
+ function formatAccountCandidate(candidate) {
935
+ const id = safeAppName(candidate.connectionId, "?");
936
+ const owner = connectionOwnerLabel({ ownerType: candidate.ownerType });
937
+ const label = connectionAccountLabel({ accountLabel: candidate.accountLabel });
938
+ const details = [safeAppName(bareProvider(candidate.provider), "?"), owner, label].filter(Boolean).join(" · ");
939
+ return ` ${id} ${details}`;
940
+ }
941
+ export function accountSelectionMessage(code, candidates, account, rerun = "Rerun with --account <connection id or account label>.") {
942
+ const headline = code === ACCOUNT_REQUIRED_CODE
943
+ ? `More than one account can run this. ${rerun}`
944
+ : account
945
+ ? `No account matches '${safeAppName(account, "?")}'. ${rerun}`
946
+ : "No account you can use has this tool.";
947
+ if (candidates.length === 0)
948
+ return headline;
949
+ return [headline, "Accounts:", ...candidates.map(formatAccountCandidate)].join("\n");
950
+ }
951
+ function accountSelectionError(error, account) {
952
+ const data = recordValue(recordValue(error)?.data);
953
+ const code = data?.code;
954
+ if (code !== ACCOUNT_REQUIRED_CODE && code !== ACCOUNT_NOT_FOUND_CODE)
955
+ return null;
956
+ return new IntegrationsCliError(accountSelectionMessage(code, accountCandidatesFrom(data?.candidates), account), {
957
+ expected: true,
958
+ code,
959
+ });
960
+ }
961
+ export function accountCandidateFor(connection) {
962
+ return {
963
+ connectionId: connection.id,
964
+ provider: connection.provider,
965
+ ...(connection.ownerType ? { ownerType: connection.ownerType } : {}),
966
+ ...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {}),
967
+ };
968
+ }
969
+ function matchesProviderSelector(connection, provider) {
970
+ const want = provider.trim().toLowerCase();
971
+ const wantHumanSlug = humanSlug(provider);
972
+ const displayName = connection.installation?.displayName ?? "";
973
+ return (bareProvider(connection.provider).toLowerCase() === want ||
974
+ connection.provider.toLowerCase() === want ||
975
+ displayName.trim().toLowerCase() === want ||
976
+ (wantHumanSlug !== "" && humanSlug(displayName) === wantHumanSlug));
977
+ }
978
+ export function selectAccountConnections(connections, opts) {
979
+ const pool = connections.filter((connection) => connection.status !== "revoked" &&
980
+ (!opts.provider || matchesProviderSelector(connection, opts.provider)));
981
+ const want = opts.account.trim().toLowerCase();
982
+ const matches = pool.filter((connection) => connection.id.toLowerCase() === want ||
983
+ (typeof connection.accountLabel === "string" && connection.accountLabel.trim().toLowerCase() === want));
984
+ if (matches.length === 0) {
985
+ throw new IntegrationsCliError(accountSelectionMessage(ACCOUNT_NOT_FOUND_CODE, pool.map(accountCandidateFor), opts.account), { expected: true, code: ACCOUNT_NOT_FOUND_CODE });
986
+ }
987
+ const providers = new Set(matches.map((connection) => connection.provider));
988
+ if (providers.size > 1) {
989
+ throw new IntegrationsCliError(accountSelectionMessage(ACCOUNT_REQUIRED_CODE, matches.map(accountCandidateFor), opts.account, "Rerun with --provider <app> or --account <connection id>."), { expected: true, code: ACCOUNT_REQUIRED_CODE });
990
+ }
991
+ return matches;
992
+ }
893
993
  //# sourceMappingURL=integrations-core.js.map
@@ -17,7 +17,7 @@ import chalk from "chalk";
17
17
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
18
18
  import { getCompanyUid } from "../utils/vault-api.js";
19
19
  import { redactSensitiveUrl, redactUrlsInText } from "../utils/redact-url.js";
20
- import { IntegrationsCliError, bareProvider, printHealthNotice, fetchConnections, fetchAdminSurface, fetchAuditPage, AUDIT_PAGE_MAX, printJson, revokedConnectionDetails, resolveConnection, selectConnection, } from "./integrations-core.js";
20
+ import { IntegrationsCliError, bareProvider, printHealthNotice, connectionAccountLabel, connectionOwnerLabel, fetchConnections, fetchAdminSurface, fetchAuditPage, AUDIT_PAGE_MAX, printJson, revokedConnectionDetails, resolveConnection, selectConnection, } from "./integrations-core.js";
21
21
  import { fetchPendingApprovals, getConnectionAccess, mutateConnectionAccess, purgeConnection, setReadSafe, terminalSafeText, uninstallIntegration, updateGovernance, } from "./integrations-api.js";
22
22
  const WRITE_POLICIES = ["auto-allow", "confirm", "deny"];
23
23
  const PERMISSIONS = ["read", "write", "admin"];
@@ -219,6 +219,13 @@ export function registerManageCommands(integrations) {
219
219
  if (install?.id)
220
220
  console.log(chalk.dim(` installation: ${install.id}`));
221
221
  console.log(chalk.dim(` status: ${connection.status}`));
222
+ const owner = connectionOwnerLabel(connection);
223
+ if (owner) {
224
+ console.log(chalk.dim(` owner: ${owner}`));
225
+ const account = connectionAccountLabel(connection);
226
+ if (account)
227
+ console.log(chalk.dim(` account: ${account}`));
228
+ }
222
229
  if (connection.status === "revoked") {
223
230
  const details = revokedConnectionDetails(connection, opts.company);
224
231
  console.log(chalk.yellow(` Revoked — ${details.reason}`));
@@ -56,7 +56,7 @@ import chalk from "chalk";
56
56
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
57
57
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
58
58
  import { redactSensitiveUrl, redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
59
- import { IntegrationsCliError, bareProvider, callGateway, parseCallTimeoutMs, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
59
+ import { IntegrationsCliError, bareProvider, callGateway, parseCallTimeoutMs, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, connectionAccountLabel, connectionOwnerLabel, selectAccountConnections, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, accountCandidateFor, accountSelectionMessage, ACCOUNT_REQUIRED_CODE, } from "./integrations-core.js";
60
60
  import { registerConnectCommands } from "./integrations-connect.js";
61
61
  import { registerImportCommands } from "./integrations-import.js";
62
62
  import { registerManageCommands } from "./integrations-manage.js";
@@ -329,6 +329,26 @@ function approvalOutcomeFailed(row) {
329
329
  return ["failed", "write_unknown"].includes(row.status ?? "") ||
330
330
  ["failed", "unknown"].includes(row.outcome ?? "");
331
331
  }
332
+ function selectToolConnection(connections, opts) {
333
+ if (opts.account === undefined) {
334
+ return { connection: selectConnection(connections, opts), accountMatches: [] };
335
+ }
336
+ if (opts.connection) {
337
+ throw new IntegrationsCliError("Use either --connection or --account, not both.", {
338
+ expected: true,
339
+ });
340
+ }
341
+ if (!opts.account.trim()) {
342
+ throw new IntegrationsCliError("--account was empty — pass a connection id or account label.", {
343
+ expected: true,
344
+ });
345
+ }
346
+ const accountMatches = selectAccountConnections(connections, {
347
+ account: opts.account,
348
+ ...(opts.provider ? { provider: opts.provider } : {}),
349
+ });
350
+ return { connection: accountMatches[0], accountMatches };
351
+ }
332
352
  export function registerIntegrationsCommand(program) {
333
353
  const integrations = program
334
354
  .command("integrations")
@@ -375,7 +395,10 @@ export function registerIntegrationsCommand(program) {
375
395
  return;
376
396
  }
377
397
  for (const c of connections) {
398
+ const owner = connectionOwnerLabel(c);
378
399
  const flags = [
400
+ owner ?? null,
401
+ owner ? connectionAccountLabel(c) ?? null : null,
379
402
  c.status,
380
403
  c.writePolicy ? `writes: ${c.writePolicy}` : null,
381
404
  c.installation?.status === "needs_credentials" ? "needs sign-in" : null,
@@ -397,13 +420,17 @@ export function registerIntegrationsCommand(program) {
397
420
  .option("--provider <slug>", "Connected app (e.g. linear)")
398
421
  .option("--connection <id>", "Connection id (acct_…)")
399
422
  .option("--company <slug>", "Company slug, e.g. indigo")
423
+ .option("--account <id-or-label>", "Which account of the app: its connection id or account label")
400
424
  .option("--describe <tool>", "Print the full input schema for one tool")
401
425
  .option("--json", "Machine-readable output")
402
426
  .action(async (opts) => {
403
427
  const token = await ensureCognitoIdToken();
404
428
  const companyUid = await getCompanyUid(token, opts.company);
405
429
  const connections = await fetchConnections(token, companyUid);
406
- const connection = selectConnection(connections, opts);
430
+ const { connection, accountMatches } = selectToolConnection(connections, opts);
431
+ if (accountMatches.length > 1) {
432
+ throw new IntegrationsCliError(accountSelectionMessage(ACCOUNT_REQUIRED_CODE, accountMatches.map(accountCandidateFor), opts.account, "Rerun with --account <connection id>."), { expected: true, code: ACCOUNT_REQUIRED_CODE });
433
+ }
407
434
  if (reportRevokedConnection(connection, opts))
408
435
  return;
409
436
  let payload;
@@ -479,6 +506,7 @@ export function registerIntegrationsCommand(program) {
479
506
  .description("Call one of a connected app's tools (approval-gated when it changes things)")
480
507
  .option("--provider <slug>", "Connected app (e.g. linear)")
481
508
  .option("--connection <id>", "Connection id (acct_…)")
509
+ .option("--account <id-or-label>", "Which account of the app: its connection id or account label")
482
510
  .option("--company <slug>", "Company slug, e.g. indigo")
483
511
  .option("--args <json>", "Tool arguments as a JSON object (default {}); use - to read the JSON from stdin")
484
512
  .option("--args-file <path>", "Read tool arguments from a JSON file (use - for stdin); avoids shell quoting of apostrophes and parentheses")
@@ -490,9 +518,23 @@ export function registerIntegrationsCommand(program) {
490
518
  const parsedArgs = await resolveCallArgs(opts);
491
519
  const token = await ensureCognitoIdToken();
492
520
  const companyUid = await getCompanyUid(token, opts.company);
493
- const connection = selectConnection(await fetchConnections(token, companyUid), opts);
521
+ const { connection, accountMatches } = selectToolConnection(await fetchConnections(token, companyUid), opts);
494
522
  if (reportRevokedConnection(connection, { ...opts, fail: true }))
495
523
  return;
524
+ const accountSelector = opts.account !== undefined &&
525
+ accountMatches.length !== 1 &&
526
+ (connection.ownerType === "person" || connection.ownerType === "company")
527
+ ? opts.account.trim()
528
+ : undefined;
529
+ if (accountSelector === undefined && accountMatches.length > 1) {
530
+ throw new IntegrationsCliError(accountSelectionMessage(ACCOUNT_REQUIRED_CODE, accountMatches.map(accountCandidateFor), opts.account, "Rerun with --account <connection id>."), { expected: true, code: ACCOUNT_REQUIRED_CODE });
531
+ }
532
+ const routing = accountSelector === undefined
533
+ ? { connectionId: connection.id }
534
+ : {};
535
+ const accountParams = accountSelector === undefined
536
+ ? {}
537
+ : { account: accountSelector, provider: connection.provider };
496
538
  const idempotencyKey = opts.idempotencyKey ?? `hq-cli-${randomUUID()}`;
497
539
  let gatewayParams;
498
540
  if (isBuiltInGatewayProvider(connection.provider)) {
@@ -506,7 +548,8 @@ export function registerIntegrationsCommand(program) {
506
548
  companyUid,
507
549
  acceptAsync: true,
508
550
  name: tool,
509
- arguments: { ...parsedArgs, companyUid, connectionId: connection.id, idempotencyKey },
551
+ ...accountParams,
552
+ arguments: { ...parsedArgs, companyUid, ...routing, idempotencyKey },
510
553
  };
511
554
  }
512
555
  else {
@@ -515,9 +558,10 @@ export function registerIntegrationsCommand(program) {
515
558
  companyUid,
516
559
  acceptAsync: true,
517
560
  name: `${prefix}.mcp.tools.call`,
561
+ ...accountParams,
518
562
  arguments: {
519
563
  companyUid,
520
- connectionId: connection.id,
564
+ ...routing,
521
565
  toolName: tool,
522
566
  arguments: parsedArgs,
523
567
  idempotencyKey,
@@ -1,4 +1,5 @@
1
1
  import type { Ctx, LaneResumeOptions } from "./lanes-shared.js";
2
+ import { LanesError } from "../lib/lanes/errors.js";
2
3
  import type { LaneRecord } from "../lib/lanes/types.js";
3
4
  /**
4
5
  * A loop lane that finished a phase and is waiting for its next envelope is
@@ -14,6 +15,25 @@ export declare function settleLoopLaneIdle(hqRoot: string, laneId: string): Lane
14
15
  * worker, so it is handed to resume as the `done` lane the phase left.
15
16
  */
16
17
  export declare function prepareIdleLoopLaneForResume(hqRoot: string, laneId: string): LaneRecord;
18
+ /**
19
+ * LanesError codes from the resume admission path that mean admission could
20
+ * not run right now and a later admission can retry: the admission or store
21
+ * lock timed out (store_lock_timeout), the lane changed while admission was
22
+ * checked (lane_admission_conflict), or a lane record CAS miss
23
+ * (version_conflict). Any other resume error is permanent and is rethrown.
24
+ */
25
+ export declare const TRANSIENT_LOOP_ADMISSION_ERRORS: ReadonlySet<string>;
26
+ /**
27
+ * Park a loop phase whose resume was refused before an engine launched. The
28
+ * lane becomes `queued` with `admit_failed` and the error, and carries the
29
+ * phase as `pending_resume`, so admission can launch it later and the lane is
30
+ * never left looking active with no engine.
31
+ */
32
+ export declare function deferRefusedLoopPhase(hqRoot: string, laneId: string, at: string, error: LanesError, phase: {
33
+ text: string;
34
+ sessionId: string | undefined;
35
+ workingDirectory: string;
36
+ }): LaneRecord;
17
37
  export interface LoopControllerDeps {
18
38
  resumeLane(laneId: string, options: LaneResumeOptions): Promise<unknown>;
19
39
  interruptLane(laneId: string, reason: string, options: {
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { readLane, retryUpdateLane, updateLane } from "../lib/lanes/store.js";
5
5
  import { LanesError, isLaneVersionConflict } from "../lib/lanes/errors.js";
6
+ import { liveNodeBinary } from "../lib/lanes/node-binary.js";
6
7
  import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
7
8
  import { spawnLaneWorker } from "../lib/lanes/spawn.js";
8
9
  import { raiseLaneQuestion } from "../lib/lanes/questions.js";
@@ -33,6 +34,44 @@ export function settleLoopLaneIdle(hqRoot, laneId) {
33
34
  export function prepareIdleLoopLaneForResume(hqRoot, laneId) {
34
35
  return casLoopLaneState(hqRoot, laneId, "running", "done");
35
36
  }
37
+ /**
38
+ * LanesError codes from the resume admission path that mean admission could
39
+ * not run right now and a later admission can retry: the admission or store
40
+ * lock timed out (store_lock_timeout), the lane changed while admission was
41
+ * checked (lane_admission_conflict), or a lane record CAS miss
42
+ * (version_conflict). Any other resume error is permanent and is rethrown.
43
+ */
44
+ export const TRANSIENT_LOOP_ADMISSION_ERRORS = new Set([
45
+ "store_lock_timeout",
46
+ "lane_admission_conflict",
47
+ "version_conflict",
48
+ ]);
49
+ /**
50
+ * Park a loop phase whose resume was refused before an engine launched. The
51
+ * lane becomes `queued` with `admit_failed` and the error, and carries the
52
+ * phase as `pending_resume`, so admission can launch it later and the lane is
53
+ * never left looking active with no engine.
54
+ */
55
+ export function deferRefusedLoopPhase(hqRoot, laneId, at, error, phase) {
56
+ return retryUpdateLane(hqRoot, laneId, "command", (record) => ({
57
+ ...record,
58
+ state: "queued",
59
+ queued_reason: "admit_failed",
60
+ queued_details: { reason: "admit_failed", error: error.error, message: error.message || error.error },
61
+ queued_at: at,
62
+ admission_reserved_at: undefined,
63
+ pending_resume: {
64
+ ...(phase.sessionId ? { session_id: phase.sessionId } : { fresh_thread: true }),
65
+ trigger: "resume",
66
+ caller: "loop",
67
+ resumed_from: "done",
68
+ text: phase.text,
69
+ compact: true,
70
+ working_directory: phase.workingDirectory,
71
+ at,
72
+ },
73
+ }));
74
+ }
36
75
  function casLoopLaneState(hqRoot, laneId, from, to) {
37
76
  for (let attempt = 0;; attempt += 1) {
38
77
  const current = readLane(hqRoot, laneId);
@@ -67,8 +106,8 @@ export function startLaneLoopController(ctx, laneId, initialAdmission = false) {
67
106
  const args = [entry, "lanes", "_loop-worker", laneId];
68
107
  const env = { ...ctx.env, HQ_LANE_LOOP_CONTROLLER: "1", ...(initialAdmission ? { HQ_LANE_LOOP_INITIAL: "1" } : {}) };
69
108
  const child = ctx.spawnProcess
70
- ? ctx.spawnProcess(process.execPath, args, { cwd: ctx.hqRoot(), detached: true, windowsHide: true, stdio: ["ignore", 1, 2], env })
71
- : spawn(process.execPath, args, { cwd: ctx.hqRoot(), detached: true, stdio: "ignore", env });
109
+ ? ctx.spawnProcess(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, windowsHide: true, stdio: ["ignore", 1, 2], env })
110
+ : spawn(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, stdio: "ignore", env });
72
111
  child.unref();
73
112
  if (child.pid === undefined)
74
113
  throw new LanesError("loop_controller_spawn_failed", "Loop controller did not start", { lane_id: laneId });
@@ -208,15 +247,34 @@ export async function runLaneLoopController(ctx, laneId, deps) {
208
247
  else {
209
248
  if (current.state === "running")
210
249
  current = prepareIdleLoopLaneForResume(hqRoot, laneId);
211
- await deps.resumeLane(laneId, {
212
- text: `Read ${briefPath} and follow it as the complete instruction for this phase.`,
213
- project: current.project_id,
214
- ...(storyId ? { story: storyId } : {}),
215
- freshThread: fresh,
216
- ...(sessionId ? { loopSessionId: sessionId } : {}),
217
- ...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
218
- workingDirectory: worktree,
219
- });
250
+ const resumeText = `Read ${briefPath} and follow it as the complete instruction for this phase.`;
251
+ try {
252
+ await deps.resumeLane(laneId, {
253
+ text: resumeText,
254
+ project: current.project_id,
255
+ ...(storyId ? { story: storyId } : {}),
256
+ freshThread: fresh,
257
+ ...(sessionId ? { loopSessionId: sessionId } : {}),
258
+ ...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
259
+ workingDirectory: worktree,
260
+ });
261
+ }
262
+ catch (error) {
263
+ // Transient admission refusal before any engine launched (for
264
+ // example a store lock timeout). The envelope is already active, so keep it
265
+ // and park the lane as queued with a reason `hq lanes show`
266
+ // displays; the next admission launches this phase from
267
+ // pending_resume and the wait below picks up its result.
268
+ if (!(error instanceof LanesError) ||
269
+ !TRANSIENT_LOOP_ADMISSION_ERRORS.has(error.error) ||
270
+ readLane(hqRoot, laneId).state !== "done")
271
+ throw error;
272
+ deferRefusedLoopPhase(hqRoot, laneId, ctx.now().toISOString(), error, {
273
+ text: resumeText,
274
+ sessionId: fresh ? undefined : sessionId ?? current.provider_session_id,
275
+ workingDirectory: worktree,
276
+ });
277
+ }
220
278
  }
221
279
  let interruptPromise;
222
280
  const abortPhase = () => {
@@ -508,9 +508,17 @@ function livenessDepsFromCtx(ctx) {
508
508
  * envelopes; every other lane spawns its worker directly. Every admission path
509
509
  * (reconcile, in-process queue drain, hq lanes admit) goes through here so a
510
510
  * loop lane never runs its brief without a controller.
511
+ *
512
+ * A queued loop lane whose controller is still alive is a deferred phase: the
513
+ * controller already activated the envelope and is waiting for its engine.
514
+ * That lane spawns the phase worker from pending_resume; starting a second
515
+ * controller would lose the runner lease and leave the lane running with no
516
+ * engine (feedback_865fe8a0).
511
517
  */
512
518
  function startAdmittedLane(ctx, root, candidate) {
513
- if (candidate.loop_mode !== true || candidate.state !== "queued") {
519
+ const deferredLoopPhase = candidate.loop_mode === true && candidate.pending_resume !== undefined &&
520
+ loopControllerIsAlive(runDir(root, candidate.lane_id));
521
+ if (candidate.loop_mode !== true || candidate.state !== "queued" || deferredLoopPhase) {
514
522
  const admissionHost = reconcileHostFromCtx(ctx);
515
523
  return spawnLaneWorker(root, candidate, {
516
524
  env: ctx.env,
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { Command } from 'commander';
23
23
  import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
24
+ import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
24
25
  export interface RescueTarget {
25
26
  source: string;
26
27
  /** undefined → let the script apply its own default ref (`main`). */
@@ -52,6 +53,14 @@ export declare function shouldAttemptSelfUpdate(opts: {
52
53
  * changes the rescue's exit status, because the core update itself succeeded.
53
54
  */
54
55
  export declare function retrustHooksAfterRescue(hqRoot: string, retrust?: typeof retrustCodexHooksAfterRescue): Promise<void>;
56
+ /**
57
+ * Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
58
+ * that do not resolve after a rescue, for example because they were laid at
59
+ * the rescue's temp extraction folder, which is then deleted
60
+ * (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
61
+ * but never changes the rescue's exit status.
62
+ */
63
+ export declare function repairReleaseLinksAfterRescue(hqRoot: string, repair?: typeof repairDanglingReleaseLinks): void;
55
64
  /** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
56
65
  export declare function getLatestReleaseTag(repo: string, token?: string): Promise<string>;
57
66
  /** Resolve a tag to its commit SHA, dereferencing annotated tags. */
@@ -6,6 +6,7 @@ import { rescue } from '@indigoai-us/hq-cloud';
6
6
  import { findHqRoot } from '../utils/manifest.js';
7
7
  import { selfUpdateAndReexec } from '../utils/self-update.js';
8
8
  import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
9
+ import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
9
10
  import { PROD_SOURCE, STAGING_SOURCE, resolveGhToken } from '../utils/shipped-codex-hooks.js';
10
11
  /**
11
12
  * Resolve the source repo + ref from the user's flags. Pure + exported for
@@ -49,6 +50,35 @@ export async function retrustHooksAfterRescue(hqRoot, retrust = retrustCodexHook
49
50
  'Codex will skip changed HQ hooks until you run `hq reindex`.'));
50
51
  }
51
52
  }
53
+ /**
54
+ * Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
55
+ * that do not resolve after a rescue, for example because they were laid at
56
+ * the rescue's temp extraction folder, which is then deleted
57
+ * (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
58
+ * but never changes the rescue's exit status.
59
+ */
60
+ export function repairReleaseLinksAfterRescue(hqRoot, repair = repairDanglingReleaseLinks) {
61
+ try {
62
+ for (const entry of repair(hqRoot)) {
63
+ if (entry.action === 'repointed') {
64
+ console.log(`rescue: repointed dangling link ${entry.rel} -> ${entry.linkText ?? entry.target}`);
65
+ }
66
+ else if (entry.action === 'removed') {
67
+ console.log(`rescue: removed dangling link ${entry.rel} (its target is missing)`);
68
+ }
69
+ else if (entry.action === 'skipped') {
70
+ console.log(`rescue: left dangling link ${entry.rel} alone: ${entry.reason ?? 'not an HQ-managed target'}`);
71
+ }
72
+ else {
73
+ console.warn(chalk.yellow(`rescue: could not repair ${entry.rel}: ${entry.reason ?? 'unknown error'}. Run \`hq doctor --fix\`.`));
74
+ }
75
+ }
76
+ }
77
+ catch (err) {
78
+ console.warn(chalk.yellow(`rescue: could not repair dangling release links: ${err instanceof Error ? err.message : 'unknown error'}. ` +
79
+ 'Run `hq doctor --fix`.'));
80
+ }
81
+ }
52
82
  function ghHeaders(token) {
53
83
  const headers = {
54
84
  Accept: 'application/vnd.github+json',
@@ -185,6 +215,7 @@ export function registerRescueCommand(program) {
185
215
  ghToken: token,
186
216
  });
187
217
  if (status === 0 && !opts.check) {
218
+ repairReleaseLinksAfterRescue(hqRoot);
188
219
  await retrustHooksAfterRescue(hqRoot);
189
220
  }
190
221
  process.exit(status);