@indigoai-us/hq-cli 5.301.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 (55) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +99 -12
  3. package/dist/command-catalog.generated.d.ts +4 -1
  4. package/dist/command-catalog.generated.js +5 -1
  5. package/dist/commands/bot.js +1 -0
  6. package/dist/commands/core-checkpoint.js +1 -0
  7. package/dist/commands/core-handoff-fast.js +1 -0
  8. package/dist/commands/core-handoff.js +2 -0
  9. package/dist/commands/deploy-comments.js +5 -2
  10. package/dist/commands/integrations-connect.d.ts +3 -0
  11. package/dist/commands/integrations-connect.js +21 -2
  12. package/dist/commands/lanes-message.js +33 -3
  13. package/dist/commands/lanes-questions.d.ts +4 -2
  14. package/dist/commands/lanes-questions.js +123 -2
  15. package/dist/commands/lanes-resume.js +25 -4
  16. package/dist/commands/lanes-shared.d.ts +13 -1
  17. package/dist/commands/lanes-watch.js +5 -1
  18. package/dist/commands/lanes.js +91 -9
  19. package/dist/commands/monitor.js +17 -2
  20. package/dist/commands/report.js +5 -2
  21. package/dist/commands/rescue.d.ts +4 -0
  22. package/dist/commands/rescue.js +27 -17
  23. package/dist/lib/agent-kit/fallback.d.ts +1 -0
  24. package/dist/lib/agent-kit/fallback.js +1 -0
  25. package/dist/lib/bot/daemon.d.ts +5 -1
  26. package/dist/lib/bot/daemon.js +4 -4
  27. package/dist/lib/core-utils/soft-timeout.js +1 -1
  28. package/dist/lib/deploy-apps.d.ts +13 -0
  29. package/dist/lib/deploy-apps.js +24 -0
  30. package/dist/lib/index-render/projects.js +82 -5
  31. package/dist/lib/lanes/delivery.d.ts +7 -3
  32. package/dist/lib/lanes/delivery.js +18 -8
  33. package/dist/lib/lanes/index.d.ts +1 -1
  34. package/dist/lib/lanes/keep-alive.d.ts +13 -3
  35. package/dist/lib/lanes/keep-alive.js +29 -5
  36. package/dist/lib/lanes/outpost-dispatch.d.ts +5 -0
  37. package/dist/lib/lanes/outpost-dispatch.js +12 -0
  38. package/dist/lib/lanes/reconcile.d.ts +1 -0
  39. package/dist/lib/lanes/reconcile.js +358 -38
  40. package/dist/lib/lanes/spawn.d.ts +1 -0
  41. package/dist/lib/lanes/spawn.js +20 -10
  42. package/dist/lib/lanes/store.js +24 -0
  43. package/dist/lib/lanes/story-ledger.d.ts +2 -0
  44. package/dist/lib/lanes/story-ledger.js +8 -0
  45. package/dist/lib/lanes/types.d.ts +26 -4
  46. package/dist/lib/lanes/types.js +33 -1
  47. package/dist/lib/lanes/watch.d.ts +1 -0
  48. package/dist/lib/lanes/watch.js +19 -6
  49. package/dist/lib/lanes/watcher.js +139 -9
  50. package/dist/lib/mesh/live/daemon/run.js +36 -2
  51. package/dist/lib/search-index/background.d.ts +1 -0
  52. package/dist/lib/search-index/background.js +1 -0
  53. package/dist/utils/update-command-supervisor.cjs +1 -0
  54. package/dist/utils/version-gate.js +1 -1
  55. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.302.0] — 2026-10-01
6
+
7
+ - Generate company project indexes while preserving hand-written content unless `--accept` is used.
8
+
9
+ - Follow all hq-deploy app-list pages when resolving apps for comments and report publishing.
10
+ - Let a senior message or resume an `awaiting_input` lane when its pending question is gone or cannot be reached from the senior's host. Pending questions remain answer-first, including across registered Outpost routes.
11
+ - A senior can resume or send a fallback message to an `awaiting_input` lane when its pending questions are addressed to another audience. Once the resume is accepted, those questions are cancelled on their owner; unreachable owners are reported while the lane continues.
12
+ - When a blocker escalation targets a session without a registered monitor inbox, record the reason and deliver it through the lane's existing inbox instead of retrying the failed escalation every watcher pass.
13
+ - `hq integrations disconnect` can now remove company integrations that were created outside the app catalog.
14
+ - Lead envelopes can set `next_check_at` to defer automatic keep-alive resumes
15
+ until a scheduled check, with pending work continuing to resume immediately.
16
+ - Reconcile records deduplicated keep-alive skip reasons in each lane's watcher event log.
17
+ - Add a company-scoped reconcile gate that closes local interrupted worker lanes after 24 hours when all carried project stories are done or dropped and no pending work remains.
18
+ - Skip refreshing failed-dispatch lane mirrors that never reached their Outpost owner.
19
+ - Keep lane keep-alive, retry-context, and monitor history readers moving past torn JSONL records.
20
+ - Refresh primary mirror heartbeats from fresh running Outpost reports before dead-host reconciliation.
21
+ - Report answerable awaiting-input questions as the `hq lanes show` delivery path, and retry blocker-freshness fallback messages after a failed append.
22
+ - Let `hq integrations connect` send an API key in a custom HTTP header.
23
+ - Bound `hq rescue` GitHub release and tag lookups to 30 seconds with a clear timeout error.
24
+ - hq-cli now logs live-board refresh failures and recovery while continuing to retry.
25
+ - On Windows, background jobs started by `/handoff` and search indexing no longer open console windows.
26
+
5
27
  ## [5.301.0] — 2026-10-01
6
28
  - `hq meetings transcript` and `hq meetings notes` stop a stalled document download after two minutes and say so, instead of waiting forever.
7
29
 
@@ -11,12 +11,11 @@ set -euo pipefail
11
11
  HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
12
12
  cd "$HQ_ROOT"
13
13
 
14
+ COMPANIES_DIR="companies"
14
15
  PROJECTS_DIR="personal/projects"
15
16
  OUT="${PROJECTS_DIR}/INDEX.md"
16
17
  DATE=$(date -u +%Y-%m-%d)
17
18
 
18
- [[ -d "$PROJECTS_DIR" ]] || { echo "rebuild-projects-index: ${PROJECTS_DIR}/ missing, skipping" >&2; exit 0; }
19
-
20
19
  sanitize_cell() {
21
20
  printf '%s' "$1" | tr '\n' ' ' | sed -e 's/|/\\|/g' -e 's/ */ /g' -e 's/^ *//' -e 's/ *$//'
22
21
  }
@@ -29,17 +28,23 @@ trunc() {
29
28
  project_status() {
30
29
  local project="$1"
31
30
  local prd="$2"
31
+ local fallback="${3:-—}"
32
+ local expected_prd="${4:-}"
32
33
  local state="workspace/orchestrator/${project}/state.json"
33
34
  if [[ -f "$state" ]]; then
34
- local raw
35
- raw=$(jq -r '.status // "active"' "$state" 2>/dev/null || echo "active")
36
- case "$raw" in
37
- completed|complete) echo "complete" ;;
38
- in_progress|active|started) echo "active" ;;
39
- archived) echo "archived" ;;
40
- *) echo "$raw" ;;
41
- esac
42
- return
35
+ local state_prd
36
+ state_prd=$(jq -r '.prd_path // ""' "$state" 2>/dev/null || echo "")
37
+ if [[ -z "$expected_prd" || "$state_prd" == "$expected_prd" ]]; then
38
+ local raw
39
+ raw=$(jq -r '.status // "active"' "$state" 2>/dev/null || echo "active")
40
+ case "$raw" in
41
+ completed|complete) echo "complete" ;;
42
+ in_progress|active|started) echo "active" ;;
43
+ archived) echo "archived" ;;
44
+ *) echo "$raw" ;;
45
+ esac
46
+ return
47
+ fi
43
48
  fi
44
49
  if [[ -f "$prd" ]]; then
45
50
  local total passes
@@ -50,9 +55,91 @@ project_status() {
50
55
  return
51
56
  fi
52
57
  fi
53
- echo "—"
58
+ echo "$fallback"
59
+ }
60
+
61
+ sha256_file() {
62
+ if command -v sha256sum >/dev/null 2>&1; then
63
+ sha256sum "$1" | awk '{print $1}'
64
+ else
65
+ shasum -a 256 "$1" | awk '{print $1}'
66
+ fi
54
67
  }
55
68
 
69
+ stamp_generated() {
70
+ local input="$1" output="$2" normalized hash
71
+ normalized=$(mktemp)
72
+ sed 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$input" > "$normalized"
73
+ hash=$(sha256_file "$normalized")
74
+ rm -f "$normalized"
75
+ awk -v h="$hash" '{print} /^> Auto-generated. Updated: / {print "<!-- hq-generated-sha256: " h " -->"}' "$input" > "$output"
76
+ }
77
+
78
+ company_title() {
79
+ printf '%s\n' "$1" | awk -F'-' '{ for (i=1; i<=NF; i++) { $i = toupper(substr($i,1,1)) substr($i,2) } print }' OFS=' '
80
+ }
81
+
82
+ write_company_projects_index() {
83
+ local company="$1" company_root project_dir output tmp name prd description status
84
+ company_root="${COMPANIES_DIR}/${company}"
85
+ project_dir="${company_root}/projects"
86
+ output="${project_dir}/INDEX.md"
87
+ tmp=$(mktemp)
88
+ {
89
+ printf '# %s Projects\n\n' "$(company_title "$company")"
90
+ printf '> Auto-generated. Updated: %s\n\n' "$DATE"
91
+ echo '| Project | Status | Description |'
92
+ echo '|---------|--------|-------------|'
93
+ while IFS= read -r project; do
94
+ name=$(basename "$project")
95
+ [[ "$name" == _* ]] && continue
96
+ [[ "$name" == .* ]] && continue
97
+ prd="${project}/prd.json"
98
+ description=""
99
+ if [[ -f "$prd" ]]; then description=$(jq -r '.description // ""' "$prd" 2>/dev/null || echo ""); fi
100
+ status=$(project_status "$name" "$prd" "active" "$prd")
101
+ description=$(sanitize_cell "$description")
102
+ description=$(trunc "$description" 110)
103
+ [[ -z "$description" ]] && description="—"
104
+ printf '| `%s` | %s | %s |\n' "$name" "$status" "$description"
105
+ done < <(find "$project_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
106
+ } > "$tmp"
107
+ local existing=0 stored actual normalized
108
+ [[ -e "$output" ]] && existing=1
109
+ if [[ "$existing" == 1 ]]; then
110
+ if grep -q '<!-- hq:keep -->' "$output" || ! grep -q '^> Auto-generated. Updated: ' "$output"; then
111
+ echo "rebuild-projects-index: refused to overwrite ${output} (hand-written or kept)" >&2
112
+ rm -f "$tmp"
113
+ return 0
114
+ fi
115
+ stored=$(sed -n 's/^<!-- hq-generated-sha256: \([a-f0-9]*\) -->$/\1/p' "$output" | head -n 1)
116
+ if [[ -n "$stored" ]]; then
117
+ normalized=$(mktemp)
118
+ sed -e '/^<!-- hq-generated-sha256: [a-f0-9]* -->$/d' -e 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$output" > "$normalized"
119
+ actual=$(sha256_file "$normalized")
120
+ rm -f "$normalized"
121
+ if [[ "$stored" != "$actual" ]]; then
122
+ echo "rebuild-projects-index: refused to overwrite ${output} (generated content was edited)" >&2
123
+ rm -f "$tmp"
124
+ return 0
125
+ fi
126
+ fi
127
+ fi
128
+ stamp_generated "$tmp" "$output"
129
+ rm -f "$tmp"
130
+ echo "rebuild-projects-index: wrote ${output}" >&2
131
+ }
132
+
133
+ while IFS= read -r dir; do
134
+ co=$(basename "$dir")
135
+ [[ "$co" == _* ]] && continue
136
+ [[ "$co" == .* ]] && continue
137
+ project_dir="${COMPANIES_DIR}/${co}/projects"
138
+ [[ -d "$project_dir" ]] && write_company_projects_index "$co"
139
+ done < <(find "$COMPANIES_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
140
+
141
+ [[ -d "$PROJECTS_DIR" ]] || { echo "rebuild-projects-index: ${PROJECTS_DIR}/ missing, skipping" >&2; exit 0; }
142
+
56
143
  {
57
144
  echo "# Projects"
58
145
  echo ""
@@ -3122,6 +3122,9 @@ export declare const COMMAND_CATALOG: readonly [{
3122
3122
  }, {
3123
3123
  readonly flags: "--token-stdin";
3124
3124
  readonly description: "Read the API key from stdin";
3125
+ }, {
3126
+ readonly flags: "--auth-header <name>";
3127
+ readonly description: "Send the API key in this HTTP header (implies --auth key)";
3125
3128
  }, {
3126
3129
  readonly flags: "--auth <mode>";
3127
3130
  readonly description: "Force the auth mode: none, key, or oauth (default: detect)";
@@ -8608,7 +8611,7 @@ export declare const COMMAND_CATALOG: readonly [{
8608
8611
  readonly subcommands: readonly [];
8609
8612
  }, {
8610
8613
  readonly name: "resume";
8611
- readonly description: "Resume an interrupted, done or blocked lane on its recorded provider session; recover failed lanes with the senior-only --from-failed option";
8614
+ readonly description: "Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with the senior-only --from-failed option";
8612
8615
  readonly aliases: readonly [];
8613
8616
  readonly hidden: false;
8614
8617
  readonly usage: "[options] <lane-id>";
@@ -4013,6 +4013,10 @@ export const COMMAND_CATALOG = [
4013
4013
  "flags": "--token-stdin",
4014
4014
  "description": "Read the API key from stdin"
4015
4015
  },
4016
+ {
4017
+ "flags": "--auth-header <name>",
4018
+ "description": "Send the API key in this HTTP header (implies --auth key)"
4019
+ },
4016
4020
  {
4017
4021
  "flags": "--auth <mode>",
4018
4022
  "description": "Force the auth mode: none, key, or oauth (default: detect)"
@@ -11100,7 +11104,7 @@ export const COMMAND_CATALOG = [
11100
11104
  },
11101
11105
  {
11102
11106
  "name": "resume",
11103
- "description": "Resume an interrupted, done or blocked lane on its recorded provider session; recover failed lanes with the senior-only --from-failed option",
11107
+ "description": "Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with the senior-only --from-failed option",
11104
11108
  "aliases": [],
11105
11109
  "hidden": false,
11106
11110
  "usage": "[options] <lane-id>",
@@ -228,6 +228,7 @@ export function spawnDetachedRun(name, dir) {
228
228
  const fd = fs.openSync(botLogPath(dir), "a");
229
229
  const child = spawn(process.execPath, [entry, "bot", "run", name], {
230
230
  detached: true,
231
+ windowsHide: true,
231
232
  stdio: ["ignore", fd, fd],
232
233
  env: { ...process.env, HQ_NO_UPDATE_CHECK: "1" },
233
234
  });
@@ -1049,6 +1049,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
1049
1049
  try {
1050
1050
  child = spawn(backend, args, {
1051
1051
  detached: true,
1052
+ windowsHide: true,
1052
1053
  cwd: liveRoot,
1053
1054
  stdio: ["ignore", logFd, logFd],
1054
1055
  env: siblingEnv(process.env, backend),
@@ -60,6 +60,7 @@ async function launchHandoffPost(args, root) {
60
60
  HQ_NATIVE_HANDOFF_POST_CHILD: "1",
61
61
  },
62
62
  detached: true,
63
+ windowsHide: true,
63
64
  stdio: "ignore",
64
65
  });
65
66
  child.once("error", (error) => {
@@ -802,6 +802,7 @@ function spawnBashDetached(script, args, cwd, root, logFile) {
802
802
  cwd,
803
803
  env: { ...process.env, HQ_ROOT: root },
804
804
  detached: true,
805
+ windowsHide: true,
805
806
  stdio: ["ignore", fd, fd],
806
807
  });
807
808
  child.once("error", (error) => appendLog(logFile, `${error.message}\n`));
@@ -841,6 +842,7 @@ async function launchHandoffPost(args, root) {
841
842
  cwd: root,
842
843
  env: { ...process.env, HQ_NATIVE_HANDOFF_POST_CHILD: "1" },
843
844
  detached: true,
845
+ windowsHide: true,
844
846
  stdio: "ignore",
845
847
  });
846
848
  child.once("error", (error) => resolve({ code: 127, stdout: "", stderr: `${error.message}\n` }));
@@ -1,4 +1,5 @@
1
1
  import { deployApiRequest } from "../lib/deploy-api.js";
2
+ import { listDeployApps } from "../lib/deploy-apps.js";
2
3
  import { DEFAULT_HQ_ROOT, ensureCognitoToken } from "../utils/cognito-session.js";
3
4
  import { readActiveCompanySlug } from "./skill.js";
4
5
  class DeployCommentsError extends Error {
@@ -50,8 +51,10 @@ async function deployJson(token, route, orgHeaders) {
50
51
  }
51
52
  }
52
53
  async function resolveAppId(token, appNameOrId, orgHeaders) {
53
- const result = await deployJson(token, "/api/apps", orgHeaders);
54
- const apps = Array.isArray(result.apps) ? result.apps : [];
54
+ const apps = await listDeployApps((cursor) => {
55
+ const route = cursor ? `/api/apps?cursor=${encodeURIComponent(cursor)}` : "/api/apps";
56
+ return deployJson(token, route, orgHeaders);
57
+ });
55
58
  const byId = apps.find((app) => app && typeof app.id === "string" && app.id === appNameOrId);
56
59
  const matches = byId
57
60
  ? [byId]
@@ -30,6 +30,7 @@ interface ConnectOptions {
30
30
  displayName?: string;
31
31
  token?: string;
32
32
  tokenStdin?: boolean;
33
+ authHeader?: string;
33
34
  auth?: string;
34
35
  browser?: boolean;
35
36
  timeout?: string;
@@ -48,6 +49,8 @@ interface ConnectOptions {
48
49
  * than fall through to a default.
49
50
  */
50
51
  export declare function assertAuthMode(value: string | undefined): void;
52
+ /** RFC 7230 token characters accepted for an HTTP field name. */
53
+ export declare function assertAuthHeader(value: string | undefined): void;
51
54
  /** Single-quote a value for a copy-pasteable shell command. */
52
55
  export declare function shellQuote(value: string): string;
53
56
  /** Everything the connect verbs need to name one app to hq-pro. */
@@ -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))
@@ -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);
@@ -536,9 +555,13 @@ export function createLaneMessageHandlers(deps) {
536
555
  ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
537
556
  });
538
557
  }
558
+ let questionCancellation = null;
539
559
  if (move?.queued) {
540
560
  // Parked at the target's cap: reconcile resumes the session there.
541
561
  markAccepted();
562
+ questionCancellation = awaitingInputFallback
563
+ ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
564
+ : null;
542
565
  }
543
566
  else {
544
567
  const dispatched = await dispatchLaneDelivery(lane, {
@@ -637,6 +660,9 @@ export function createLaneMessageHandlers(deps) {
637
660
  if (dispatched.mechanism === "resume")
638
661
  resumed = dispatched.value;
639
662
  markAccepted();
663
+ questionCancellation = awaitingInputFallback
664
+ ? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
665
+ : null;
640
666
  }
641
667
  // Every accepted message moves the compaction reference forward.
642
668
  const next = casUpdateLane(hqRoot, laneId, (current) => ({
@@ -679,6 +705,10 @@ export function createLaneMessageHandlers(deps) {
679
705
  inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
680
706
  drain_hook: drainHook,
681
707
  warning,
708
+ ...(questionCancellation === null ? {} : {
709
+ cancelled_questions: questionCancellation.cancelled_question_ids,
710
+ question_cancellation_failures: questionCancellation.failures,
711
+ }),
682
712
  resumed_session_id: resumed?.last_resume?.session_id ?? null,
683
713
  compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
684
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,