@indigoai-us/hq-cli 5.345.31 → 5.345.33

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 (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/command-catalog.generated.d.ts +100 -3
  3. package/dist/command-catalog.generated.js +121 -3
  4. package/dist/commands/db-dump.d.ts +8 -0
  5. package/dist/commands/db-dump.js +50 -0
  6. package/dist/commands/db-provision.d.ts +3 -10
  7. package/dist/commands/db-provision.js +26 -47
  8. package/dist/commands/db-remote-deps.d.ts +18 -0
  9. package/dist/commands/db-remote-deps.js +59 -0
  10. package/dist/commands/db-sql.d.ts +2 -1
  11. package/dist/commands/db-sql.js +30 -5
  12. package/dist/commands/db-usage.d.ts +7 -0
  13. package/dist/commands/db-usage.js +28 -0
  14. package/dist/commands/db.js +4 -0
  15. package/dist/commands/files.js +33 -5
  16. package/dist/commands/integrations-core.d.ts +40 -0
  17. package/dist/commands/integrations-core.js +90 -0
  18. package/dist/commands/integrations.d.ts +2 -2
  19. package/dist/commands/integrations.js +27 -3
  20. package/dist/commands/lanes-message.js +40 -8
  21. package/dist/commands/lanes-questions.d.ts +6 -1
  22. package/dist/commands/lanes-questions.js +19 -7
  23. package/dist/commands/lanes-resume.js +17 -6
  24. package/dist/commands/lanes-shared.d.ts +6 -1
  25. package/dist/commands/lanes.js +74 -1
  26. package/dist/commands/mesh.js +150 -26
  27. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +68 -604
  28. package/dist/hq-cloud-sync-runner/package.json +1 -1
  29. package/dist/lib/db/control-plane.d.ts +32 -0
  30. package/dist/lib/db/control-plane.js +50 -0
  31. package/dist/lib/db/redact.d.ts +4 -0
  32. package/dist/lib/db/redact.js +17 -0
  33. package/dist/lib/db/remote-engine.d.ts +67 -0
  34. package/dist/lib/db/remote-engine.js +165 -0
  35. package/dist/lib/db/usage-format.d.ts +4 -0
  36. package/dist/lib/db/usage-format.js +41 -0
  37. package/dist/lib/lanes/keep-alive.d.ts +12 -0
  38. package/dist/lib/lanes/keep-alive.js +25 -6
  39. package/dist/lib/mesh/api.d.ts +2 -0
  40. package/dist/lib/mesh/project-board.d.ts +17 -0
  41. package/dist/lib/mesh/project-board.js +28 -0
  42. package/dist/lib/mesh/story-status.d.ts +16 -0
  43. package/dist/lib/mesh/story-status.js +32 -7
  44. package/dist/utils/self-update.d.ts +2 -0
  45. package/dist/utils/self-update.js +61 -23
  46. package/dist/utils/version-check.d.ts +1 -1
  47. package/dist/utils/version-check.js +2 -2
  48. package/dist/utils/version-gate.d.ts +21 -14
  49. package/dist/utils/version-gate.js +119 -28
  50. 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`,
@@ -225,6 +225,46 @@ export declare function toolPrefixForProvider(provider: string): string;
225
225
  */
226
226
  export declare function fetchAdminSurface(token: string, companyUid: string): Promise<AdminSurface>;
227
227
  export declare function fetchConnections(token: string, companyUid: string): Promise<AdminConnection[]>;
228
+ /**
229
+ * One app the caller can use right now through the HQ gateway. Field names
230
+ * follow the bot `company_apps` projection (name, provider, domain, status),
231
+ * so session context and bot context describe an app the same way. No
232
+ * endpoint URLs, credentials, scopes, or creator identities.
233
+ */
234
+ export interface UsableApp {
235
+ connectionId: string;
236
+ /** Display name, stripped of control characters and capped in length. */
237
+ name: string;
238
+ provider: string;
239
+ domain: string | null;
240
+ status: string;
241
+ /** Bare provider slug, e.g. `notion`. */
242
+ providerArg: string;
243
+ /**
244
+ * The flag that resolves to exactly this connection in `hq integrations
245
+ * tools|call`: `--provider <slug>` when no other active row shares the
246
+ * provider, else `--connection <id>`.
247
+ */
248
+ selector: string;
249
+ }
250
+ export interface UsableAppsResult {
251
+ companyUid: string;
252
+ /**
253
+ * False when the server ignored `view=summary` (an older deploy). Its rows
254
+ * carry no `viewerAccess`, so nothing can be confirmed usable and `apps` is
255
+ * empty rather than guessed.
256
+ */
257
+ viewerAccessKnown: boolean;
258
+ apps: UsableApp[];
259
+ }
260
+ /**
261
+ * The apps the caller may use, from `GET /v1/integrations/admin?view=summary`.
262
+ * A row counts only when all of these hold, mirroring the gateway's checks:
263
+ * the server's per-caller ACL decision (`viewerAccess.usable`) is true, the
264
+ * status is one the gateway serves, and the gateway has a provider registered
265
+ * for it. Fails closed: a row without `viewerAccess` is not listed.
266
+ */
267
+ export declare function fetchUsableApps(token: string, companyUid: string): Promise<UsableAppsResult>;
228
268
  /**
229
269
  * Resolve one connection by `--connection acct_…` or `--provider linear`
230
270
  * (matches `factory:<slug>`, bare provider ids, and an installation's human
@@ -355,6 +355,96 @@ export async function fetchAdminSurface(token, companyUid) {
355
355
  export async function fetchConnections(token, companyUid) {
356
356
  return (await fetchAdminSurface(token, companyUid)).connections;
357
357
  }
358
+ /**
359
+ * Statuses the gateway serves. `needs-attention` is derived by the summary
360
+ * view from a stored `connected` row whose token renewal is failing, so the
361
+ * gateway still dispatches to it.
362
+ */
363
+ const GATEWAY_SERVED_STATUSES = new Set(["connected", "needs-attention"]);
364
+ /**
365
+ * Providers hq-pro registers statically (`createIntegrationProviderRegistry`).
366
+ * Every other provider is registered per company from a factory installation
367
+ * keyed by its `runtimeProviderId`; a connection with neither is refused by the
368
+ * gateway as `ProviderUnsupported`.
369
+ */
370
+ const STATIC_GATEWAY_PROVIDERS = new Set(["google-workspace", "linear", "slack"]);
371
+ const MAX_APP_NAME_LENGTH = 60;
372
+ /**
373
+ * Display names are set by whoever installed the app and end up in agent
374
+ * context, so keep them to one short line of printable text.
375
+ */
376
+ function safeAppName(raw, fallback) {
377
+ const cleaned = (raw ?? "")
378
+ .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
379
+ .replace(/\s+/g, " ")
380
+ .trim();
381
+ if (!cleaned)
382
+ return fallback;
383
+ const chars = Array.from(cleaned);
384
+ return chars.length > MAX_APP_NAME_LENGTH
385
+ ? `${chars.slice(0, MAX_APP_NAME_LENGTH - 1).join("").trimEnd()}…`
386
+ : cleaned;
387
+ }
388
+ /** Case-insensitive code-point order, so the list reads the same in every locale. */
389
+ function compareNames(a, b) {
390
+ const x = a.toLowerCase();
391
+ const y = b.toLowerCase();
392
+ return x < y ? -1 : x > y ? 1 : 0;
393
+ }
394
+ function gatewayHasProvider(row) {
395
+ if (STATIC_GATEWAY_PROVIDERS.has(row.provider))
396
+ return true;
397
+ return row.installation?.runtimeProviderId === row.provider;
398
+ }
399
+ /**
400
+ * The apps the caller may use, from `GET /v1/integrations/admin?view=summary`.
401
+ * A row counts only when all of these hold, mirroring the gateway's checks:
402
+ * the server's per-caller ACL decision (`viewerAccess.usable`) is true, the
403
+ * status is one the gateway serves, and the gateway has a provider registered
404
+ * for it. Fails closed: a row without `viewerAccess` is not listed.
405
+ */
406
+ export async function fetchUsableApps(token, companyUid) {
407
+ const res = await vaultApiFetch({
408
+ token,
409
+ path: "/v1/integrations/admin",
410
+ query: { companyUid, view: "summary" },
411
+ });
412
+ if (!res.ok)
413
+ await raiseForResponse(res, "Failed to list integrations");
414
+ const data = (await res.json());
415
+ const viewerAccessKnown = data.view === "summary";
416
+ const rows = Array.isArray(data.connections) ? data.connections : [];
417
+ // `--provider` picks the first non-revoked row with that provider, whoever
418
+ // may use it, so a shared provider needs `--connection` to be exact.
419
+ const activeByProvider = new Map();
420
+ for (const row of rows) {
421
+ if (row.status === "revoked")
422
+ continue;
423
+ const key = bareProvider(row.provider).toLowerCase();
424
+ activeByProvider.set(key, (activeByProvider.get(key) ?? 0) + 1);
425
+ }
426
+ const apps = viewerAccessKnown
427
+ ? rows
428
+ .filter((row) => row.viewerAccess?.usable === true &&
429
+ GATEWAY_SERVED_STATUSES.has(row.status) &&
430
+ gatewayHasProvider(row))
431
+ .map((row) => {
432
+ const providerArg = bareProvider(row.provider);
433
+ const shared = (activeByProvider.get(providerArg.toLowerCase()) ?? 0) > 1;
434
+ return {
435
+ connectionId: row.id,
436
+ name: safeAppName(row.installation?.displayName, providerArg),
437
+ provider: row.provider,
438
+ domain: typeof row.domain === "string" && row.domain ? row.domain : null,
439
+ status: row.status,
440
+ providerArg,
441
+ selector: shared ? `--connection ${row.id}` : `--provider ${providerArg}`,
442
+ };
443
+ })
444
+ .sort((a, b) => compareNames(a.name, b.name))
445
+ : [];
446
+ return { companyUid: data.companyUid ?? companyUid, viewerAccessKnown, apps };
447
+ }
358
448
  /**
359
449
  * Resolve one connection by `--connection acct_…` or `--provider linear`
360
450
  * (matches `factory:<slug>`, bare provider ids, and an installation's human
@@ -51,8 +51,8 @@
51
51
  */
52
52
  import { Command } from "commander";
53
53
  import { type PendingApproval } from "./integrations-api.js";
54
- export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
55
- export type { AdminConnection } from "./integrations-core.js";
54
+ export { IntegrationsCliError, callGateway, fetchConnections, fetchUsableApps, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
55
+ export type { AdminConnection, UsableApp, UsableAppsResult } from "./integrations-core.js";
56
56
  /** Wait for a decided queue item without resending the approve request. */
57
57
  export declare function waitForApprovalTerminal(token: string, companyUid: string, queueId: string, options?: {
58
58
  timeoutMs?: number;
@@ -54,12 +54,12 @@ import chalk from "chalk";
54
54
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
55
55
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
56
56
  import { redactSensitiveUrl, redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
57
- import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
57
+ import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, fetchUsableApps, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
58
58
  import { registerConnectCommands } from "./integrations-connect.js";
59
59
  import { registerImportCommands } from "./integrations-import.js";
60
60
  import { registerManageCommands } from "./integrations-manage.js";
61
61
  import { fetchPendingApprovals } from "./integrations-api.js";
62
- export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
62
+ export { IntegrationsCliError, callGateway, fetchConnections, fetchUsableApps, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
63
63
  /**
64
64
  * Gateway metadata is additive and may be absent when talking to an older
65
65
  * gateway. Parse it defensively so an unknown or malformed value never breaks
@@ -220,9 +220,33 @@ export function registerIntegrationsCommand(program) {
220
220
  .description("List the company's connected apps")
221
221
  .option("--company <slug>", "Company slug, e.g. indigo (defaults to your single active company)")
222
222
  .option("--json", "Machine-readable output")
223
+ .option("--usable", "Only apps you can use right now (connected and shared with you), with the --provider value to call them")
224
+ .option("--no-login", "Fail instead of opening a browser sign-in when there is no HQ session")
223
225
  .action(async (opts) => {
224
- const token = await ensureCognitoIdToken();
226
+ const token = await ensureCognitoIdToken(opts.login === false ? { interactive: false } : {});
225
227
  const companyUid = await getCompanyUid(token, opts.company);
228
+ if (opts.usable) {
229
+ const result = await fetchUsableApps(token, companyUid);
230
+ if (opts.json) {
231
+ printJson(result);
232
+ return;
233
+ }
234
+ if (!result.viewerAccessKnown) {
235
+ console.log("This HQ server cannot report which apps are shared with you yet. Run `hq integrations list` to see every connected app.");
236
+ return;
237
+ }
238
+ if (result.apps.length === 0) {
239
+ console.log("No connected apps are available to you in this company. Run `hq integrations list` to see what is connected, or ask an admin to share one.");
240
+ return;
241
+ }
242
+ for (const app of result.apps) {
243
+ const where = app.domain ? ` (${app.domain})` : "";
244
+ const flag = app.status === "needs-attention" ? chalk.yellow(" needs attention") : "";
245
+ console.log(`${chalk.bold(app.name)}${chalk.dim(where)}${flag}`);
246
+ console.log(chalk.dim(` hq integrations tools ${app.selector}`));
247
+ }
248
+ return;
249
+ }
226
250
  const connections = await fetchConnections(token, companyUid);
227
251
  if (opts.json) {
228
252
  printJson(connections);
@@ -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) {