@indigoai-us/hq-cli 5.345.46 → 5.345.48

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/scaffold/core/scripts/hq-session.sh +113 -7
  3. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +101 -0
  4. package/dist/command-catalog.generated.d.ts +4 -0
  5. package/dist/command-catalog.generated.js +5 -0
  6. package/dist/commands/core-hq-session-native.d.ts +2 -0
  7. package/dist/commands/core-hq-session-native.js +242 -52
  8. package/dist/commands/core-resolve-company-native.d.ts +1 -0
  9. package/dist/commands/core-resolve-company-native.js +41 -11
  10. package/dist/commands/files.js +4 -1
  11. package/dist/commands/install-global.d.ts +2 -0
  12. package/dist/commands/install-global.js +38 -4
  13. package/dist/commands/secrets.d.ts +5 -0
  14. package/dist/commands/secrets.js +181 -2
  15. package/dist/commands/workers.d.ts +4 -1
  16. package/dist/commands/workers.js +57 -15
  17. package/dist/lib/cli-flag-keys.d.ts +1 -0
  18. package/dist/lib/cli-flag-keys.js +1 -0
  19. package/dist/lib/core-utils/session-id.d.ts +6 -0
  20. package/dist/lib/core-utils/session-id.js +35 -0
  21. package/dist/lib/daemon/catalog.js +1 -1
  22. package/dist/lib/daemon/lanes-reconcile-guard.d.ts +6 -5
  23. package/dist/lib/daemon/lanes-reconcile-guard.js +26 -7
  24. package/dist/lib/daemon/run.d.ts +6 -0
  25. package/dist/lib/daemon/run.js +36 -9
  26. package/dist/lib/daemon/supervisor.d.ts +7 -1
  27. package/dist/lib/daemon/supervisor.js +50 -4
  28. package/dist/lib/install/provider-wrappers.d.ts +9 -0
  29. package/dist/lib/install/provider-wrappers.js +190 -0
  30. package/dist/lib/lanes/provider-rejection.d.ts +5 -5
  31. package/dist/lib/lanes/provider-rejection.js +15 -10
  32. package/dist/lib/lanes/spawn.js +9 -4
  33. package/dist/lib/lanes/watcher.d.ts +6 -2
  34. package/dist/lib/lanes/watcher.js +37 -17
  35. package/dist/utils/resident-package-preload.js +4 -0
  36. package/dist/utils/self-update.js +8 -1
  37. package/dist/utils/version-gate.d.ts +7 -0
  38. package/dist/utils/version-gate.js +37 -0
  39. package/package.json +1 -1
@@ -14,6 +14,12 @@ import { type HostApp } from "./supervisor.js";
14
14
  export declare const LOG_ROTATE_BYTES: number;
15
15
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
16
16
  export declare const RESTART_EXIT_CODE = 75;
17
+ /** A bounded shutdown deadline that can account for sync push persistence. */
18
+ export declare function createShutdownCap(): {
19
+ promise: Promise<"timeout">;
20
+ extendForSyncRunnerPersist(): void;
21
+ clear(): void;
22
+ };
17
23
  export interface DaemonLockConflictOutcome {
18
24
  exitCode: 0 | 1;
19
25
  stdout?: string;
@@ -45,12 +45,36 @@ import { stageHqChildBootstrap } from "./hq-child-bootstrap-stage.js";
45
45
  import { runnerSessionName } from "./remote-control.js";
46
46
  import { adoptChild, createChildTracker, finishSuccession, handoffEligible, readProcStart, stopOrphans, successionPaths, systemdKillMode, takeSuccession, writeHandoff, } from "./succession.js";
47
47
  import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
48
- import { STOP_ESCALATION_MS, createSupervisor, } from "./supervisor.js";
48
+ import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, } from "./supervisor.js";
49
49
  import { userToolDirs } from "./tools.js";
50
50
  import { daemonRunGuard } from "./lanes-reconcile-guard.js";
51
51
  export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
52
52
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
53
53
  export const RESTART_EXIT_CODE = 75;
54
+ const NON_SYNC_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + 5_000;
55
+ const SYNC_RUNNER_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + SYNC_RUNNER_PERSIST_GRACE_MS + 5_000;
56
+ /** A bounded shutdown deadline that can account for sync push persistence. */
57
+ export function createShutdownCap() {
58
+ const startedAt = Date.now();
59
+ let deadline = startedAt + NON_SYNC_SHUTDOWN_CAP_MS;
60
+ let resolveCap;
61
+ let timer;
62
+ const promise = new Promise((resolve) => { resolveCap = resolve; });
63
+ const arm = () => {
64
+ if (timer)
65
+ clearTimeout(timer);
66
+ timer = setTimeout(() => resolveCap("timeout"), Math.max(0, deadline - Date.now()));
67
+ };
68
+ arm();
69
+ return {
70
+ promise,
71
+ extendForSyncRunnerPersist() {
72
+ deadline = Math.max(deadline, startedAt + SYNC_RUNNER_SHUTDOWN_CAP_MS);
73
+ arm();
74
+ },
75
+ clear() { clearTimeout(timer); },
76
+ };
77
+ }
54
78
  /** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
55
79
  export function daemonLockConflictOutcome(lock, anywhereEnabled) {
56
80
  if (lock.ok)
@@ -772,6 +796,7 @@ export async function runDaemon(opts) {
772
796
  };
773
797
  // Set once the shutdown path exists below; a restart request before then is dropped.
774
798
  let restartDaemon = () => false;
799
+ let extendSyncShutdownCap;
775
800
  const supervisor = createSupervisor({
776
801
  machineType,
777
802
  host: opts.host,
@@ -794,6 +819,7 @@ export async function runDaemon(opts) {
794
819
  runGuard: daemonRunGuard,
795
820
  writeState: (state) => writeJsonAtomic(paths.statePath, state),
796
821
  log,
822
+ onSyncRunnerStopping: () => extendSyncShutdownCap?.(),
797
823
  syncOwnerBlockReason: () => {
798
824
  const config = readDaemonConfig(paths.configPath);
799
825
  const hqRoot = config.hqRoot ?? env.HQ_ROOT?.trim();
@@ -891,14 +917,15 @@ export async function runDaemon(opts) {
891
917
  stopping = true;
892
918
  log("info", `${why}; stopping services`);
893
919
  closeHqd();
894
- // The supervisor escalates to SIGKILL after STOP_ESCALATION_MS; the extra
895
- // margin only covers a child that survives even that.
896
- let capTimer;
897
- const cap = new Promise((r) => {
898
- capTimer = setTimeout(() => r("timeout"), STOP_ESCALATION_MS + 5_000);
899
- });
900
- void Promise.race([supervisor.stop(), cap]).then((result) => {
901
- clearTimeout(capTimer);
920
+ // Keep shutdown bounded, but let a stopping sync runner reach its persist deadline.
921
+ const cap = createShutdownCap();
922
+ extendSyncShutdownCap = cap.extendForSyncRunnerPersist;
923
+ if (supervisor.hasStoppingSyncRunner())
924
+ cap.extendForSyncRunnerPersist();
925
+ const servicesStopped = supervisor.stop();
926
+ void Promise.race([servicesStopped, cap.promise]).then((result) => {
927
+ cap.clear();
928
+ extendSyncShutdownCap = undefined;
902
929
  if (result === "timeout")
903
930
  log("error", "some services did not exit after SIGKILL; releasing the lock anyway");
904
931
  else
@@ -13,7 +13,8 @@
13
13
  * - restart/trigger/stop requests from the CLI are polled and applied; a
14
14
  * request for a unit that is not planned yet re-reads the plan first;
15
15
  * - stop() sends SIGTERM to every child, escalates to SIGKILL after
16
- * STOP_ESCALATION_MS, and resolves once every child has exited;
16
+ * STOP_ESCALATION_MS except for a sync runner already stopping, which it lets
17
+ * finish persisting pending pushes before resolving;
17
18
  * - release() instead leaves every child running and reports the long-running
18
19
  * ones, so the next daemon can adopt them (`adopt`). Adopted units whose
19
20
  * command is unchanged keep running; changed ones are replaced one at a
@@ -26,6 +27,7 @@ import type { MachineType } from "./machine-type.js";
26
27
  import type { ControlRequest } from "./requests.js";
27
28
  import { syncPassResult } from "./sync-runner.js";
28
29
  export declare const STOP_ESCALATION_MS = 10000;
30
+ export declare const SYNC_RUNNER_PERSIST_GRACE_MS = 60000;
29
31
  export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
30
32
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
31
33
  export declare const HANDOFF_STAGGER_MS = 15000;
@@ -98,6 +100,8 @@ export interface SupervisorDeps {
98
100
  restartDaemon?: () => void | boolean | Promise<boolean | void>;
99
101
  writeState: (state: DaemonState) => void;
100
102
  log: (level: "info" | "warn" | "error", msg: string) => void;
103
+ /** Extend the daemon shutdown deadline when a sync runner reports stopping. */
104
+ onSyncRunnerStopping?: () => void;
101
105
  now?: () => number;
102
106
  setTimeout?: (fn: () => void, ms: number) => unknown;
103
107
  clearTimeout?: (handle: unknown) => void;
@@ -120,6 +124,8 @@ export interface Supervisor {
120
124
  stop(): Promise<void>;
121
125
  reconcile(): void;
122
126
  state(): DaemonState;
127
+ /** True while a live sync runner has reported that it is persisting pushes. */
128
+ hasStoppingSyncRunner(): boolean;
123
129
  /** Stop supervising without signalling any child; returns the long-running units left running. */
124
130
  release(): ReleasedUnit[];
125
131
  }
@@ -13,7 +13,8 @@
13
13
  * - restart/trigger/stop requests from the CLI are polled and applied; a
14
14
  * request for a unit that is not planned yet re-reads the plan first;
15
15
  * - stop() sends SIGTERM to every child, escalates to SIGKILL after
16
- * STOP_ESCALATION_MS, and resolves once every child has exited;
16
+ * STOP_ESCALATION_MS except for a sync runner already stopping, which it lets
17
+ * finish persisting pending pushes before resolving;
17
18
  * - release() instead leaves every child running and reports the long-running
18
19
  * ones, so the next daemon can adopt them (`adopt`). Adopted units whose
19
20
  * command is unchanged keep running; changed ones are replaced one at a
@@ -27,6 +28,7 @@ const BACKOFF_BASE_MS = 1_000;
27
28
  const BACKOFF_MAX_MS = 60_000;
28
29
  const HEALTHY_RUN_MS = 60_000;
29
30
  export const STOP_ESCALATION_MS = 10_000;
31
+ export const SYNC_RUNNER_PERSIST_GRACE_MS = 60_000;
30
32
  const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
31
33
  export const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60_000;
32
34
  const MESH_OWNER_RECHECK_MS = 15_000;
@@ -91,6 +93,7 @@ export function createSupervisor(deps) {
91
93
  let handoffTimer;
92
94
  /** Every child process not yet known to have exited, including ones the slots have let go of. */
93
95
  const live = new Set();
96
+ const liveOwners = new Map();
94
97
  let drained = null;
95
98
  let onDrained = null;
96
99
  let deferredCliRestart = false;
@@ -247,6 +250,16 @@ export function createSupervisor(deps) {
247
250
  deps.log("error", `could not send ${signal} to ${slot.plan.id}: ${errorMessage(err)}`);
248
251
  }
249
252
  };
253
+ const syncRunnerIsStopping = (slot) => slot?.plan.service === "sync" && slot.lastProtocolStatus === "stopping";
254
+ const scheduleSyncRunnerPersistEscalation = (slot, child) => {
255
+ slot.killTimer = setT(() => {
256
+ slot.killTimer = undefined;
257
+ if (slot.child !== child)
258
+ return;
259
+ deps.log("warn", `${slot.plan.id} did not finish persisting pending pushes within ${SYNC_RUNNER_PERSIST_GRACE_MS / 1_000} s; sending SIGKILL${lastProtocolHint(slot)}`);
260
+ signalChild(slot, child, "SIGKILL");
261
+ }, SYNC_RUNNER_PERSIST_GRACE_MS);
262
+ };
250
263
  /**
251
264
  * Stop the slot's child (if any), then run `then` once it has exited. With
252
265
  * `drain`, a unit that has a drain signal gets it first and SIGTERM only
@@ -276,6 +289,11 @@ export function createSupervisor(deps) {
276
289
  slot.killTimer = undefined;
277
290
  if (slot.child !== child)
278
291
  return;
292
+ if (syncRunnerIsStopping(slot)) {
293
+ deps.log("info", `${slot.plan.id} is stopping; allowing ${SYNC_RUNNER_PERSIST_GRACE_MS / 1_000} s for pending pushes to persist`);
294
+ scheduleSyncRunnerPersistEscalation(slot, child);
295
+ return;
296
+ }
279
297
  deps.log("warn", `${slot.plan.id} did not exit within ${killGraceMs / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
280
298
  signalChild(slot, child, "SIGKILL");
281
299
  }, killGraceMs);
@@ -313,6 +331,7 @@ export function createSupervisor(deps) {
313
331
  };
314
332
  const watch = (slot, child) => {
315
333
  live.add(child);
334
+ liveOwners.set(child, slot);
316
335
  let ended = false;
317
336
  const timeoutMs = slot.plan.heartbeatTimeoutMs;
318
337
  const decoder = timeoutMs ? new StringDecoder("utf8") : null;
@@ -350,6 +369,8 @@ export function createSupervisor(deps) {
350
369
  slot.syncPassActive = false;
351
370
  else if (event.type === "status" && typeof event.state === "string") {
352
371
  slot.lastProtocolStatus = event.state;
372
+ if (event.state === "stopping")
373
+ deps.onSyncRunnerStopping?.();
353
374
  if (event.state === "stopped" || event.state === "waiting-for-lock")
354
375
  slot.syncPassActive = false;
355
376
  else if (slot.syncPassActive === undefined && (event.state === "starting" || event.state === "running")) {
@@ -405,6 +426,7 @@ export function createSupervisor(deps) {
405
426
  return;
406
427
  ended = true;
407
428
  live.delete(child);
429
+ liveOwners.delete(child);
408
430
  if (live.size === 0)
409
431
  onDrained?.();
410
432
  if (slot.child !== child)
@@ -991,10 +1013,8 @@ export function createSupervisor(deps) {
991
1013
  clearT(handoffTimer);
992
1014
  clearDeferredCliRestart();
993
1015
  clearPendingSyncCommandRestart();
994
- const childOwners = new Map();
1016
+ const childOwners = new Map(liveOwners);
995
1017
  for (const slot of slots.values()) {
996
- if (slot.child)
997
- childOwners.set(slot.child, slot);
998
1018
  clearTimer(slot);
999
1019
  clearHeartbeatTimer(slot);
1000
1020
  slot.child = undefined;
@@ -1020,9 +1040,16 @@ export function createSupervisor(deps) {
1020
1040
  resolve();
1021
1041
  return;
1022
1042
  }
1043
+ const persistingSyncRunners = new Set();
1044
+ let persistEscalation;
1023
1045
  const escalation = setT(() => {
1024
1046
  for (const child of live) {
1025
1047
  const owner = childOwners.get(child);
1048
+ if (owner && syncRunnerIsStopping(owner)) {
1049
+ deps.log("info", `${owner.plan.id} is stopping; allowing ${SYNC_RUNNER_PERSIST_GRACE_MS / 1_000} s for pending pushes to persist`);
1050
+ persistingSyncRunners.add(child);
1051
+ continue;
1052
+ }
1026
1053
  deps.log("warn", `${owner?.plan.id ?? `pid ${child.pid ?? "?"}`} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${owner ? lastProtocolHint(owner) : ""}`);
1027
1054
  try {
1028
1055
  child.kill("SIGKILL");
@@ -1031,10 +1058,28 @@ export function createSupervisor(deps) {
1031
1058
  deps.log("error", `could not SIGKILL pid ${child.pid ?? "?"}: ${errorMessage(err)}`);
1032
1059
  }
1033
1060
  }
1061
+ if (persistingSyncRunners.size > 0) {
1062
+ persistEscalation = setT(() => {
1063
+ for (const child of persistingSyncRunners) {
1064
+ if (!live.has(child))
1065
+ continue;
1066
+ const owner = childOwners.get(child);
1067
+ deps.log("warn", `${owner?.plan.id ?? `pid ${child.pid ?? "?"}`} did not finish persisting pending pushes within ${SYNC_RUNNER_PERSIST_GRACE_MS / 1_000} s; sending SIGKILL${owner ? lastProtocolHint(owner) : ""}`);
1068
+ try {
1069
+ child.kill("SIGKILL");
1070
+ }
1071
+ catch (err) {
1072
+ deps.log("error", `could not SIGKILL pid ${child.pid ?? "?"}: ${errorMessage(err)}`);
1073
+ }
1074
+ }
1075
+ }, SYNC_RUNNER_PERSIST_GRACE_MS);
1076
+ }
1034
1077
  }, STOP_ESCALATION_MS);
1035
1078
  onDrained = () => {
1036
1079
  onDrained = null;
1037
1080
  clearT(escalation);
1081
+ if (persistEscalation !== undefined)
1082
+ clearT(persistEscalation);
1038
1083
  resolve();
1039
1084
  };
1040
1085
  });
@@ -1042,6 +1087,7 @@ export function createSupervisor(deps) {
1042
1087
  },
1043
1088
  reconcile,
1044
1089
  state: snapshot,
1090
+ hasStoppingSyncRunner: () => [...liveOwners.values()].some(syncRunnerIsStopping),
1045
1091
  release() {
1046
1092
  const released = [];
1047
1093
  for (const slot of slots.values()) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Throw if either wrapper target holds a non-HQ command. Callers run this before
3
+ * any install mutation so a collision cannot leave HQ half-installed.
4
+ */
5
+ export declare function assertProviderWrappersInstallable(binDir: string): void;
6
+ export declare function providerWrapperInstallPaths(home: string): string[];
7
+ export declare function installProviderWrappers(binDir: string, home?: string): string[];
8
+ export declare function removeProviderWrappers(binDir: string): string[];
9
+ //# sourceMappingURL=provider-wrappers.d.ts.map
@@ -0,0 +1,190 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ const marker = '# hq-anywhere provider sign-in wrapper';
5
+ const PROVIDER_PATH_RC_FILES = ['.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile'];
6
+ const wrapperHeader = (provider) => `#!/bin/sh\n${marker}: ${provider}\n`;
7
+ /**
8
+ * Throw if either wrapper target holds a non-HQ command. Callers run this before
9
+ * any install mutation so a collision cannot leave HQ half-installed.
10
+ */
11
+ export function assertProviderWrappersInstallable(binDir) {
12
+ for (const provider of ['claude', 'codex']) {
13
+ const file = path.join(binDir, provider);
14
+ let current;
15
+ try {
16
+ current = fs.readFileSync(file, 'utf8');
17
+ }
18
+ catch (e) {
19
+ if (e.code === 'ENOENT')
20
+ continue;
21
+ throw e;
22
+ }
23
+ if (!current.startsWith(wrapperHeader(provider))) {
24
+ throw new Error(`Refusing to replace an existing ${provider} command at ${file}.`);
25
+ }
26
+ }
27
+ }
28
+ export function providerWrapperInstallPaths(home) {
29
+ const binDir = path.join(home, '.hq', 'bin');
30
+ const paths = ['claude', 'codex'].map((provider) => path.join(binDir, provider));
31
+ const pathSetupMarker = path.join(home, '.hq', 'provider-path-installed');
32
+ if (!fs.existsSync(pathSetupMarker)) {
33
+ paths.push(pathSetupMarker, ...PROVIDER_PATH_RC_FILES.map((rc) => path.join(home, rc)));
34
+ }
35
+ return paths;
36
+ }
37
+ function wrapperSource(provider) {
38
+ const pretty = provider === 'claude' ? 'Claude Code' : 'Codex';
39
+ const login = provider === 'claude' ? 'claude /login' : 'codex login';
40
+ const statusArgs = provider === 'claude' ? 'auth status' : 'login status';
41
+ const authFailure = provider === 'codex'
42
+ ? '401|unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated|missing.*(bearer|credential|auth)|websocket.*auth'
43
+ : '401|unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated|missing.*(bearer|credential|auth)';
44
+ const runAuthFailure = provider === 'codex'
45
+ ? 'HTTP error: *401|status 401|401 Unauthorized|websocket.*auth|not logged in|signed out|not authenticated|authentication required|unauthenticated'
46
+ : 'HTTP error: *401|status 401|401 Unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated';
47
+ return [
48
+ '#!/bin/sh',
49
+ `${marker}: ${provider}`,
50
+ 'set -u',
51
+ 'umask 077',
52
+ `name=${provider}`,
53
+ `pretty='${pretty}'`,
54
+ `login='${login}'`,
55
+ `provider='${provider}'`,
56
+ '',
57
+ '# Skip this managed wrapper and locate the provider executable later in PATH.',
58
+ 'self=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")',
59
+ 'real=',
60
+ 'old_ifs=$IFS',
61
+ 'IFS=:',
62
+ 'for dir in $PATH; do',
63
+ ' [ -n "$dir" ] || dir=.',
64
+ ' candidate=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)/$name',
65
+ ' if [ -x "$candidate" ] && [ "$candidate" != "$self" ]; then real=$candidate; break; fi',
66
+ 'done',
67
+ 'IFS=$old_ifs',
68
+ 'if [ -z "$real" ]; then printf \'%s CLI was not found on PATH.\\n\' "$pretty" >&2; exit 127; fi',
69
+ '',
70
+ '# The flag override is part of the existing hq-anywhere-runtime flag contract.',
71
+ 'case "${HQ_FLAG_HQ_ANYWHERE_RUNTIME:-}" in false|0) exec "$real" "$@" ;; esac',
72
+ '',
73
+ '# Login, auth, and informational commands must remain reachable while signed out.',
74
+ 'case "${1:-}" in',
75
+ ' --version|-v|--help|-h) exec "$real" "$@" ;;',
76
+ ` ${provider === 'claude' ? '/login|auth' : 'login'}) exec "$real" "$@" ;;`,
77
+ 'esac',
78
+ '',
79
+ 'log_dir="$HOME/.hq/logs"',
80
+ 'mkdir -p "$log_dir" 2>/dev/null || :',
81
+ 'log_file="$log_dir/provider-auth.log"',
82
+ 'tmp=$(mktemp -d "${TMPDIR:-/tmp}/hq-provider.XXXXXX") || exit 1',
83
+ 'trap \'rm -rf "$tmp"\' EXIT HUP INT TERM',
84
+ `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; else auth_status=124; fi`,
85
+ 'case "$auth_status" in 124|142) : >"$tmp/status-timeout" ;; esac',
86
+ 'if [ "$auth_status" -ne 0 ]; then cat "$tmp/status" >>"$log_file" 2>/dev/null || :; fi',
87
+ `if [ ! -e "$tmp/status-timeout" ] && grep -Eiq '${authFailure}' "$tmp/status"; then`,
88
+ ' cat "$tmp/status" >>"$log_file" 2>/dev/null || :',
89
+ ' printf \'%s\\n\' "[$(date -u +%FT%TZ)] $pretty sign-in check reported unauthenticated" >>"$log_file" 2>/dev/null || :',
90
+ ' printf \'%s is not signed in. Sign in to use HQ Anywhere:\\n %s\\n\' "$pretty" "$login" >&2',
91
+ ' exit 1',
92
+ 'fi',
93
+ 'if [ -t 1 ] && { [ "$provider" = claude ] && [ "${1:-}" != -p ] && [ "${1:-}" != --print ] || [ "$provider" = codex ] && [ "${1:-}" != exec ]; }; then exec "$real" "$@"; fi',
94
+ '',
95
+ '# Leave provider stdout attached to the caller so streaming and TTY state survive.',
96
+ 'mkfifo "$tmp/stderr.pipe"',
97
+ 'filter_stderr() {',
98
+ ' trap - EXIT HUP INT TERM',
99
+ ' while :; do',
100
+ ' line=',
101
+ ' IFS= read -r line',
102
+ ' read_status=$?',
103
+ ' [ -n "$line" ] || [ "$read_status" -eq 0 ] || break',
104
+ ' if printf \'%s\\n\' "$line" | grep -Eiq "' + runAuthFailure + '"; then',
105
+ ' printf \'%s\\n\' "$line" >>"$tmp/auth-found"',
106
+ ' else',
107
+ ' printf \'%s\' "$line" >&2',
108
+ ' [ "$read_status" -eq 0 ] && printf \'\\n\' >&2',
109
+ ' fi',
110
+ ' [ "$read_status" -eq 0 ] || break',
111
+ ' done <"$tmp/stderr.pipe"',
112
+ '}',
113
+ 'filter_stderr &',
114
+ 'filter_pid=$!',
115
+ '"$real" "$@" 2>"$tmp/stderr.pipe"',
116
+ 'status=$?',
117
+ 'wait "$filter_pid"',
118
+ `if [ "$status" -ne 0 ] && [ -f "$tmp/auth-found" ]; then`,
119
+ ' # Only the matched authentication lines are logged; the run\'s other output can',
120
+ ' # carry prompts, source or downstream secrets and never reaches this file.',
121
+ ' { printf \'\\n[hq-anywhere] %s returned an authentication failure.\\n\' "$pretty"; cat "$tmp/auth-found"; } >>"$log_file" 2>/dev/null || :',
122
+ ` printf \'%s could not authenticate this run. Sign in to use HQ Anywhere:\\n %s\\n\' "$pretty" "$login" >&2`,
123
+ ' exit "$status"',
124
+ 'fi',
125
+ 'exit "$status"',
126
+ '',
127
+ ].join('\n');
128
+ }
129
+ export function installProviderWrappers(binDir, home = path.dirname(path.dirname(binDir))) {
130
+ fs.mkdirSync(binDir, { recursive: true, mode: 0o755 });
131
+ const files = ['claude', 'codex'].map((provider) => path.join(binDir, provider));
132
+ // Both targets are checked before either is written, so a collision on codex
133
+ // cannot leave a freshly written claude wrapper behind.
134
+ assertProviderWrappersInstallable(binDir);
135
+ for (const provider of ['claude', 'codex']) {
136
+ const file = path.join(binDir, provider);
137
+ fs.writeFileSync(file, wrapperSource(provider), { mode: 0o755 });
138
+ fs.chmodSync(file, 0o755);
139
+ }
140
+ // Startup files are append-only. The marker avoids reading any shell startup file.
141
+ const pathSetupMarker = path.join(path.dirname(binDir), 'provider-path-installed');
142
+ if (!fs.existsSync(pathSetupMarker)) {
143
+ const pathLine = '\nexport PATH="$HOME/.hq/bin:$PATH" # hq-anywhere provider commands\n';
144
+ // Only startup files that already exist are edited. Creating .bash_profile or
145
+ // .zprofile would make the login shell select it and stop reading the user's
146
+ // existing .profile, silently dropping their environment setup.
147
+ const existing = PROVIDER_PATH_RC_FILES.filter((rc) => fs.existsSync(path.join(home, rc)));
148
+ const createdRcFiles = [];
149
+ if (existing.length === 0) {
150
+ // .profile shadows nothing: every POSIX login shell reads it, and no other
151
+ // startup file is present for it to take precedence over.
152
+ createdRcFiles.push('.profile');
153
+ }
154
+ for (const rc of [...existing, ...createdRcFiles]) {
155
+ fs.appendFileSync(path.join(home, rc), pathLine, { mode: 0o600 });
156
+ }
157
+ fs.writeFileSync(pathSetupMarker, `${createdRcFiles.join('\n')}\n`, { mode: 0o600 });
158
+ }
159
+ return files;
160
+ }
161
+ export function removeProviderWrappers(binDir) {
162
+ const removed = [];
163
+ for (const provider of ['claude', 'codex']) {
164
+ const file = path.join(binDir, provider);
165
+ if (!fs.existsSync(file))
166
+ continue;
167
+ const current = fs.readFileSync(file, 'utf8');
168
+ if (!current.startsWith(wrapperHeader(provider)))
169
+ continue;
170
+ fs.unlinkSync(file);
171
+ removed.push(file);
172
+ }
173
+ const home = path.dirname(path.dirname(binDir));
174
+ const pathSetupMarker = path.join(home, '.hq', 'provider-path-installed');
175
+ if (fs.existsSync(pathSetupMarker)) {
176
+ const createdRcFiles = new Set(fs.readFileSync(pathSetupMarker, 'utf8').split('\n').filter(Boolean));
177
+ const deletePathSetup = 's/\\nexport PATH="\\$HOME\\/\\.hq\\/bin:\\$PATH" # hq-anywhere provider commands\\n//';
178
+ for (const rc of PROVIDER_PATH_RC_FILES) {
179
+ const file = path.join(home, rc);
180
+ if (fs.existsSync(file)) {
181
+ execFileSync('perl', ['-0pi', '-e', deletePathSetup, file]);
182
+ if (createdRcFiles.has(rc))
183
+ fs.unlinkSync(file);
184
+ }
185
+ }
186
+ fs.unlinkSync(pathSetupMarker);
187
+ }
188
+ return removed;
189
+ }
190
+ //# sourceMappingURL=provider-wrappers.js.map
@@ -6,13 +6,13 @@
6
6
  * output and no envelope. Without this module the watcher classifies that
7
7
  * `interrupted` with `exited_with_partial_output`, and the operator has to
8
8
  * read the raw log to learn why. Here the failure is named: the lane is
9
- * `failed` with `terminal_reason: provider_rejected` and the provider's own
10
- * message on `terminal_details`.
9
+ * terminalized with a named provider reason and the provider's own message on
10
+ * `terminal_details`; transient failures remain resumable.
11
11
  *
12
12
  * A 429 is parsed here like any other status so that a codex or grok 429,
13
- * which the claude-shaped quota reader cannot see, still reaches the quota
14
- * path: the watcher routes a `status === QUOTA_STATUS` failure to quota.ts
15
- * (reset time, account hold) and everything else to `provider_rejected`.
13
+ * which the claude-shaped quota reader cannot see, still reaches the watcher.
14
+ * Quota-specific messages become account holds; generic rate-limit responses
15
+ * remain retryable provider rejections.
16
16
  *
17
17
  * Only the fields named below are read out of the payload. The prompt, the
18
18
  * response body and everything else stay on disk. Nothing here logs.
@@ -6,13 +6,13 @@
6
6
  * output and no envelope. Without this module the watcher classifies that
7
7
  * `interrupted` with `exited_with_partial_output`, and the operator has to
8
8
  * read the raw log to learn why. Here the failure is named: the lane is
9
- * `failed` with `terminal_reason: provider_rejected` and the provider's own
10
- * message on `terminal_details`.
9
+ * terminalized with a named provider reason and the provider's own message on
10
+ * `terminal_details`; transient failures remain resumable.
11
11
  *
12
12
  * A 429 is parsed here like any other status so that a codex or grok 429,
13
- * which the claude-shaped quota reader cannot see, still reaches the quota
14
- * path: the watcher routes a `status === QUOTA_STATUS` failure to quota.ts
15
- * (reset time, account hold) and everything else to `provider_rejected`.
13
+ * which the claude-shaped quota reader cannot see, still reaches the watcher.
14
+ * Quota-specific messages become account holds; generic rate-limit responses
15
+ * remain retryable provider rejections.
16
16
  *
17
17
  * Only the fields named below are read out of the payload. The prompt, the
18
18
  * response body and everything else stay on disk. Nothing here logs.
@@ -114,10 +114,14 @@ function capMessage(message) {
114
114
  export function retryableFromStatus(status) {
115
115
  if (status === null)
116
116
  return false;
117
- if (status === 408 || status === 425)
117
+ if (status === 408 || status === 425 || status === 429)
118
118
  return true;
119
119
  return status >= 500 && status <= 599;
120
120
  }
121
+ function retryableProviderFailure(status, message) {
122
+ return retryableFromStatus(status) ||
123
+ (status === null && /\b(?:rate limit|too many requests)\b/i.test(message));
124
+ }
121
125
  /**
122
126
  * Codex `exec --json`: `{"type":"turn.failed","error":{"message":"<text>"}}`.
123
127
  * Measured 2026-09-22 (lane 01M34HCCP1ZDN3H68ESCRZE7RZ): the message is
@@ -151,7 +155,7 @@ export function parseCodexTurnFailed(text) {
151
155
  message: capMessage(message),
152
156
  status,
153
157
  error_type: errorType,
154
- retryable: retryableFromStatus(status),
158
+ retryable: retryableProviderFailure(status, message),
155
159
  event: "codex.turn.failed",
156
160
  };
157
161
  });
@@ -175,15 +179,16 @@ export function parseClaudeResultRejection(text) {
175
179
  if (rec.is_error !== true && !loginFailure)
176
180
  return NOT_REJECTED;
177
181
  const status = statusOf(rec.api_error_status);
178
- if (status === null && !loginFailure)
182
+ if (status === null && !loginFailure && !retryableProviderFailure(status, message)) {
179
183
  return NOT_REJECTED;
184
+ }
180
185
  return {
181
186
  reason: PROVIDER_REJECTED_REASON,
182
187
  provider: "claude",
183
188
  message: capMessage(message),
184
189
  status,
185
190
  error_type: typeof rec.subtype === "string" ? rec.subtype : null,
186
- retryable: retryableFromStatus(status),
191
+ retryable: retryableProviderFailure(status, message),
187
192
  event: loginFailure && rec.is_error !== true
188
193
  ? "claude.result.login_failure"
189
194
  : "claude.result.is_error",
@@ -231,7 +236,7 @@ function grokRejectionFrom(rec) {
231
236
  message: capMessage(message),
232
237
  status,
233
238
  error_type: null,
234
- retryable: retryableFromStatus(status),
239
+ retryable: retryableProviderFailure(status, message),
235
240
  event: "grok.error",
236
241
  };
237
242
  }
@@ -1621,11 +1621,16 @@ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details, options = {
1621
1621
  : []),
1622
1622
  ].filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)));
1623
1623
  const quotaResetAt = resetCandidates.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
1624
- const resumableQuotaHold = current.keep_alive === true &&
1625
- (current.pending_resume !== undefined || options.resumeTrigger !== undefined) &&
1626
- (reason === "account_quota_exhausted" || reason === "account_model_unavailable") &&
1624
+ // Initial queued lanes can be retried by reconcile without a session;
1625
+ // ordinary resumes keep their existing failed-state behavior.
1626
+ const initialQueuedSpawn = current.state === "queued" &&
1627
+ current.pending_resume === undefined && options.resumeTrigger === undefined;
1628
+ const keepAliveResume = current.keep_alive === true &&
1629
+ (current.pending_resume !== undefined || options.resumeTrigger !== undefined);
1630
+ const resumableQuotaHold = (reason === "account_quota_exhausted" || reason === "account_model_unavailable") &&
1627
1631
  typeof quotaResetAt === "string" &&
1628
- Number.isFinite(Date.parse(quotaResetAt));
1632
+ Number.isFinite(Date.parse(quotaResetAt)) &&
1633
+ (initialQueuedSpawn || keepAliveResume);
1629
1634
  if (current.state === "failed" && existing === reason && !resumableQuotaHold)
1630
1635
  return current;
1631
1636
  const operatorQuotaHold = resumableQuotaHold && options.resumeTrigger !== undefined;
@@ -76,6 +76,7 @@ export interface WatcherTickInput {
76
76
  errors: string[];
77
77
  } | null;
78
78
  providerRejection?: ProviderRejection | null;
79
+ hasProviderSession?: boolean;
79
80
  }
80
81
  export interface WatcherTickResult {
81
82
  emit?: WatcherSignal;
@@ -103,10 +104,13 @@ export declare function classifyLaneExit(input: {
103
104
  /**
104
105
  * Set when the worker's output stream carries a provider-level turn
105
106
  * failure (codex `turn.failed`, a Claude error result with an HTTP status,
106
- * a grok error object). Failed, never interrupted: the provider refused
107
- * the turn, so there is nothing to resume and no envelope to repair.
107
+ * a grok error object). A retryable refusal interrupts only when a provider
108
+ * session exists to resume; without one, the lane fails with the named
109
+ * provider reason. Neither path attempts envelope repair.
108
110
  */
109
111
  providerRejection?: ProviderRejection | null;
112
+ /** True only when the lane has a current or prior provider session id. */
113
+ hasProviderSession?: boolean;
110
114
  }): TerminalClassification;
111
115
  export declare function routeProviderFailure(failure: ProviderRejection | null, readQuota: () => QuotaSignal | null, model: string | undefined, now: Date): {
112
116
  quota: QuotaSignal | null;