@indigoai-us/hq-cli 5.345.32 → 5.345.34

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 (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/command-catalog.generated.d.ts +92 -3
  3. package/dist/command-catalog.generated.js +111 -3
  4. package/dist/commands/cloud-provision.d.ts +2 -0
  5. package/dist/commands/db-dump.d.ts +8 -0
  6. package/dist/commands/db-dump.js +50 -0
  7. package/dist/commands/db-provision.d.ts +3 -10
  8. package/dist/commands/db-provision.js +26 -47
  9. package/dist/commands/db-remote-deps.d.ts +18 -0
  10. package/dist/commands/db-remote-deps.js +59 -0
  11. package/dist/commands/db-sql.d.ts +2 -1
  12. package/dist/commands/db-sql.js +30 -5
  13. package/dist/commands/db-usage.d.ts +7 -0
  14. package/dist/commands/db-usage.js +28 -0
  15. package/dist/commands/db.js +4 -0
  16. package/dist/commands/files.js +33 -5
  17. package/dist/commands/lanes-message.js +40 -8
  18. package/dist/commands/lanes-questions.d.ts +6 -1
  19. package/dist/commands/lanes-questions.js +19 -7
  20. package/dist/commands/lanes-resume.js +17 -6
  21. package/dist/commands/lanes-shared.d.ts +6 -1
  22. package/dist/commands/lanes.js +92 -15
  23. package/dist/commands/mesh.js +150 -26
  24. package/dist/commands/reindex.js +11 -8
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +68 -604
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/lib/daemon/remote-control.js +62 -3
  28. package/dist/lib/db/control-plane.d.ts +32 -0
  29. package/dist/lib/db/control-plane.js +50 -0
  30. package/dist/lib/db/redact.d.ts +4 -0
  31. package/dist/lib/db/redact.js +17 -0
  32. package/dist/lib/db/remote-engine.d.ts +67 -0
  33. package/dist/lib/db/remote-engine.js +165 -0
  34. package/dist/lib/db/usage-format.d.ts +4 -0
  35. package/dist/lib/db/usage-format.js +41 -0
  36. package/dist/lib/index-render/shared.d.ts +3 -1
  37. package/dist/lib/index-render/shared.js +5 -2
  38. package/dist/lib/lanes/depth-cap.d.ts +7 -0
  39. package/dist/lib/lanes/depth-cap.js +41 -0
  40. package/dist/lib/lanes/keep-alive.d.ts +12 -0
  41. package/dist/lib/lanes/keep-alive.js +25 -6
  42. package/dist/lib/lanes/reconcile.d.ts +1 -1
  43. package/dist/lib/lanes/reconcile.js +16 -5
  44. package/dist/lib/mesh/api.d.ts +2 -0
  45. package/dist/lib/mesh/live/daemon/install.d.ts +7 -4
  46. package/dist/lib/mesh/live/daemon/install.js +144 -47
  47. package/dist/lib/mesh/project-board.d.ts +17 -0
  48. package/dist/lib/mesh/project-board.js +28 -0
  49. package/dist/lib/mesh/story-status.d.ts +16 -0
  50. package/dist/lib/mesh/story-status.js +32 -7
  51. package/dist/utils/self-update.d.ts +2 -0
  52. package/dist/utils/self-update.js +61 -23
  53. package/dist/utils/version-check.d.ts +1 -1
  54. package/dist/utils/version-check.js +2 -2
  55. package/dist/utils/version-gate.d.ts +21 -14
  56. package/dist/utils/version-gate.js +119 -28
  57. package/package.json +4 -1
@@ -7,7 +7,12 @@
7
7
  import { Option } from "commander";
8
8
  import chalk from "chalk";
9
9
  import fs from "node:fs";
10
+ import { safeErrorMessage } from "../lib/db/redact.js";
11
+ import { formatRemoteResults, openRemoteAppConnection, runRemoteSql, } from "../lib/db/remote-engine.js";
10
12
  import { formatSqlResult, runLocalSql } from "../lib/db/sql.js";
13
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
14
+ import { defaultRemoteDeps, describeRemoteError, remoteEngineDeps, } from "./db-remote-deps.js";
15
+ import { describeSessionGap } from "./db-dump.js";
11
16
  function readSqlArg(sqlArgs, stdinFlag) {
12
17
  if (stdinFlag || sqlArgs.length === 0) {
13
18
  if (process.stdin.isTTY && sqlArgs.length === 0) {
@@ -17,10 +22,11 @@ function readSqlArg(sqlArgs, stdinFlag) {
17
22
  }
18
23
  return sqlArgs.join(" ");
19
24
  }
20
- export function registerDbSqlCommand(db) {
25
+ export function registerDbSqlCommand(db, remoteDepsFactory = defaultRemoteDeps) {
21
26
  db.command("sql")
22
- .description("Run SQL against the company vault DB (local; read-only by default)")
27
+ .description("Run SQL against the company vault DB (local) or an app's remote database (--app); read-only by default")
23
28
  .requiredOption("--company <slug>", "Company slug (tenant scope; required)")
29
+ .option("--app <app>", "Run against this app's remote database schema")
24
30
  .option("--write", "Allow write/DDL statements (discouraged for ad-hoc DDL)", false)
25
31
  .addOption(new Option("--remote", "Remote SQL is not available in this CLI build yet; local queries work without --remote").hideHelp())
26
32
  .option("--db-path <path>", "Dangerous: open a non-canonical DB path (denied unless --allow-cross-company-path)")
@@ -37,13 +43,17 @@ export function registerDbSqlCommand(db) {
37
43
  process.exitCode = 1;
38
44
  return;
39
45
  }
40
- if (opts.remote) {
41
- console.error(chalk.red("Error: Remote SQL is not available in this CLI build yet. Local queries work without --remote."));
46
+ if (opts.remote && !opts.app) {
47
+ console.error(chalk.red("Error: Remote SQL needs --app <app>. Local queries work without --remote."));
42
48
  process.exitCode = 1;
43
49
  return;
44
50
  }
45
51
  const sql = readSqlArg(sqlParts, !!opts.stdin).trim();
46
52
  const format = opts.format === "table" ? "table" : "jsonl";
53
+ if (opts.app) {
54
+ await runRemote(remoteDepsFactory(), company, opts.app, sql, !!opts.write);
55
+ return;
56
+ }
47
57
  const result = runLocalSql({
48
58
  company,
49
59
  sql,
@@ -56,9 +66,24 @@ export function registerDbSqlCommand(db) {
56
66
  console.log(formatSqlResult(result, format));
57
67
  }
58
68
  catch (error) {
59
- console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
69
+ if (isPlanGateError(error))
70
+ throw error;
71
+ console.error(chalk.red("Error:"), describeSessionGap(error) ?? describeRemoteError(error) ?? safeErrorMessage(error));
60
72
  process.exitCode = 1;
61
73
  }
62
74
  });
63
75
  }
76
+ async function runRemote(deps, company, app, sql, write) {
77
+ const resolved = await deps.resolveCompany(company);
78
+ const conn = await openRemoteAppConnection(remoteEngineDeps(deps), {
79
+ companyUid: resolved.companyUid,
80
+ appId: app,
81
+ });
82
+ try {
83
+ console.log(formatRemoteResults(await runRemoteSql(conn, sql, write)));
84
+ }
85
+ finally {
86
+ await conn.close();
87
+ }
88
+ }
64
89
  //# sourceMappingURL=db-sql.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * hq db usage — month-to-date remote database usage against both caps.
3
+ */
4
+ import { Command } from "commander";
5
+ import { type DbRemoteCommandDeps } from "./db-remote-deps.js";
6
+ export declare function registerDbUsageCommand(db: Command, depsFactory?: () => DbRemoteCommandDeps): void;
7
+ //# sourceMappingURL=db-usage.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * hq db usage — month-to-date remote database usage against both caps.
3
+ */
4
+ import chalk from "chalk";
5
+ import { safeErrorMessage } from "../lib/db/redact.js";
6
+ import { formatUsage } from "../lib/db/usage-format.js";
7
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
8
+ import { controlPlaneClient, defaultRemoteDeps, describeRemoteError, } from "./db-remote-deps.js";
9
+ export function registerDbUsageCommand(db, depsFactory = defaultRemoteDeps) {
10
+ db.command("usage")
11
+ .description("Show this month's remote database usage against the compute and storage caps")
12
+ .requiredOption("--company <slug>", "Company slug")
13
+ .action(async (opts) => {
14
+ try {
15
+ const deps = depsFactory();
16
+ const resolved = await deps.resolveCompany(String(opts.company ?? "").trim());
17
+ const usage = await controlPlaneClient(deps).usage(resolved.companyUid);
18
+ console.log(formatUsage(usage));
19
+ }
20
+ catch (error) {
21
+ if (isPlanGateError(error))
22
+ throw error;
23
+ console.error(chalk.red("Error:"), describeRemoteError(error) ?? safeErrorMessage(error));
24
+ process.exitCode = 1;
25
+ }
26
+ });
27
+ }
28
+ //# sourceMappingURL=db-usage.js.map
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * hq db — vault database commands (local + remote tiers).
3
3
  */
4
+ import { registerDbDumpCommand } from "./db-dump.js";
4
5
  import { registerDbMigrateCommand } from "./db-migrate.js";
5
6
  import { registerDbProvisionCommand } from "./db-provision.js";
6
7
  import { registerDbSqlCommand } from "./db-sql.js";
7
8
  import { registerDbStatusCommand } from "./db-status.js";
9
+ import { registerDbUsageCommand } from "./db-usage.js";
8
10
  /**
9
11
  * Register the `hq db` command group and its subcommands.
10
12
  */
@@ -16,5 +18,7 @@ export function registerDbCommand(program) {
16
18
  registerDbSqlCommand(db);
17
19
  registerDbMigrateCommand(db);
18
20
  registerDbProvisionCommand(db);
21
+ registerDbUsageCommand(db);
22
+ registerDbDumpCommand(db);
19
23
  }
20
24
  //# sourceMappingURL=db.js.map
@@ -64,7 +64,9 @@ function formatAclPatternConflict(prefix, serverMessage) {
64
64
  const sharedPattern = `${privatePattern}*`;
65
65
  const detail = serverMessage ? `${serverMessage}. ` : "";
66
66
  return (`${detail}A private folder '${privatePattern}' and shared folder glob ` +
67
- `'${sharedPattern}' cannot both exist. Remove the conflicting ACL pattern and retry.`);
67
+ `'${sharedPattern}' cannot both exist. If the shared glob has no grants, run ` +
68
+ `hq files unshare '${sharedPattern}'. If it has grants, remove them first with ` +
69
+ `hq files unshare '${sharedPattern}' --with <principal>.`);
68
70
  }
69
71
  /**
70
72
  * POST /files/{companyUid}/share-session — mint a browser-launch share
@@ -265,8 +267,8 @@ export function registerFilesCommand(program) {
265
267
  });
266
268
  files
267
269
  .command("unshare <prefix>")
268
- .description("Remove a file access grant. A trailing slash targets the private-folder row exactly.")
269
- .requiredOption("--with <principal>", "Person uid, agent uid, email address, group id, or '@all' to remove the grant")
270
+ .description("Remove a named file access grant, or remove an empty ACL row without --with.")
271
+ .option("--with <principal>", "Person uid, agent uid, email address, group id, or '@all' to remove the grant")
270
272
  .action(async (prefix, opts) => {
271
273
  try {
272
274
  // Unshare MUTATES access with no preview. Anchored content paths remain
@@ -279,13 +281,13 @@ export function registerFilesCommand(program) {
279
281
  ? { companySlug: files.opts().company }
280
282
  : {}),
281
283
  });
282
- const principal = opts.with;
284
+ const principal = opts.with ?? "";
283
285
  const isAll = principal === "@all";
284
286
  const isEmail = !isAll && EMAIL_PATTERN.test(principal);
285
287
  const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
286
288
  const isAgent = !isAll && !isEmail && !isGroup && AGENT_UID_PATTERN.test(principal);
287
289
  const isPerson = !isAll && !isEmail && !isGroup && !isAgent && PERSON_UID_PATTERN.test(principal);
288
- if (!isAll && !isEmail && !isGroup && !isPerson && !isAgent) {
290
+ if (opts.with && !isAll && !isEmail && !isGroup && !isPerson && !isAgent) {
289
291
  console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid matching prs_<alphanumeric>, an agent uid matching agt_<alphanumeric>, or a group id matching grp_<alphanumeric>`));
290
292
  process.exit(1);
291
293
  }
@@ -313,6 +315,32 @@ export function registerFilesCommand(program) {
313
315
  explicit: explicitCompanySlug,
314
316
  anchorSlug: companyRootSlug,
315
317
  });
318
+ if (!opts.with) {
319
+ const res = await vaultApiFetch({
320
+ token,
321
+ path: `/files/${encodeURIComponent(companyUid)}/acl?prefix=${encodeURIComponent(canonicalPrefix)}`,
322
+ method: "DELETE",
323
+ });
324
+ const body = await res.json().catch(() => ({}));
325
+ if (res.status === 404 && body.code === "ACL_NOT_FOUND") {
326
+ console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no ACL row.`));
327
+ return;
328
+ }
329
+ if (!res.ok) {
330
+ if (res.status === 403) {
331
+ console.error(chalk.red("Not authorized to remove this ACL row"));
332
+ await offerAccessLadder(canonicalPrefix, {
333
+ company: files.opts().company,
334
+ });
335
+ }
336
+ else {
337
+ console.error(chalk.red(body.error ?? "Unable to remove ACL row"));
338
+ }
339
+ process.exit(1);
340
+ }
341
+ console.log(chalk.green(`Removed empty ACL pattern '${canonicalPrefix}'. It now follows its parent folder's access, so anyone who can see that parent can see this folder.`));
342
+ return;
343
+ }
316
344
  const res = await vaultApiFetch({
317
345
  token,
318
346
  path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
@@ -11,7 +11,8 @@ import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, w
11
11
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
12
12
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
13
13
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
14
- import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
14
+ import { findRetiredLaneEntry, keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
15
+ import { isTerminalLaneState } from "../lib/lanes/types.js";
15
16
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
16
17
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
17
18
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
@@ -136,6 +137,14 @@ export function createLaneMessageHandlers(deps) {
136
137
  ...(snapshot.hire_grant === undefined ? {} : { hire_grant: snapshot.hire_grant }),
137
138
  };
138
139
  }
140
+ function activeRetiredRegistryWarning(hqRoot, lane) {
141
+ if (lane.keep_alive !== true || isTerminalLaneState(lane.state))
142
+ return undefined;
143
+ const entry = findRetiredLaneEntry(hqRoot, lane.lane_id);
144
+ if (entry === undefined)
145
+ return undefined;
146
+ return `The active keep-alive lane record takes precedence over ${entry.path} retired[${entry.index}] (lane ${entry.lane}, retired_at ${entry.retired_at ?? "not recorded"}); remove the stale registry row from workspace/lane-briefs/juniors.json.`;
147
+ }
139
148
  async function messageLaneExclusion(ctx, lane, grantContext) {
140
149
  if (lane.state === "killed" || lane.state === "cancelled") {
141
150
  return { lane, exclusion: lane.state };
@@ -172,6 +181,14 @@ export function createLaneMessageHandlers(deps) {
172
181
  lanes = [...merged.values()];
173
182
  }
174
183
  let exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
184
+ let retiredRegistryWarning = exclusion === "retired"
185
+ ? activeRetiredRegistryWarning(ctx.hqRoot(), currentLane)
186
+ : undefined;
187
+ if (retiredRegistryWarning !== undefined) {
188
+ exclusion = keepAliveSupersededBy(currentLane, lanes, lineageAware, companyForLane) === undefined
189
+ ? undefined
190
+ : "superseded";
191
+ }
175
192
  if (exclusion === "superseded" && currentLane.state !== "done" && currentLane.state !== "interrupted") {
176
193
  const runningSupersededMessageDeliveryEnabled = await readLanesRegistryFlag(LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, { company: companyForLane(currentLane), env: ctx.env });
177
194
  if (runningSupersededMessageDeliveryEnabled) {
@@ -203,6 +220,14 @@ export function createLaneMessageHandlers(deps) {
203
220
  lanes = latestLanes;
204
221
  retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
205
222
  exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
223
+ retiredRegistryWarning = exclusion === "retired"
224
+ ? activeRetiredRegistryWarning(ctx.hqRoot(), currentLane)
225
+ : undefined;
226
+ if (retiredRegistryWarning !== undefined) {
227
+ exclusion = keepAliveSupersededBy(currentLane, lanes, lineageAware, companyForLane) === undefined
228
+ ? undefined
229
+ : "superseded";
230
+ }
206
231
  if (exclusion === "superseded" && currentLane.state !== "done" && currentLane.state !== "interrupted") {
207
232
  const callerLaneId = dispatchedContextBound ? dispatchedCallerId : undefined;
208
233
  const callerSessionId = dispatchedContextBound
@@ -222,6 +247,7 @@ export function createLaneMessageHandlers(deps) {
222
247
  ? { supersededMessageSessionId: currentLane.senior.id }
223
248
  : {}),
224
249
  supersededMessageWarning: `A newer lead supersedes this ${currentLane.state} lane; message delivery is allowed while the lane remains active.`,
250
+ ...(retiredRegistryWarning === undefined ? {} : { retiredRegistryWarning }),
225
251
  };
226
252
  }
227
253
  }
@@ -234,6 +260,7 @@ export function createLaneMessageHandlers(deps) {
234
260
  return {
235
261
  lane: currentLane,
236
262
  exclusion: undefined,
263
+ ...(retiredRegistryWarning === undefined ? {} : { retiredRegistryWarning }),
237
264
  ...(!dispatchedContextAuthorized && ctx.env.HQ_LANE_ID?.trim()
238
265
  ? { supersededMessageCallerId: ctx.env.HQ_LANE_ID.trim() }
239
266
  : {}),
@@ -245,7 +272,7 @@ export function createLaneMessageHandlers(deps) {
245
272
  : {}),
246
273
  };
247
274
  }
248
- return { lane: currentLane, exclusion };
275
+ return { lane: currentLane, exclusion, ...(retiredRegistryWarning === undefined ? {} : { retiredRegistryWarning }) };
249
276
  }
250
277
  function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
251
278
  writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
@@ -450,19 +477,23 @@ export function createLaneMessageHandlers(deps) {
450
477
  if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
451
478
  throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
452
479
  }
453
- const pendingAnswerableQuestion = lane.state === "awaiting_input"
454
- ? await deps.hasPendingQuestionForLane(ctx, lane)
455
- : false;
456
- if (pendingAnswerableQuestion) {
457
- const message = deliveryRefusalReason(lane);
480
+ const pendingQuestions = lane.state === "awaiting_input"
481
+ ? await deps.pendingQuestionsForLane(ctx, lane)
482
+ : [];
483
+ if (pendingQuestions.length > 0) {
484
+ const companySlug = lane.company?.slug ?? lane.mesh_company ?? "<company-slug>";
485
+ const instructions = pendingQuestions.map(({ question_id, audience }) => `question ${question_id} awaits ${audience.kind}:${audience.id}; answer it with hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`);
486
+ const message = `Lane ${laneId} is awaiting input. ${instructions.join("; ")}`;
458
487
  throw new LanesError("lane_not_accepting_messages", message, {
459
488
  lane_id: laneId,
460
489
  state: lane.state,
490
+ pending_questions: pendingQuestions,
491
+ answer_commands: pendingQuestions.map(({ question_id }) => `hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`),
461
492
  provider: lane.provider,
462
493
  message,
463
494
  });
464
495
  }
465
- const awaitingInputFallback = lane.state === "awaiting_input" && !pendingAnswerableQuestion;
496
+ const awaitingInputFallback = lane.state === "awaiting_input";
466
497
  // One decision point for how a follow-up reaches this lane (delivery.ts).
467
498
  // `none` refuses before anything is written: no inbox append, no spawn.
468
499
  const mechanism = laneDeliveryMechanism(lane);
@@ -915,6 +946,7 @@ export function createLaneMessageHandlers(deps) {
915
946
  : "worker_spawned";
916
947
  const warning = [
917
948
  eligibility.supersededMessageWarning,
949
+ eligibility.retiredRegistryWarning,
918
950
  mechanism === "dropbox" && drainHook !== null && !drainHook.registered
919
951
  ? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
920
952
  : mechanism === "dropbox" && next.stalled === true
@@ -1,8 +1,13 @@
1
- import { type QuestionBody, type LaneRecord, type QuestionRecord } from "../lib/lanes/index.js";
1
+ import { type QuestionBody, type LaneRecord, type QuestionRecord, type SeniorRef } from "../lib/lanes/index.js";
2
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
5
  hasPendingQuestionForLane: (ctx: Ctx, lane: LaneRecord) => Promise<boolean>;
6
+ pendingQuestionsForLane: (ctx: Ctx, lane: LaneRecord) => Promise<Array<{
7
+ question_id: string;
8
+ asked_at: string;
9
+ audience: SeniorRef;
10
+ }>>;
6
11
  cancelFallbackQuestionsForLane: (ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string) => Promise<FallbackQuestionCancellationResult>;
7
12
  runQuestionsList: (ctx: Ctx, opts: {
8
13
  allHosts?: boolean;
@@ -488,21 +488,32 @@ export function createLaneQuestionsHandlers(deps) {
488
488
  }
489
489
  return { cancelled_question_ids: cancelledQuestionIds, failures };
490
490
  }
491
- async function hasPendingQuestionForLane(ctx, lane) {
491
+ async function pendingQuestionsForLane(ctx, lane) {
492
492
  const hqRoot = ctx.hqRoot();
493
- const local = loadAllQuestions(hqRoot).some((question) => question.lane_id === lane.lane_id &&
493
+ const local = loadAllQuestions(hqRoot).filter((question) => question.lane_id === lane.lane_id &&
494
494
  question.status === "pending" &&
495
495
  questionAudienceMatchesSenior(resolveAudience(hqRoot, question, ctx), lane, ctx));
496
- if (local)
497
- return true;
496
+ const localRows = local.map((question) => ({
497
+ question_id: question.question_id,
498
+ asked_at: question.asked_at,
499
+ audience: resolveAudience(hqRoot, question, ctx),
500
+ }));
498
501
  if (lane.host.kind !== "outpost" || laneIsHostedHere(ctx, lane))
499
- return false;
502
+ return localRows;
500
503
  // Reuse the owner lookup used by questions list/answer. An unreachable
501
504
  // owner leaves no answerable question, so the message/resume path can recover.
502
505
  const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
503
- return lookup.questions.some(({ question, lane: ownerLane, audience }) => ownerLane.lane_id === lane.lane_id &&
506
+ const remoteRows = lookup.questions.filter(({ question, lane: ownerLane, audience }) => ownerLane.lane_id === lane.lane_id &&
504
507
  question.status === "pending" &&
505
- questionAudienceMatchesSenior(audience, ownerLane, ctx));
508
+ questionAudienceMatchesSenior(audience, ownerLane, ctx)).map(({ question, audience }) => ({
509
+ question_id: question.question_id,
510
+ asked_at: question.asked_at,
511
+ audience,
512
+ }));
513
+ return [...localRows, ...remoteRows];
514
+ }
515
+ async function hasPendingQuestionForLane(ctx, lane) {
516
+ return (await pendingQuestionsForLane(ctx, lane)).length > 0;
506
517
  }
507
518
  function syncPrimaryQuestionDelivery(hqRoot, owner) {
508
519
  const deliveredAt = owner.question.delivered_at;
@@ -991,6 +1002,7 @@ export function createLaneQuestionsHandlers(deps) {
991
1002
  }
992
1003
  return {
993
1004
  hasPendingQuestionForLane,
1005
+ pendingQuestionsForLane,
994
1006
  cancelFallbackQuestionsForLane,
995
1007
  runQuestionsList,
996
1008
  runQuestionsShow,
@@ -181,9 +181,20 @@ export function createLaneResumeHandlers(deps) {
181
181
  opts.compact === false)) {
182
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 });
183
183
  }
184
- const pendingQuestion = lane.state === "awaiting_input"
185
- ? await deps.hasPendingQuestionForLane(ctx, lane)
186
- : false;
184
+ const pendingQuestions = lane.state === "awaiting_input"
185
+ ? await deps.pendingQuestionsForLane(ctx, lane)
186
+ : [];
187
+ if (pendingQuestions.length > 0) {
188
+ const companySlug = lane.company?.slug ?? lane.mesh_company ?? "<company-slug>";
189
+ const instructions = pendingQuestions.map(({ question_id, audience }) => `question ${question_id} awaits ${audience.kind}:${audience.id}; answer it with hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`);
190
+ const message = `Lane ${laneId} is awaiting input. ${instructions.join("; ")}`;
191
+ throw new LanesError("lane_pending_questions", message, {
192
+ lane_id: laneId,
193
+ state: lane.state,
194
+ pending_questions: pendingQuestions,
195
+ answer_commands: pendingQuestions.map(({ question_id }) => `hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`),
196
+ });
197
+ }
187
198
  const projectId = opts.project?.trim() || lane.project_id;
188
199
  const storyId = opts.story?.trim() || lane.story_id;
189
200
  if (opts.story !== undefined && !opts.story.trim()) {
@@ -206,7 +217,7 @@ export function createLaneResumeHandlers(deps) {
206
217
  throw new LanesError("profile_provider_changed", `Worker profile now selects ${effectiveProvider}; this lane runs ${lane.provider}; create a new lane`, { lane_id: lane.lane_id, lane_provider: lane.provider, profile_provider: effectiveProvider });
207
218
  }
208
219
  }
209
- const refusal = queuedProfileRecovery || (lane.state === "awaiting_input" && !pendingQuestion)
220
+ const refusal = queuedProfileRecovery || lane.state === "awaiting_input"
210
221
  ? null
211
222
  : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
212
223
  if (refusal !== null) {
@@ -510,7 +521,7 @@ export function createLaneResumeHandlers(deps) {
510
521
  trigger: {
511
522
  kind: "resume",
512
523
  from: resumedFrom,
513
- ...(resumedFrom === "awaiting_input" && !pendingQuestion ? { allowAwaitingInput: true } : {}),
524
+ ...(resumedFrom === "awaiting_input" && pendingQuestions.length === 0 ? { allowAwaitingInput: true } : {}),
514
525
  ...(opts.fromFailed === true ? { fromFailed: true } : {}),
515
526
  ...(failedResumeExpectedSenior !== undefined
516
527
  ? { expectedSenior: failedResumeExpectedSenior }
@@ -527,7 +538,7 @@ export function createLaneResumeHandlers(deps) {
527
538
  }, hireGrantContext);
528
539
  }, ctx.sleep);
529
540
  }
530
- const questionCancellation = resumedFrom === "awaiting_input" && !pendingQuestion
541
+ const questionCancellation = resumedFrom === "awaiting_input" && pendingQuestions.length === 0
531
542
  ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", at)
532
543
  : null;
533
544
  if (deliveryText !== undefined && messageId !== undefined) {
@@ -197,6 +197,11 @@ export interface LaneMessageHandlerDeps {
197
197
  resolveCommandLane(ctx: Ctx, laneId: string): Promise<CommandLaneResolution>;
198
198
  resolveActiveQuestionCompany(ctx: Ctx, message?: string): Promise<LaneCompanyRef>;
199
199
  hasPendingQuestionForLane(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
200
+ pendingQuestionsForLane(ctx: Ctx, lane: LaneRecord): Promise<Array<{
201
+ question_id: string;
202
+ asked_at: string;
203
+ audience: SeniorRef;
204
+ }>>;
200
205
  cancelFallbackQuestionsForLane(ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string): Promise<FallbackQuestionCancellationResult>;
201
206
  resolveResumeProfile(ctx: Ctx, lane: LaneRecord, workerId: string, allowProviderChange?: boolean): ResolvedResumeProfile;
202
207
  reuseProfileRefreshEnabled(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
@@ -211,7 +216,7 @@ export interface LaneMessageHandlerDeps {
211
216
  validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
212
217
  withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
213
218
  }
214
- export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "reuseProfileRefreshEnabled" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
219
+ export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "pendingQuestionsForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "reuseProfileRefreshEnabled" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
215
220
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
216
221
  authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
217
222
  authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;