@indigoai-us/hq-cli 5.300.0 → 5.302.0

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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +99 -12
  3. package/assets/scaffold/core/scripts/token-usage-report.sh +9 -0
  4. package/dist/command-catalog.generated.d.ts +32 -3
  5. package/dist/command-catalog.generated.js +39 -3
  6. package/dist/commands/__fixtures__/access-vault.d.ts +2 -0
  7. package/dist/commands/__fixtures__/access-vault.js +1 -1
  8. package/dist/commands/access.d.ts +2 -1
  9. package/dist/commands/access.js +58 -49
  10. package/dist/commands/bot.js +1 -0
  11. package/dist/commands/cloud-provision.js +51 -2
  12. package/dist/commands/core-checkpoint.js +1 -0
  13. package/dist/commands/core-company-index-scope-flag.d.ts +3 -0
  14. package/dist/commands/core-company-index-scope-flag.js +6 -0
  15. package/dist/commands/core-handoff-fast.js +1 -0
  16. package/dist/commands/core-handoff.js +2 -0
  17. package/dist/commands/core-job-native-utils.d.ts +6 -0
  18. package/dist/commands/core-job-native-utils.js +80 -0
  19. package/dist/commands/core-job-native.d.ts +2 -4
  20. package/dist/commands/core-job-native.js +6 -10
  21. package/dist/commands/core-job-notify-native.d.ts +36 -0
  22. package/dist/commands/core-job-notify-native.js +359 -0
  23. package/dist/commands/core-job-remediate-native.d.ts +26 -0
  24. package/dist/commands/core-job-remediate-native.js +374 -0
  25. package/dist/commands/core.js +26 -8
  26. package/dist/commands/deploy-comments.js +5 -2
  27. package/dist/commands/files.js +31 -14
  28. package/dist/commands/integrations-api.d.ts +6 -6
  29. package/dist/commands/integrations-api.js +4 -13
  30. package/dist/commands/integrations-connect.d.ts +3 -0
  31. package/dist/commands/integrations-connect.js +21 -2
  32. package/dist/commands/integrations-manage.js +1 -4
  33. package/dist/commands/lanes-message.js +43 -5
  34. package/dist/commands/lanes-questions.d.ts +4 -2
  35. package/dist/commands/lanes-questions.js +123 -2
  36. package/dist/commands/lanes-resume.js +25 -4
  37. package/dist/commands/lanes-shared.d.ts +15 -1
  38. package/dist/commands/lanes-watch.js +5 -1
  39. package/dist/commands/lanes.d.ts +2 -0
  40. package/dist/commands/lanes.js +92 -9
  41. package/dist/commands/meetings.js +35 -16
  42. package/dist/commands/monitor.js +17 -2
  43. package/dist/commands/report.js +5 -2
  44. package/dist/commands/rescue.d.ts +4 -0
  45. package/dist/commands/rescue.js +27 -17
  46. package/dist/commands/safe-extract.d.ts +4 -4
  47. package/dist/commands/safe-extract.js +6 -7
  48. package/dist/lib/agent-kit/fallback.d.ts +1 -0
  49. package/dist/lib/agent-kit/fallback.js +1 -0
  50. package/dist/lib/bot/daemon.d.ts +5 -1
  51. package/dist/lib/bot/daemon.js +4 -4
  52. package/dist/lib/core-utils/soft-timeout.js +1 -1
  53. package/dist/lib/core-utils/token-usage-report.js +17 -3
  54. package/dist/lib/deploy-apps.d.ts +13 -0
  55. package/dist/lib/deploy-apps.js +24 -0
  56. package/dist/lib/index-render/companies.d.ts +4 -1
  57. package/dist/lib/index-render/companies.js +66 -3
  58. package/dist/lib/index-render/index.d.ts +1 -0
  59. package/dist/lib/index-render/index.js +2 -1
  60. package/dist/lib/index-render/projects.js +82 -5
  61. package/dist/lib/index-render/shared.d.ts +1 -1
  62. package/dist/lib/index-render/shared.js +4 -2
  63. package/dist/lib/lanes/delivery.d.ts +7 -3
  64. package/dist/lib/lanes/delivery.js +18 -8
  65. package/dist/lib/lanes/index.d.ts +1 -1
  66. package/dist/lib/lanes/keep-alive.d.ts +13 -3
  67. package/dist/lib/lanes/keep-alive.js +29 -5
  68. package/dist/lib/lanes/outpost-dispatch.d.ts +5 -0
  69. package/dist/lib/lanes/outpost-dispatch.js +12 -0
  70. package/dist/lib/lanes/reconcile.d.ts +1 -0
  71. package/dist/lib/lanes/reconcile.js +358 -38
  72. package/dist/lib/lanes/spawn.d.ts +1 -0
  73. package/dist/lib/lanes/spawn.js +20 -10
  74. package/dist/lib/lanes/store.js +24 -0
  75. package/dist/lib/lanes/story-ledger.d.ts +2 -0
  76. package/dist/lib/lanes/story-ledger.js +8 -0
  77. package/dist/lib/lanes/types.d.ts +26 -4
  78. package/dist/lib/lanes/types.js +33 -1
  79. package/dist/lib/lanes/watch.d.ts +1 -0
  80. package/dist/lib/lanes/watch.js +19 -6
  81. package/dist/lib/lanes/watcher.js +139 -9
  82. package/dist/lib/mesh/live/daemon/mqtt-events.d.ts +7 -0
  83. package/dist/lib/mesh/live/daemon/mqtt-events.js +2 -0
  84. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.d.ts +2 -0
  85. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.js +10 -0
  86. package/dist/lib/mesh/live/daemon/presence.d.ts +2 -2
  87. package/dist/lib/mesh/live/daemon/run.js +36 -2
  88. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +2 -2
  89. package/dist/lib/search-index/background.d.ts +1 -0
  90. package/dist/lib/search-index/background.js +1 -0
  91. package/dist/utils/self-update.js +51 -4
  92. package/dist/utils/staged-npm-install.d.ts +16 -0
  93. package/dist/utils/staged-npm-install.js +264 -0
  94. package/dist/utils/update-command-supervisor.cjs +54 -1
  95. package/dist/utils/version-gate.d.ts +8 -0
  96. package/dist/utils/version-gate.js +83 -16
  97. package/package.json +2 -2
  98. package/dist/lib/install-path-compatibility-flag.d.ts +0 -9
  99. package/dist/lib/install-path-compatibility-flag.js +0 -12
@@ -185,6 +185,12 @@ export function assertAuthMode(value) {
185
185
  throw new IntegrationsCliError("--auth must be none, key, or oauth.", { expected: true });
186
186
  }
187
187
  }
188
+ /** RFC 7230 token characters accepted for an HTTP field name. */
189
+ export function assertAuthHeader(value) {
190
+ if (value !== undefined && !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(value)) {
191
+ throw new IntegrationsCliError("--auth-header must be a non-empty HTTP header name using RFC 7230 token characters.", { expected: true });
192
+ }
193
+ }
188
194
  /** Single-quote a value for a copy-pasteable shell command. */
189
195
  export function shellQuote(value) {
190
196
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -904,7 +910,9 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
904
910
  */
905
911
  async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
906
912
  const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
907
- const authMode = opts.auth ?? target.authClass;
913
+ const authMode = opts.authHeader
914
+ ? "key"
915
+ : opts.auth ?? target.authClass;
908
916
  assertUserClientApplicable(opts, target, authMode);
909
917
  assertScopesApplicable(opts, target, authMode);
910
918
  if (authMode === "oauth") {
@@ -922,7 +930,13 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
922
930
  const result = await installIntegration(token, companyUid, {
923
931
  ...target.ref,
924
932
  ...(bearerToken
925
- ? { authMode: "bearer", bearerToken }
933
+ ? {
934
+ authMode: "bearer",
935
+ bearerToken,
936
+ ...(opts.authHeader
937
+ ? { authScheme: { placement: "header", header: opts.authHeader } }
938
+ : {}),
939
+ }
926
940
  : authMode === "none"
927
941
  ? { authMode: "none" }
928
942
  : {}),
@@ -1173,6 +1187,7 @@ export function registerConnectCommands(integrations) {
1173
1187
  .option("--display-name <name>", "Display name for a custom --mcp-url")
1174
1188
  .option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
1175
1189
  .option("--token-stdin", "Read the API key from stdin")
1190
+ .option("--auth-header <name>", "Send the API key in this HTTP header (implies --auth key)")
1176
1191
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
1177
1192
  .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
1178
1193
  .option("--client-secret-stdin", "Read that app's client secret from stdin")
@@ -1183,6 +1198,10 @@ export function registerConnectCommands(integrations) {
1183
1198
  .option("--json", "Machine-readable output")
1184
1199
  .action(async (app, opts) => {
1185
1200
  assertAuthMode(opts.auth);
1201
+ assertAuthHeader(opts.authHeader);
1202
+ if (opts.authHeader && opts.auth !== undefined && opts.auth !== "key") {
1203
+ throw new IntegrationsCliError("--auth-header can only be used with --auth key.", { expected: true });
1204
+ }
1186
1205
  const token = await ensureCognitoIdToken();
1187
1206
  const companyUid = await getCompanyUid(token, opts.company);
1188
1207
  if (await refuseIfAlreadyConnected(token, companyUid, app, opts))
@@ -610,9 +610,6 @@ export function registerManageCommands(integrations) {
610
610
  const companyUid = await getCompanyUid(token, opts.company);
611
611
  const connection = await resolveConnection(token, companyUid, app, opts);
612
612
  const installationId = connection.installation?.id;
613
- if (!installationId) {
614
- throw new IntegrationsCliError(`${connectionLabel(connection)} was not installed through the app catalog, so it cannot be disconnected from here.`, { expected: true });
615
- }
616
613
  if (!opts.yes) {
617
614
  const label = connectionLabel(connection);
618
615
  // Warning + prompt go to stderr: with --json, stdout must stay parseable,
@@ -625,7 +622,7 @@ export function registerManageCommands(integrations) {
625
622
  throw new IntegrationsCliError("Not disconnected. Re-run with --yes if you are sure.", { expected: true });
626
623
  }
627
624
  }
628
- const result = await uninstallIntegration(token, companyUid, installationId);
625
+ const result = await uninstallIntegration(token, companyUid, installationId ? { installationId } : { connectionId: connection.id });
629
626
  if (opts.json) {
630
627
  printJson(result);
631
628
  return;
@@ -247,6 +247,7 @@ export function createLaneMessageHandlers(deps) {
247
247
  }
248
248
  let lane = resolution.lane;
249
249
  if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
250
+ const fallbackAt = lane.state === "awaiting_input" ? ctx.now().toISOString() : undefined;
250
251
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
251
252
  const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
252
253
  ? undefined
@@ -262,9 +263,14 @@ export function createLaneMessageHandlers(deps) {
262
263
  ...opts,
263
264
  ...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
264
265
  ...(supersededMessageSessionId === undefined ? {} : { supersededMessageSessionId }),
265
- }, resolution.remoteOnly
266
- ? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
267
- : undefined);
266
+ }, {
267
+ ...(resolution.remoteOnly
268
+ ? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
269
+ : {}),
270
+ ...(fallbackAt === undefined ? {} : {
271
+ afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "message", fallbackAt),
272
+ }),
273
+ });
268
274
  return;
269
275
  }
270
276
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
@@ -350,6 +356,19 @@ export function createLaneMessageHandlers(deps) {
350
356
  if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
351
357
  throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
352
358
  }
359
+ const pendingAnswerableQuestion = lane.state === "awaiting_input"
360
+ ? await deps.hasPendingQuestionForLane(ctx, lane)
361
+ : false;
362
+ if (pendingAnswerableQuestion) {
363
+ const message = deliveryRefusalReason(lane);
364
+ throw new LanesError("lane_not_accepting_messages", message, {
365
+ lane_id: laneId,
366
+ state: lane.state,
367
+ provider: lane.provider,
368
+ message,
369
+ });
370
+ }
371
+ const awaitingInputFallback = lane.state === "awaiting_input" && !pendingAnswerableQuestion;
353
372
  // One decision point for how a follow-up reaches this lane (delivery.ts).
354
373
  // `none` refuses before anything is written: no inbox append, no spawn.
355
374
  const mechanism = laneDeliveryMechanism(lane);
@@ -488,6 +507,8 @@ export function createLaneMessageHandlers(deps) {
488
507
  });
489
508
  lane = move.lane;
490
509
  if (move.queued) {
510
+ storySwitchDeliveryStarted = true;
511
+ deps.onLaneMessageQueued?.(laneId, "account_move");
491
512
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
492
513
  }
493
514
  }
@@ -513,8 +534,11 @@ export function createLaneMessageHandlers(deps) {
513
534
  accountSelection: selection,
514
535
  });
515
536
  lane = move.lane;
516
- if (move.queued)
537
+ if (move.queued) {
538
+ storySwitchDeliveryStarted = true;
539
+ deps.onLaneMessageQueued?.(laneId, "account_move");
517
540
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
541
+ }
518
542
  }
519
543
  else {
520
544
  lane = persistClaudeAccountSelection(hqRoot, lane, selection);
@@ -531,9 +555,13 @@ export function createLaneMessageHandlers(deps) {
531
555
  ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
532
556
  });
533
557
  }
558
+ let questionCancellation = null;
534
559
  if (move?.queued) {
535
560
  // Parked at the target's cap: reconcile resumes the session there.
536
561
  markAccepted();
562
+ questionCancellation = awaitingInputFallback
563
+ ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
564
+ : null;
537
565
  }
538
566
  else {
539
567
  const dispatched = await dispatchLaneDelivery(lane, {
@@ -565,7 +593,7 @@ export function createLaneMessageHandlers(deps) {
565
593
  }
566
594
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
567
595
  queuedOnAdmission = true;
568
- return casUpdateLane(hqRoot, laneId, (current) => {
596
+ const queued = casUpdateLane(hqRoot, laneId, (current) => {
569
597
  if (current.version !== lane.version || current.state !== lane.state) {
570
598
  throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
571
599
  lane_id: laneId,
@@ -592,6 +620,9 @@ export function createLaneMessageHandlers(deps) {
592
620
  },
593
621
  };
594
622
  });
623
+ storySwitchDeliveryStarted = true;
624
+ deps.onLaneMessageQueued?.(laneId, "admission");
625
+ return queued;
595
626
  }
596
627
  const started = spawnLaneWorker(hqRoot, lane, {
597
628
  env: ctx.env,
@@ -629,6 +660,9 @@ export function createLaneMessageHandlers(deps) {
629
660
  if (dispatched.mechanism === "resume")
630
661
  resumed = dispatched.value;
631
662
  markAccepted();
663
+ questionCancellation = awaitingInputFallback
664
+ ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
665
+ : null;
632
666
  }
633
667
  // Every accepted message moves the compaction reference forward.
634
668
  const next = casUpdateLane(hqRoot, laneId, (current) => ({
@@ -671,6 +705,10 @@ export function createLaneMessageHandlers(deps) {
671
705
  inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
672
706
  drain_hook: drainHook,
673
707
  warning,
708
+ ...(questionCancellation === null ? {} : {
709
+ cancelled_questions: questionCancellation.cancelled_question_ids,
710
+ question_cancellation_failures: questionCancellation.failures,
711
+ }),
674
712
  resumed_session_id: resumed?.last_resume?.session_id ?? null,
675
713
  compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
676
714
  ? resumed.last_compaction
@@ -1,7 +1,9 @@
1
- import { type QuestionBody, type QuestionRecord } from "../lib/lanes/index.js";
2
- import type { Ctx, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
1
+ import { type QuestionBody, type LaneRecord, type QuestionRecord } from "../lib/lanes/index.js";
2
+ import type { Ctx, FallbackQuestionCancellationResult, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
3
3
  export declare function resolveAnswerChoice(choice: string, options: string[], freeform: boolean): string;
4
4
  export declare function createLaneQuestionsHandlers(deps: LaneQuestionsHandlerDeps): {
5
+ hasPendingQuestionForLane: (ctx: Ctx, lane: LaneRecord) => Promise<boolean>;
6
+ cancelFallbackQuestionsForLane: (ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string) => Promise<FallbackQuestionCancellationResult>;
5
7
  runQuestionsList: (ctx: Ctx, opts: {
6
8
  allHosts?: boolean;
7
9
  for?: string;
@@ -1,4 +1,4 @@
1
- import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
1
+ import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
2
2
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
3
3
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
4
4
  import { appendCappedHistory, createQuestion, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
20
20
  throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
21
21
  }
22
22
  export function createLaneQuestionsHandlers(deps) {
23
- const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
23
+ const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, casUpdateLane, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
24
24
  function requireQuestionBody(hqRoot, question) {
25
25
  const body = readQuestionBody(hqRoot, question);
26
26
  if (!body) {
@@ -52,6 +52,16 @@ export function createLaneQuestionsHandlers(deps) {
52
52
  return audience.kind === "user";
53
53
  return audience.kind === filter.kind && audience.id === filter.id;
54
54
  }
55
+ function questionAudienceMatchesSenior(audience, lane, ctx) {
56
+ if (audienceMatches(audience, lane.senior))
57
+ return true;
58
+ const sessionId = sessionIdFromEnv(ctx.env);
59
+ if (sessionId && audienceMatches(audience, { kind: "session", id: sessionId }))
60
+ return true;
61
+ const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
62
+ return callerLaneId !== undefined && callerLaneId.length > 0 &&
63
+ audienceMatches(audience, { kind: "lane", id: callerLaneId });
64
+ }
55
65
  function parseForFilter(raw, ctx) {
56
66
  const trimmed = raw.trim();
57
67
  if (trimmed === "me") {
@@ -384,6 +394,115 @@ export function createLaneQuestionsHandlers(deps) {
384
394
  errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
385
395
  };
386
396
  }
397
+ async function cancelFallbackQuestionsForLane(ctx, lane, trigger, at) {
398
+ const hqRoot = ctx.hqRoot();
399
+ const caller = ctx.env.HQ_LANE_ID?.trim() || sessionIdFromEnv(ctx.env) || "user";
400
+ const reason = `Fallback ${trigger} by ${caller} at ${at}`;
401
+ const failures = [];
402
+ const cancelledQuestionIds = [];
403
+ const remoteOwner = lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane);
404
+ let pending;
405
+ try {
406
+ pending = remoteOwner
407
+ ? (await readOutpostQuestionRecords(ctx, lane.host.id, effectiveLaneQuestionCompany(lane, hqRoot)))
408
+ .map(({ question }) => question)
409
+ .filter((question) => question.origin.id === lane.lane_id && question.status === "pending")
410
+ : loadAllQuestions(hqRoot).filter((question) => question.origin.id === lane.lane_id && question.status === "pending");
411
+ }
412
+ catch (err) {
413
+ failures.push({ error: dispatchErrorMessage(err) });
414
+ pending = [];
415
+ }
416
+ for (const question of pending) {
417
+ try {
418
+ if (remoteOwner) {
419
+ const outpostId = lane.host.id;
420
+ const dispatched = await ctx.dispatchOutpostExec({
421
+ outpostId,
422
+ command: buildOutpostQuestionCancelCommand({ questionId: question.question_id, reason }),
423
+ timeoutSeconds: Math.ceil(CAPACITY_PROBE_TIMEOUT_MS / 1000),
424
+ waitMs: CAPACITY_PROBE_TIMEOUT_MS,
425
+ });
426
+ const remote = parseRemoteJson(dispatched.stdout, "Outpost question cancel");
427
+ const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
428
+ ? remote.question
429
+ : null;
430
+ if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0 ||
431
+ remote?.ok !== true || remote.action !== "questions.cancel" ||
432
+ remoteQuestion?.question_id !== question.question_id || remoteQuestion.status !== "cancelled") {
433
+ throw new LanesError("outpost_question_cancel_unconfirmed", `Outpost ${outpostId} did not confirm cancellation of question ${question.question_id}`, { lane_id: lane.lane_id, question_id: question.question_id, outpost_id: outpostId });
434
+ }
435
+ }
436
+ else {
437
+ const cancellationEntry = {
438
+ hop: question.hop,
439
+ audience: resolveAudience(hqRoot, question, ctx),
440
+ at,
441
+ reason: "cancelled",
442
+ detail: reason,
443
+ };
444
+ const updated = casUpdateQuestion(hqRoot, question.question_id, (current) => {
445
+ if (current.status !== "pending")
446
+ return current;
447
+ return {
448
+ ...current,
449
+ status: "cancelled",
450
+ routing_history: appendCappedHistory(current.routing_history, cancellationEntry),
451
+ };
452
+ });
453
+ if (updated.status !== "cancelled")
454
+ continue;
455
+ }
456
+ cancelledQuestionIds.push(question.question_id);
457
+ }
458
+ catch (err) {
459
+ failures.push({ question_id: question.question_id, error: dispatchErrorMessage(err) });
460
+ }
461
+ }
462
+ if (failures.length > 0) {
463
+ const failure = {
464
+ at,
465
+ caller,
466
+ trigger,
467
+ question_ids: failures.flatMap((entry) => entry.question_id ? [entry.question_id] : []),
468
+ error: failures.map((entry) => entry.error).join("; "),
469
+ };
470
+ try {
471
+ casUpdateLane(hqRoot, lane.lane_id, (current) => {
472
+ const history = current.fallback_question_cancellation_failures ?? [];
473
+ const duplicate = history.some((entry) => entry.at === failure.at && entry.caller === caller && entry.trigger === trigger);
474
+ return duplicate
475
+ ? current
476
+ : {
477
+ ...current,
478
+ fallback_question_cancellation_failures: [...history, failure].slice(-20),
479
+ };
480
+ });
481
+ }
482
+ catch (err) {
483
+ const message = `Could not record fallback question cancellation failure for lane ${lane.lane_id}: ${dispatchErrorMessage(err)}\n`;
484
+ process.stderr.write(message);
485
+ failures.push({ error: `Failure record could not be persisted: ${dispatchErrorMessage(err)}` });
486
+ }
487
+ }
488
+ return { cancelled_question_ids: cancelledQuestionIds, failures };
489
+ }
490
+ async function hasPendingQuestionForLane(ctx, lane) {
491
+ const hqRoot = ctx.hqRoot();
492
+ const local = loadAllQuestions(hqRoot).some((question) => question.lane_id === lane.lane_id &&
493
+ question.status === "pending" &&
494
+ questionAudienceMatchesSenior(resolveAudience(hqRoot, question, ctx), lane, ctx));
495
+ if (local)
496
+ return true;
497
+ if (lane.host.kind !== "outpost" || laneIsHostedHere(ctx, lane))
498
+ return false;
499
+ // Reuse the owner lookup used by questions list/answer. An unreachable
500
+ // owner leaves no answerable question, so the message/resume path can recover.
501
+ const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
502
+ return lookup.questions.some(({ question, lane: ownerLane, audience }) => ownerLane.lane_id === lane.lane_id &&
503
+ question.status === "pending" &&
504
+ questionAudienceMatchesSenior(audience, ownerLane, ctx));
505
+ }
387
506
  function syncPrimaryQuestionDelivery(hqRoot, owner) {
388
507
  const deliveredAt = owner.question.delivered_at;
389
508
  if (!deliveredAt)
@@ -870,6 +989,8 @@ export function createLaneQuestionsHandlers(deps) {
870
989
  });
871
990
  }
872
991
  return {
992
+ hasPendingQuestionForLane,
993
+ cancelFallbackQuestionsForLane,
873
994
  runQuestionsList,
874
995
  runQuestionsShow,
875
996
  runQuestionsAnswer,
@@ -75,7 +75,8 @@ export function createLaneResumeHandlers(deps) {
75
75
  }
76
76
  /**
77
77
  * `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
78
- * continues a lane in `interrupted`, `done` or `blocked`, or a failed lane
78
+ * continues a lane in `interrupted`, `done` or `blocked`, an `awaiting_input`
79
+ * lane with no answerable pending question, or a failed lane
79
80
  * whose only terminal reason is an invalid envelope after its repair turn,
80
81
  * on its recorded
81
82
  * provider session. `hq lanes message` keeps refusing `interrupted`; this
@@ -124,12 +125,21 @@ export function createLaneResumeHandlers(deps) {
124
125
  throw new LanesError("worker_profile_hash_mismatch", "Profile re-pin context is only valid on an Outpost owner dispatch", { lane_id: laneId });
125
126
  }
126
127
  if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
128
+ const fallbackAt = lane.state === "awaiting_input" ? ctx.now().toISOString() : undefined;
127
129
  if (!resolution.remoteOnly) {
128
130
  await withResumeHireAdmission(ctx, lane, () => undefined);
129
131
  }
130
132
  await dispatchOutpostLaneCommand(ctx, lane, "resume", opts, resolution.remoteOnly
131
- ? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
132
- : undefined);
133
+ ? {
134
+ syncPrimary: false,
135
+ expectedOwnerHost: resolution.ownerHost,
136
+ ...(fallbackAt === undefined ? {} : {
137
+ afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", fallbackAt),
138
+ }),
139
+ }
140
+ : fallbackAt === undefined
141
+ ? undefined
142
+ : { afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", fallbackAt) });
133
143
  return;
134
144
  }
135
145
  // Fail before provider/session work if a terminal child would consume a
@@ -171,7 +181,10 @@ export function createLaneResumeHandlers(deps) {
171
181
  opts.compact === false)) {
172
182
  throw new LanesError("worker_profile_recovery_options_invalid", "Queued worker-profile recovery accepts only --worker; rerun hq lanes resume after reconcile admits the lane", { lane_id: laneId });
173
183
  }
174
- const refusal = queuedProfileRecovery
184
+ const pendingQuestion = lane.state === "awaiting_input"
185
+ ? await deps.hasPendingQuestionForLane(ctx, lane)
186
+ : false;
187
+ const refusal = queuedProfileRecovery || (lane.state === "awaiting_input" && !pendingQuestion)
175
188
  ? null
176
189
  : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
177
190
  if (refusal !== null) {
@@ -444,6 +457,7 @@ export function createLaneResumeHandlers(deps) {
444
457
  trigger: {
445
458
  kind: "resume",
446
459
  from: resumedFrom,
460
+ ...(resumedFrom === "awaiting_input" && !pendingQuestion ? { allowAwaitingInput: true } : {}),
447
461
  ...(opts.fromFailed === true ? { fromFailed: true } : {}),
448
462
  ...(failedResumeExpectedSenior !== undefined
449
463
  ? { expectedSenior: failedResumeExpectedSenior }
@@ -460,6 +474,9 @@ export function createLaneResumeHandlers(deps) {
460
474
  }, hireGrantContext);
461
475
  }, ctx.sleep);
462
476
  }
477
+ const questionCancellation = resumedFrom === "awaiting_input" && !pendingQuestion
478
+ ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", at)
479
+ : null;
463
480
  if (deliveryText !== undefined && messageId !== undefined) {
464
481
  // Same audit copy a message resume leaves; nothing drains it. Archive only
465
482
  // after a successful spawn so a concurrent resume cannot leave a phantom.
@@ -519,6 +536,10 @@ export function createLaneResumeHandlers(deps) {
519
536
  story_id: next.story_id,
520
537
  stories: laneStories(next),
521
538
  story_warning: storyWarning(next) ?? null,
539
+ ...(questionCancellation === null ? {} : {
540
+ cancelled_questions: questionCancellation.cancelled_question_ids,
541
+ question_cancellation_failures: questionCancellation.failures,
542
+ }),
522
543
  lane: next,
523
544
  });
524
545
  }
@@ -137,15 +137,26 @@ export type CommandLaneResolution = {
137
137
  outpostId: string;
138
138
  ownerHost: LaneRecord["host"];
139
139
  };
140
+ export interface FallbackQuestionCancellationResult {
141
+ cancelled_question_ids: string[];
142
+ failures: Array<{
143
+ question_id?: string;
144
+ error: string;
145
+ }>;
146
+ }
140
147
  export interface OutpostLaneDispatchBehavior {
141
148
  /** Keep remote-only lanes out of the primary lane store. */
142
149
  syncPrimary?: boolean;
143
150
  /** Host identity returned by the remote record lookup for a remote-only lane. */
144
151
  expectedOwnerHost?: LaneRecord["host"];
152
+ /** Run after a successful remote awaiting_input fallback, before printing. */
153
+ afterFallbackResume?: () => Promise<FallbackQuestionCancellationResult>;
145
154
  }
146
155
  export interface LaneMessageHandlerDeps {
147
156
  /** Test seam invoked after a provider worker has been started. */
148
157
  onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
158
+ /** Test seam invoked after a message has been durably queued for a lane. */
159
+ onLaneMessageQueued?: (laneId: string, path: "admission" | "account_move") => void;
149
160
  archiveResumeMessage(hqRoot: string, laneId: string, message: {
150
161
  message_id: string;
151
162
  [key: string]: unknown;
@@ -175,6 +186,8 @@ export interface LaneMessageHandlerDeps {
175
186
  recordResumeSetup(ctx: Ctx, lane: LaneRecord, input: RecordResumeSetupInput): LaneRecord;
176
187
  resolveCommandLane(ctx: Ctx, laneId: string): Promise<CommandLaneResolution>;
177
188
  resolveActiveQuestionCompany(ctx: Ctx, message?: string): Promise<LaneCompanyRef>;
189
+ hasPendingQuestionForLane(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
190
+ cancelFallbackQuestionsForLane(ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string): Promise<FallbackQuestionCancellationResult>;
178
191
  resolveResumeProfile(ctx: Ctx, lane: LaneRecord, workerId: string): ResolvedResumeProfile;
179
192
  selectAutomaticClaudeResumeAccount(ctx: Ctx, lane: LaneRecord, workerProfile?: ResolvedResumeProfile, options?: {
180
193
  repairUnavailableAccount?: boolean;
@@ -186,7 +199,7 @@ export interface LaneMessageHandlerDeps {
186
199
  validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
187
200
  withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
188
201
  }
189
- export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
202
+ export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
190
203
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
191
204
  authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
192
205
  authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
@@ -380,6 +393,7 @@ export interface LaneQuestionsHandlerDeps {
380
393
  assertQuestionAnswerAuthorized(ctx: Ctx, lane: LaneRecord): void;
381
394
  assertQuestionCompanyMatchesLane(question: QuestionRecord, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, code: string, hqRoot?: string): void;
382
395
  casUpdateQuestion(hqRoot: string, questionId: string, updater: (current: QuestionRecord) => QuestionRecord): QuestionRecord;
396
+ casUpdateLane(hqRoot: string, laneId: string, updater: (current: LaneRecord) => LaneRecord): LaneRecord;
383
397
  effectiveLaneQuestionCompany(lane: Pick<LaneRecord, "company" | "mesh_company" | "project_id" | "lane_id">, hqRoot?: string): LaneCompanyRef | null;
384
398
  emitLaneMeshEvent(ctx: Ctx, company: LaneCompanyRef | null | undefined, threadId: string | undefined, event: LaneMeshEvent, payload: Record<string, unknown>): Promise<void>;
385
399
  groupQuestionLanesByCompany(hqRoot: string, lanes: LaneRecord[], fallbackCompany?: LaneCompanyRef): QuestionCompanyGroup[];
@@ -1,5 +1,5 @@
1
1
  import { accountStatus, coveredLaneIds, loadLanesConfig, MAX_WATCHED_LANES, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, monitorReminderText, runLaneWatch, } from "../lib/lanes/index.js";
2
- import { LEAD_LANE_KEEP_ALIVE_FLAG } from "../lib/lanes/keep-alive.js";
2
+ import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG } from "../lib/lanes/keep-alive.js";
3
3
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
4
4
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
5
5
  import { isLanesError, LanesError } from "../lib/lanes/errors.js";
@@ -50,6 +50,10 @@ export function createLaneWatchHandlers(deps) {
50
50
  company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
51
51
  env: ctx.env,
52
52
  }),
53
+ keepAliveNextCheckAtEnabled: (lane) => readLanesRegistryFlag(LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG, {
54
+ company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
55
+ env: ctx.env,
56
+ }),
53
57
  readAccountStatuses: async (lanes) => {
54
58
  if (lanes.length === 0)
55
59
  return [];
@@ -31,6 +31,8 @@ export { emitLaneDecisionEvents };
31
31
  export interface LanesCommandDeps {
32
32
  /** Test seam for failures after a lane provider worker is started. */
33
33
  onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
34
+ /** Test seam for failures after a message is durably queued. */
35
+ onLaneMessageQueued?: (laneId: string, path: "admission" | "account_move") => void;
34
36
  hqRoot?: () => string;
35
37
  /** Start HQ-root walk here instead of process.cwd(). Tests only. */
36
38
  cwd?: () => string;