@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.
- package/CHANGELOG.md +12 -0
- package/assets/scaffold/core/scripts/hq-session.sh +113 -7
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +101 -0
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/core-hq-session-native.d.ts +2 -0
- package/dist/commands/core-hq-session-native.js +242 -52
- package/dist/commands/core-resolve-company-native.d.ts +1 -0
- package/dist/commands/core-resolve-company-native.js +41 -11
- package/dist/commands/files.js +4 -1
- package/dist/commands/install-global.d.ts +2 -0
- package/dist/commands/install-global.js +38 -4
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +181 -2
- package/dist/commands/workers.d.ts +4 -1
- package/dist/commands/workers.js +57 -15
- package/dist/lib/cli-flag-keys.d.ts +1 -0
- package/dist/lib/cli-flag-keys.js +1 -0
- package/dist/lib/core-utils/session-id.d.ts +6 -0
- package/dist/lib/core-utils/session-id.js +35 -0
- package/dist/lib/daemon/catalog.js +1 -1
- package/dist/lib/daemon/lanes-reconcile-guard.d.ts +6 -5
- package/dist/lib/daemon/lanes-reconcile-guard.js +26 -7
- package/dist/lib/daemon/run.d.ts +6 -0
- package/dist/lib/daemon/run.js +36 -9
- package/dist/lib/daemon/supervisor.d.ts +7 -1
- package/dist/lib/daemon/supervisor.js +50 -4
- package/dist/lib/install/provider-wrappers.d.ts +9 -0
- package/dist/lib/install/provider-wrappers.js +190 -0
- package/dist/lib/lanes/provider-rejection.d.ts +5 -5
- package/dist/lib/lanes/provider-rejection.js +15 -10
- package/dist/lib/lanes/spawn.js +9 -4
- package/dist/lib/lanes/watcher.d.ts +6 -2
- package/dist/lib/lanes/watcher.js +37 -17
- package/dist/utils/resident-package-preload.js +4 -0
- package/dist/utils/self-update.js +8 -1
- package/dist/utils/version-gate.d.ts +7 -0
- package/dist/utils/version-gate.js +37 -0
- package/package.json +1 -1
package/dist/lib/daemon/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
void Promise.race([
|
|
901
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
239
|
+
retryable: retryableProviderFailure(status, message),
|
|
235
240
|
event: "grok.error",
|
|
236
241
|
};
|
|
237
242
|
}
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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).
|
|
107
|
-
*
|
|
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;
|