@indigoai-us/hq-cli 5.154.0 → 5.155.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.
- package/CHANGELOG.md +25 -0
- package/dist/commands/lanes.d.ts +3 -11
- package/dist/commands/lanes.js +68 -37
- package/dist/lib/lanes/accounts.d.ts +16 -11
- package/dist/lib/lanes/accounts.js +50 -8
- package/dist/lib/lanes/admission-host.d.ts +19 -0
- package/dist/lib/lanes/admission-host.js +33 -0
- package/dist/lib/lanes/config.js +25 -0
- package/dist/lib/lanes/provider-rejection.d.ts +4 -4
- package/dist/lib/lanes/provider-rejection.js +24 -6
- package/dist/lib/lanes/reconcile.js +30 -1
- package/dist/lib/lanes/spawn.d.ts +9 -1
- package/dist/lib/lanes/spawn.js +420 -14
- package/dist/lib/lanes/types.d.ts +7 -3
- package/dist/lib/lanes/types.js +3 -2
- package/dist/lib/lanes/watcher.d.ts +8 -1
- package/dist/lib/lanes/watcher.js +39 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.155.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Lane respawns keep their recorded account to preserve provider sessions and
|
|
10
|
+
prompt caches. Automatic moves now record why they happened and are limited
|
|
11
|
+
to model changes, usage limits, and unavailable or logged-out accounts.
|
|
12
|
+
- Account retries share the provider admission lock and the host-scoped provider,
|
|
13
|
+
account, and profile checks used by normal admission. Active reservations and
|
|
14
|
+
earlier eligible queue entries keep their place; blocked retries remain queued
|
|
15
|
+
for normal admission. Retries skip accounts already rejected for login and
|
|
16
|
+
retain message, resume, and answer context when queued.
|
|
17
|
+
- Watcher retries resolve the current process hostname and Outpost identity, so
|
|
18
|
+
local-kind and Outpost-kind lanes on one Outpost share admission caps.
|
|
19
|
+
- Account enable and registration now require a positive login check. Provider
|
|
20
|
+
startup messages for logged-out accounts trigger failover to another eligible
|
|
21
|
+
account or a named `account_not_logged_in` failure.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Provider config can list accounts in preference order for a lane's first
|
|
26
|
+
assignment. Disabled, logged-out, usage-limited, and capped accounts are
|
|
27
|
+
skipped. If no preferred account is eligible, hq-cli chooses the least-loaded
|
|
28
|
+
eligible account. Running and resumed lanes keep their recorded account.
|
|
29
|
+
|
|
5
30
|
## [5.154.0] — 2026-09-24
|
|
6
31
|
|
|
7
32
|
### Fixed
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* `watch` and the monitor reminder are the line-oriented text surfaces.
|
|
7
7
|
*/
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
+
import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
10
|
+
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
11
|
+
export type { OutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
9
12
|
import { type OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
|
|
10
13
|
import { emitLaneDecisionEvents, type LaneMesh } from "../lib/lanes/mesh-events.js";
|
|
11
14
|
import { MAX_LANE_DEPTH, type LaneCompanyRef, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
|
|
@@ -16,17 +19,7 @@ import { spawnLaneWorker, type KillProcessFn, type ProveDetachedFn, type SpawnPr
|
|
|
16
19
|
export { spawnLaneWorker };
|
|
17
20
|
export { questionBodyAbsPath };
|
|
18
21
|
export { MAX_LANE_DEPTH };
|
|
19
|
-
export declare const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
20
|
-
export declare const OUTPOST_DIR = "/etc/outpost";
|
|
21
22
|
export declare const LANES_RUNS_REL: string;
|
|
22
|
-
export type OutpostIdentity = {
|
|
23
|
-
kind: "id";
|
|
24
|
-
id: string;
|
|
25
|
-
} | {
|
|
26
|
-
kind: "unknown";
|
|
27
|
-
} | {
|
|
28
|
-
kind: "not_outpost";
|
|
29
|
-
};
|
|
30
23
|
export interface SpawnLoginResult {
|
|
31
24
|
status: number;
|
|
32
25
|
}
|
|
@@ -79,7 +72,6 @@ export interface LanesCommandDeps {
|
|
|
79
72
|
locateTranscript?: (home: string, sessionId: string) => string | null;
|
|
80
73
|
monitorEvidence?: MonitorEvidenceReader;
|
|
81
74
|
}
|
|
82
|
-
export declare function readOutpostIdentity(existsSync?: (p: string) => boolean, readFileSync?: (p: string) => string): OutpostIdentity;
|
|
83
75
|
export declare function parseSeniorRef(raw: string): SeniorRef;
|
|
84
76
|
export declare function resolveAnswerChoice(choice: string, options: string[], freeform: boolean): string;
|
|
85
77
|
/**
|
package/dist/commands/lanes.js
CHANGED
|
@@ -12,13 +12,15 @@ import * as os from "node:os";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
14
14
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
15
|
+
import { readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
16
|
+
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
15
17
|
import { buildOutpostLaneCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
16
18
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
17
19
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
18
20
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
19
21
|
import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
|
|
20
22
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
21
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile,
|
|
23
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
22
24
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
23
25
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
24
26
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
@@ -33,8 +35,6 @@ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ON
|
|
|
33
35
|
export { spawnLaneWorker };
|
|
34
36
|
export { questionBodyAbsPath };
|
|
35
37
|
export { MAX_LANE_DEPTH };
|
|
36
|
-
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
37
|
-
export const OUTPOST_DIR = "/etc/outpost";
|
|
38
38
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
39
39
|
const VENDOR_LOGIN = {
|
|
40
40
|
claude: { command: "claude", args: ["auth", "login"] },
|
|
@@ -133,27 +133,6 @@ async function defaultFetchText(url) {
|
|
|
133
133
|
}
|
|
134
134
|
return res.text();
|
|
135
135
|
}
|
|
136
|
-
export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
|
|
137
|
-
const hasMarker = existsSync(OUTPOST_ID_PATH);
|
|
138
|
-
const hasDir = existsSync(OUTPOST_DIR);
|
|
139
|
-
if (hasMarker) {
|
|
140
|
-
let text;
|
|
141
|
-
try {
|
|
142
|
-
text = readFileSync(OUTPOST_ID_PATH);
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
146
|
-
throw err;
|
|
147
|
-
}
|
|
148
|
-
const id = text.trim();
|
|
149
|
-
if (!id)
|
|
150
|
-
return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
|
|
151
|
-
return { kind: "id", id };
|
|
152
|
-
}
|
|
153
|
-
if (hasDir)
|
|
154
|
-
return { kind: "unknown" };
|
|
155
|
-
return { kind: "not_outpost" };
|
|
156
|
-
}
|
|
157
136
|
function defaultSpawnLogin(opts) {
|
|
158
137
|
return new Promise((resolve, reject) => {
|
|
159
138
|
const child = spawn(opts.command, opts.args, {
|
|
@@ -263,11 +242,10 @@ function livenessDepsFromCtx(ctx) {
|
|
|
263
242
|
};
|
|
264
243
|
}
|
|
265
244
|
function reconcileHostFromCtx(ctx) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
};
|
|
245
|
+
return resolveAdmissionHost({
|
|
246
|
+
hostname: ctx.hostname,
|
|
247
|
+
readOutpostIdentity: ctx.readOutpostIdentity,
|
|
248
|
+
});
|
|
271
249
|
}
|
|
272
250
|
export function parseSeniorRef(raw) {
|
|
273
251
|
const trimmed = raw.trim();
|
|
@@ -1261,9 +1239,17 @@ export function applyOutpostAccountPickup(hqRoot, laneId, modelDeps = {}) {
|
|
|
1261
1239
|
try {
|
|
1262
1240
|
const resolved = resolveAccount(lane.provider, lane.account, config, {
|
|
1263
1241
|
hqRoot,
|
|
1242
|
+
accountDeps: {
|
|
1243
|
+
probe: modelDeps.probe,
|
|
1244
|
+
env: modelDeps.env,
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
const status = accountStatus(lane.provider, resolved.id, config, {
|
|
1248
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1249
|
+
probe: modelDeps.probe,
|
|
1250
|
+
env: modelDeps.env,
|
|
1264
1251
|
});
|
|
1265
|
-
|
|
1266
|
-
if (isLoggedOut(status)) {
|
|
1252
|
+
if (status.logged_in !== true) {
|
|
1267
1253
|
return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
|
|
1268
1254
|
provider: lane.provider,
|
|
1269
1255
|
account: resolved.id,
|
|
@@ -1549,8 +1535,10 @@ async function runCreate(ctx, opts) {
|
|
|
1549
1535
|
resolveAccount(provider, undefined, config, {
|
|
1550
1536
|
hqRoot,
|
|
1551
1537
|
existsSync: ctx.existsSync,
|
|
1538
|
+
accountDeps: { probe: ctx.probeProvider, env: ctx.env },
|
|
1552
1539
|
cliModel,
|
|
1553
1540
|
model: modelToValidate,
|
|
1541
|
+
preferredAccounts: config.providers[provider].preferred_accounts ?? [],
|
|
1554
1542
|
...(profileAccounts !== undefined
|
|
1555
1543
|
? { allowedAccounts: profileAccounts }
|
|
1556
1544
|
: {}),
|
|
@@ -1563,7 +1551,11 @@ async function runCreate(ctx, opts) {
|
|
|
1563
1551
|
.filter((id) => id !== candidate &&
|
|
1564
1552
|
accounts[id]?.enabled &&
|
|
1565
1553
|
(profileAccounts === undefined || profileAccounts.includes(id)) &&
|
|
1566
|
-
accountStatus(provider, id, config, {
|
|
1554
|
+
accountStatus(provider, id, config, {
|
|
1555
|
+
existsSync: ctx.existsSync,
|
|
1556
|
+
probe: ctx.probeProvider,
|
|
1557
|
+
env: ctx.env,
|
|
1558
|
+
}).logged_in === true)
|
|
1567
1559
|
.sort(),
|
|
1568
1560
|
];
|
|
1569
1561
|
const checkedAccounts = [];
|
|
@@ -1648,8 +1640,10 @@ async function runCreate(ctx, opts) {
|
|
|
1648
1640
|
pinnedAccount = resolveAccount(provider, undefined, config, {
|
|
1649
1641
|
hqRoot,
|
|
1650
1642
|
existsSync: ctx.existsSync,
|
|
1643
|
+
accountDeps: { probe: ctx.probeProvider, env: ctx.env },
|
|
1651
1644
|
cliModel,
|
|
1652
1645
|
model: cliModel ?? profileModel,
|
|
1646
|
+
preferredAccounts: config.providers[provider].preferred_accounts ?? [],
|
|
1653
1647
|
...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
|
|
1654
1648
|
}).id;
|
|
1655
1649
|
}
|
|
@@ -1797,6 +1791,7 @@ async function runCreate(ctx, opts) {
|
|
|
1797
1791
|
probe: ctx.probeProvider,
|
|
1798
1792
|
env: ctx.env,
|
|
1799
1793
|
},
|
|
1794
|
+
currentModelValidated: !outpostId && modelToValidate !== undefined,
|
|
1800
1795
|
existsSync: ctx.existsSync,
|
|
1801
1796
|
spawnProcess: ctx.spawnProcess,
|
|
1802
1797
|
proveDetached: ctx.proveDetached,
|
|
@@ -2196,6 +2191,7 @@ async function runPickup(ctx, opts) {
|
|
|
2196
2191
|
probe: ctx.probeProvider,
|
|
2197
2192
|
env: ctx.env,
|
|
2198
2193
|
},
|
|
2194
|
+
currentModelValidated: afterAccount.model !== undefined,
|
|
2199
2195
|
existsSync: ctx.existsSync,
|
|
2200
2196
|
spawnProcess: ctx.spawnProcess,
|
|
2201
2197
|
proveDetached: ctx.proveDetached,
|
|
@@ -2512,7 +2508,11 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
2512
2508
|
if (!targetCfg.enabled) {
|
|
2513
2509
|
throw new AccountResolutionError("account_disabled", `Account ${JSON.stringify(target)} is disabled${targetCfg.disabled_reason ? `: ${targetCfg.disabled_reason}` : ""}`, { provider: lane.provider, account: target, reason: targetCfg.disabled_reason ?? "" });
|
|
2514
2510
|
}
|
|
2515
|
-
if (
|
|
2511
|
+
if (accountStatus(lane.provider, target, config, {
|
|
2512
|
+
existsSync: ctx.existsSync,
|
|
2513
|
+
probe: ctx.probeProvider,
|
|
2514
|
+
env: ctx.env,
|
|
2515
|
+
}).logged_in !== true) {
|
|
2516
2516
|
throw new LanesError("account_not_logged_in", `Account ${target} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: target, lane_id: laneId });
|
|
2517
2517
|
}
|
|
2518
2518
|
const profile = lane.worker_profile !== undefined
|
|
@@ -3829,7 +3829,11 @@ async function runAccountsList(ctx, opts) {
|
|
|
3829
3829
|
// model and effort are what a spawn on each account would start from
|
|
3830
3830
|
// (spec: --model/--effort, then the account, then the provider).
|
|
3831
3831
|
const quota = loadQuotaState(hqRoot);
|
|
3832
|
-
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config
|
|
3832
|
+
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config, {
|
|
3833
|
+
existsSync: ctx.existsSync,
|
|
3834
|
+
probe: ctx.probeProvider,
|
|
3835
|
+
env: ctx.env,
|
|
3836
|
+
}).map((status) => {
|
|
3833
3837
|
const accountCfg = config.providers[provider].accounts[status.id];
|
|
3834
3838
|
const model = resolveLaneModel({
|
|
3835
3839
|
accountModel: accountCfg?.model,
|
|
@@ -3930,8 +3934,17 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
|
3930
3934
|
home,
|
|
3931
3935
|
enabled: true,
|
|
3932
3936
|
};
|
|
3937
|
+
const status = accountStatus(provider, id, config, {
|
|
3938
|
+
existsSync: ctx.existsSync,
|
|
3939
|
+
probe: ctx.probeProvider,
|
|
3940
|
+
env: ctx.env,
|
|
3941
|
+
});
|
|
3942
|
+
if (status.logged_in !== true) {
|
|
3943
|
+
throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
|
|
3944
|
+
? `Account ${id} for ${provider} is not logged in`
|
|
3945
|
+
: `Could not verify that account ${id} for ${provider} is logged in`, { provider, account: id, home });
|
|
3946
|
+
}
|
|
3933
3947
|
writeLanesConfig(hqRoot, config);
|
|
3934
|
-
const status = accountStatus(provider, id, loadLanesConfig(hqRoot));
|
|
3935
3948
|
printJson({ ok: true, action: "accounts.login", account: status });
|
|
3936
3949
|
}
|
|
3937
3950
|
async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
@@ -3948,13 +3961,27 @@ async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
|
3948
3961
|
.map(([id]) => id),
|
|
3949
3962
|
});
|
|
3950
3963
|
}
|
|
3964
|
+
const status = accountStatus(provider, accountId, config, {
|
|
3965
|
+
existsSync: ctx.existsSync,
|
|
3966
|
+
probe: ctx.probeProvider,
|
|
3967
|
+
env: ctx.env,
|
|
3968
|
+
});
|
|
3969
|
+
if (status.logged_in !== true) {
|
|
3970
|
+
throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
|
|
3971
|
+
? `Account ${accountId} for ${provider} is not logged in`
|
|
3972
|
+
: `Could not verify that account ${accountId} for ${provider} is logged in`, { provider, account: accountId });
|
|
3973
|
+
}
|
|
3951
3974
|
account.enabled = true;
|
|
3952
3975
|
delete account.disabled_reason;
|
|
3953
3976
|
writeLanesConfig(hqRoot, config);
|
|
3954
3977
|
printJson({
|
|
3955
3978
|
ok: true,
|
|
3956
3979
|
action: "accounts.enable",
|
|
3957
|
-
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)
|
|
3980
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
|
|
3981
|
+
existsSync: ctx.existsSync,
|
|
3982
|
+
probe: ctx.probeProvider,
|
|
3983
|
+
env: ctx.env,
|
|
3984
|
+
}),
|
|
3958
3985
|
});
|
|
3959
3986
|
}
|
|
3960
3987
|
async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
@@ -3978,7 +4005,11 @@ async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
|
3978
4005
|
printJson({
|
|
3979
4006
|
ok: true,
|
|
3980
4007
|
action: "accounts.disable",
|
|
3981
|
-
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)
|
|
4008
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
|
|
4009
|
+
existsSync: ctx.existsSync,
|
|
4010
|
+
probe: ctx.probeProvider,
|
|
4011
|
+
env: ctx.env,
|
|
4012
|
+
}),
|
|
3982
4013
|
});
|
|
3983
4014
|
}
|
|
3984
4015
|
async function runSessionsList(ctx, opts) {
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Naming an account and omitting one mean different things: an explicit
|
|
5
5
|
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
6
|
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
-
* derived from
|
|
8
|
-
* never reads, prints, copies, or hashes
|
|
7
|
+
* derived from credential-file existence or a provider status command; this
|
|
8
|
+
* module never reads, prints, copies, or hashes credential files.
|
|
9
9
|
*/
|
|
10
10
|
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
11
11
|
import type { LanesConfig } from "./types.js";
|
|
@@ -19,13 +19,22 @@ export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
|
|
|
19
19
|
* file here at all.
|
|
20
20
|
*/
|
|
21
21
|
export declare const PROVIDER_CREDENTIAL_FILES: Record<LaneProvider, readonly string[]>;
|
|
22
|
-
export type AuthState = "file" | "absent" | "unknown";
|
|
22
|
+
export type AuthState = "file" | "status" | "absent" | "unknown";
|
|
23
|
+
export interface AccountStatusProbeResult {
|
|
24
|
+
status: number | null;
|
|
25
|
+
stdout: string;
|
|
26
|
+
stderr: string;
|
|
27
|
+
}
|
|
23
28
|
export interface AccountDeps {
|
|
24
29
|
existsSync: (p: string) => boolean;
|
|
30
|
+
/** Provider status output is parsed for a boolean only; it is never printed. */
|
|
31
|
+
probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => AccountStatusProbeResult;
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
25
33
|
}
|
|
26
34
|
export interface AccountResolveContext {
|
|
27
35
|
hqRoot?: string;
|
|
28
36
|
existsSync?: (p: string) => boolean;
|
|
37
|
+
accountDeps?: Omit<AccountDeps, "existsSync">;
|
|
29
38
|
loadLanes?: (hqRoot: string) => LaneRecord[];
|
|
30
39
|
/** Already-resolved model for this lane. Beats cliModel and account defaults. */
|
|
31
40
|
model?: string;
|
|
@@ -38,6 +47,8 @@ export interface AccountResolveContext {
|
|
|
38
47
|
* set is never selected, so no refusal has to be decided under the lock.
|
|
39
48
|
*/
|
|
40
49
|
allowedAccounts?: readonly string[];
|
|
50
|
+
/** Ordered preferences; callers pass these only for a new lane's first assignment. */
|
|
51
|
+
preferredAccounts?: readonly string[];
|
|
41
52
|
}
|
|
42
53
|
export interface ResolvedAccount {
|
|
43
54
|
provider: LaneProvider;
|
|
@@ -54,13 +65,7 @@ export interface AccountStatus {
|
|
|
54
65
|
disabled_reason?: string;
|
|
55
66
|
/** Present in personal/lanes/config.json. */
|
|
56
67
|
configured: boolean;
|
|
57
|
-
/**
|
|
58
|
-
* True when a provider credential file exists. False only when every
|
|
59
|
-
* candidate is absent AND absence is conclusive for that provider
|
|
60
|
-
* (Codex, Grok). Null for Claude when no candidate exists: Claude can
|
|
61
|
-
* authenticate via keychain, so a missing file is not logged-out.
|
|
62
|
-
* Never inspects file contents.
|
|
63
|
-
*/
|
|
68
|
+
/** True/false comes from file existence or the provider status command. */
|
|
64
69
|
logged_in: boolean | null;
|
|
65
70
|
auth_state: AuthState;
|
|
66
71
|
envVar: string;
|
|
@@ -79,7 +84,7 @@ export declare function authStateFor(provider: LaneProvider, present: boolean):
|
|
|
79
84
|
export declare function loggedInFromAuthState(state: AuthState): boolean | null;
|
|
80
85
|
/** Admission and listing share this: refuse only a conclusive logged-out. */
|
|
81
86
|
export declare function isLoggedOut(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
82
|
-
/** Auto-select requires a
|
|
87
|
+
/** Auto-select requires a positive file or provider-status login result. */
|
|
83
88
|
export declare function isCredentialProven(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
84
89
|
/**
|
|
85
90
|
* Occupying states hold a slot: running / awaiting_input / blocked have a
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Naming an account and omitting one mean different things: an explicit
|
|
5
5
|
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
6
|
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
-
* derived from
|
|
8
|
-
* never reads, prints, copies, or hashes
|
|
7
|
+
* derived from credential-file existence or a provider status command; this
|
|
8
|
+
* module never reads, prints, copies, or hashes credential files.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
@@ -76,7 +76,7 @@ export function loggedInFromAuthState(state) {
|
|
|
76
76
|
export function isLoggedOut(status) {
|
|
77
77
|
return status.logged_in === false;
|
|
78
78
|
}
|
|
79
|
-
/** Auto-select requires a
|
|
79
|
+
/** Auto-select requires a positive file or provider-status login result. */
|
|
80
80
|
export function isCredentialProven(status) {
|
|
81
81
|
return status.logged_in === true;
|
|
82
82
|
}
|
|
@@ -205,8 +205,12 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
205
205
|
}
|
|
206
206
|
const proven = [];
|
|
207
207
|
for (const id of enabled) {
|
|
208
|
-
const
|
|
209
|
-
|
|
208
|
+
const status = accountStatus(provider, id, config, {
|
|
209
|
+
existsSync,
|
|
210
|
+
...(ctx.accountDeps?.probe ? { probe: ctx.accountDeps.probe } : {}),
|
|
211
|
+
...(ctx.accountDeps?.env ? { env: ctx.accountDeps.env } : {}),
|
|
212
|
+
});
|
|
213
|
+
if (status.logged_in === true)
|
|
210
214
|
proven.push(id);
|
|
211
215
|
}
|
|
212
216
|
if (proven.length === 0) {
|
|
@@ -235,6 +239,9 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
235
239
|
const cap = accountMaxRunning(providerCfg, id);
|
|
236
240
|
return runningLaneCount(lanes, provider, id) < cap;
|
|
237
241
|
});
|
|
242
|
+
const preferred = underCap.length > 0
|
|
243
|
+
? ctx.preferredAccounts?.find((id) => underCap.includes(id))
|
|
244
|
+
: undefined;
|
|
238
245
|
const pool = underCap.length > 0 ? underCap : runnable;
|
|
239
246
|
const ranked = pool
|
|
240
247
|
.map((id) => ({
|
|
@@ -243,7 +250,7 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
243
250
|
lastStarted: lastStartedAt(lanes, provider, id),
|
|
244
251
|
}))
|
|
245
252
|
.sort(compareEligible);
|
|
246
|
-
const chosen = ranked[0].id;
|
|
253
|
+
const chosen = preferred ?? ranked[0].id;
|
|
247
254
|
return boundAccount(provider, chosen, providerCfg.accounts[chosen]);
|
|
248
255
|
}
|
|
249
256
|
export function resolveAccount(provider, requestedId, config, ctx = {}) {
|
|
@@ -293,14 +300,28 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
293
300
|
};
|
|
294
301
|
}
|
|
295
302
|
const present = hasCredentialFile(provider, account.home, deps.existsSync);
|
|
296
|
-
|
|
303
|
+
let auth_state = authStateFor(provider, present);
|
|
304
|
+
let logged_in = loggedInFromAuthState(auth_state);
|
|
305
|
+
if (!present && provider === "claude" && deps.probe !== undefined) {
|
|
306
|
+
try {
|
|
307
|
+
const result = deps.probe("claude", ["auth", "status"], { ...(deps.env ?? process.env), CLAUDE_CONFIG_DIR: account.home });
|
|
308
|
+
const providerStatus = claudeLoginStatus(result);
|
|
309
|
+
if (providerStatus !== null) {
|
|
310
|
+
auth_state = "status";
|
|
311
|
+
logged_in = providerStatus;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
process.stderr.write(`hq lanes: Claude auth status probe failed for account ${accountId}\n`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
297
318
|
const status = {
|
|
298
319
|
provider,
|
|
299
320
|
id: accountId,
|
|
300
321
|
home: account.home,
|
|
301
322
|
enabled: account.enabled,
|
|
302
323
|
configured: true,
|
|
303
|
-
logged_in
|
|
324
|
+
logged_in,
|
|
304
325
|
auth_state,
|
|
305
326
|
envVar,
|
|
306
327
|
};
|
|
@@ -312,6 +333,27 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
312
333
|
export function listAccountStatuses(provider, config, deps = defaultAccountDeps()) {
|
|
313
334
|
return Object.keys(config.providers[provider].accounts).map((id) => accountStatus(provider, id, config, deps));
|
|
314
335
|
}
|
|
336
|
+
function claudeLoginStatus(result) {
|
|
337
|
+
const text = result.stdout.trim();
|
|
338
|
+
if (text.startsWith("{")) {
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(text);
|
|
341
|
+
if (parsed !== null &&
|
|
342
|
+
typeof parsed === "object" &&
|
|
343
|
+
!Array.isArray(parsed) &&
|
|
344
|
+
typeof parsed.loggedIn === "boolean") {
|
|
345
|
+
return parsed.loggedIn;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
process.stderr.write(`hq lanes: Claude auth status returned invalid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (/\bnot logged in\b/i.test(`${result.stdout}\n${result.stderr}`))
|
|
354
|
+
return false;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
315
357
|
/** Environment assignment the worker process should receive. */
|
|
316
358
|
export function accountEnv(resolved) {
|
|
317
359
|
return { [resolved.envVar]: resolved.home };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AdmissionHost } from "./admission.js";
|
|
2
|
+
export declare const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
3
|
+
export declare const OUTPOST_DIR = "/etc/outpost";
|
|
4
|
+
export type OutpostIdentity = {
|
|
5
|
+
kind: "id";
|
|
6
|
+
id: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "unknown";
|
|
9
|
+
} | {
|
|
10
|
+
kind: "not_outpost";
|
|
11
|
+
};
|
|
12
|
+
export declare function readOutpostIdentity(existsSync?: (p: string) => boolean, readFileSync?: (p: string) => string): OutpostIdentity;
|
|
13
|
+
export declare function resolveAdmissionHost(deps?: {
|
|
14
|
+
hostname?: () => string;
|
|
15
|
+
readOutpostIdentity?: () => OutpostIdentity;
|
|
16
|
+
existsSync?: (p: string) => boolean;
|
|
17
|
+
readFileSync?: (p: string) => string;
|
|
18
|
+
}): AdmissionHost;
|
|
19
|
+
//# sourceMappingURL=admission-host.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
4
|
+
export const OUTPOST_DIR = "/etc/outpost";
|
|
5
|
+
export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
|
|
6
|
+
const hasMarker = existsSync(OUTPOST_ID_PATH);
|
|
7
|
+
const hasDir = existsSync(OUTPOST_DIR);
|
|
8
|
+
if (hasMarker) {
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = readFileSync(OUTPOST_ID_PATH);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
const id = text.trim();
|
|
18
|
+
if (!id)
|
|
19
|
+
return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
|
|
20
|
+
return { kind: "id", id };
|
|
21
|
+
}
|
|
22
|
+
if (hasDir)
|
|
23
|
+
return { kind: "unknown" };
|
|
24
|
+
return { kind: "not_outpost" };
|
|
25
|
+
}
|
|
26
|
+
export function resolveAdmissionHost(deps = {}) {
|
|
27
|
+
const identity = deps.readOutpostIdentity?.() ?? readOutpostIdentity(deps.existsSync, deps.readFileSync);
|
|
28
|
+
return {
|
|
29
|
+
hostname: (deps.hostname ?? os.hostname)(),
|
|
30
|
+
outpostId: identity.kind === "id" ? identity.id : null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=admission-host.js.map
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -173,6 +173,29 @@ function parseFastModeModels(value, field) {
|
|
|
173
173
|
}
|
|
174
174
|
return value.map((model) => model.trim());
|
|
175
175
|
}
|
|
176
|
+
function parsePreferredAccounts(provider, value, accounts) {
|
|
177
|
+
const field = `providers.${provider}.preferred_accounts`;
|
|
178
|
+
if (value === undefined)
|
|
179
|
+
return [];
|
|
180
|
+
if (!Array.isArray(value) ||
|
|
181
|
+
value.some((id) => typeof id !== "string" ||
|
|
182
|
+
id.trim().length === 0 ||
|
|
183
|
+
id.trim() !== id)) {
|
|
184
|
+
throw new LanesConfigError("invalid_preferred_accounts", `${field} must be an array of non-empty account ids without surrounding whitespace`, { provider, field });
|
|
185
|
+
}
|
|
186
|
+
const preferred = value;
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
for (const id of preferred) {
|
|
189
|
+
if (seen.has(id)) {
|
|
190
|
+
throw new LanesConfigError("invalid_preferred_accounts", `${field} contains duplicate account ${JSON.stringify(id)}`, { provider, field, account: id });
|
|
191
|
+
}
|
|
192
|
+
if (accounts[id] === undefined) {
|
|
193
|
+
throw new LanesConfigError("preferred_account_unconfigured", `${field} names unconfigured account ${JSON.stringify(id)}`, { provider, field, account: id, accounts: Object.keys(accounts) });
|
|
194
|
+
}
|
|
195
|
+
seen.add(id);
|
|
196
|
+
}
|
|
197
|
+
return [...preferred];
|
|
198
|
+
}
|
|
176
199
|
function rejectDuplicateHomes(provider, accounts) {
|
|
177
200
|
const seen = new Map();
|
|
178
201
|
for (const [id, account] of Object.entries(accounts)) {
|
|
@@ -232,6 +255,7 @@ function parseProvider(provider, raw, deps) {
|
|
|
232
255
|
max_running: positiveInt(raw.max_running, `providers.${provider}.max_running`, DEFAULT_PROVIDER_MAX_RUNNING[provider]),
|
|
233
256
|
max_queued: positiveInt(raw.max_queued, `providers.${provider}.max_queued`, DEFAULT_MAX_QUEUED),
|
|
234
257
|
accounts,
|
|
258
|
+
preferred_accounts: parsePreferredAccounts(provider, raw.preferred_accounts, accounts),
|
|
235
259
|
};
|
|
236
260
|
if (provider === "codex") {
|
|
237
261
|
parsed.fast_mode_models = parseFastModeModels(raw.fast_mode_models, `providers.${provider}.fast_mode_models`);
|
|
@@ -301,6 +325,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
|
301
325
|
max_running: DEFAULT_PROVIDER_MAX_RUNNING[provider],
|
|
302
326
|
max_queued: DEFAULT_MAX_QUEUED,
|
|
303
327
|
accounts: defaultAccountMap(provider, deps),
|
|
328
|
+
preferred_accounts: [],
|
|
304
329
|
};
|
|
305
330
|
if (provider === "codex")
|
|
306
331
|
providerConfig.fast_mode_models = [];
|
|
@@ -37,6 +37,7 @@ export interface ProviderRejection {
|
|
|
37
37
|
/** Which event on the stream carried the failure. */
|
|
38
38
|
event: "codex.turn.failed" | "claude.result.is_error" | "grok.error";
|
|
39
39
|
}
|
|
40
|
+
export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
|
|
40
41
|
export declare function capMessage(message: string): string;
|
|
41
42
|
/**
|
|
42
43
|
* Whether a later run could succeed without the operator changing anything.
|
|
@@ -56,10 +57,9 @@ export declare function retryableFromStatus(status: number | null): boolean;
|
|
|
56
57
|
export declare function parseCodexTurnFailed(text: string): ProviderRejection | null;
|
|
57
58
|
/**
|
|
58
59
|
* Claude `-p --output-format json`: the last `type: "result"` object with
|
|
59
|
-
* `is_error: true` and a numeric `api_error_status
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* existing classification.
|
|
60
|
+
* `is_error: true` and a numeric `api_error_status`, or the CLI's explicit
|
|
61
|
+
* unauthenticated message. Other status-less errors keep the existing
|
|
62
|
+
* classification because they may be quota text or a worker failure.
|
|
63
63
|
*/
|
|
64
64
|
export declare function parseClaudeResultRejection(text: string): ProviderRejection | null;
|
|
65
65
|
/**
|
|
@@ -24,6 +24,25 @@ export const PROVIDER_REJECTED_REASON = "provider_rejected";
|
|
|
24
24
|
export const PROVIDER_REJECTION_MESSAGE_CAP = 2000;
|
|
25
25
|
/** Status the watcher routes to the quota path instead of `provider_rejected`. */
|
|
26
26
|
export const QUOTA_STATUS = 429;
|
|
27
|
+
/** Exact auth failures observed from each installed CLI in an empty home. */
|
|
28
|
+
function isProviderLoginMessage(provider, message) {
|
|
29
|
+
switch (provider) {
|
|
30
|
+
case "claude":
|
|
31
|
+
return /\bnot logged in\b/i.test(message);
|
|
32
|
+
case "codex":
|
|
33
|
+
return /\bnot logged in\b/i.test(message) ||
|
|
34
|
+
/\bmissing bearer or basic authentication in header\b/i.test(message);
|
|
35
|
+
case "grok":
|
|
36
|
+
return /^\s*not signed in\b/i.test(message);
|
|
37
|
+
default: {
|
|
38
|
+
const _exhaustive = provider;
|
|
39
|
+
return _exhaustive;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function isProviderLoginFailure(failure) {
|
|
44
|
+
return failure !== null && isProviderLoginMessage(failure.provider, failure.message);
|
|
45
|
+
}
|
|
27
46
|
function isPlainObject(value) {
|
|
28
47
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
29
48
|
}
|
|
@@ -134,10 +153,9 @@ export function parseCodexTurnFailed(text) {
|
|
|
134
153
|
}
|
|
135
154
|
/**
|
|
136
155
|
* Claude `-p --output-format json`: the last `type: "result"` object with
|
|
137
|
-
* `is_error: true` and a numeric `api_error_status
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* existing classification.
|
|
156
|
+
* `is_error: true` and a numeric `api_error_status`, or the CLI's explicit
|
|
157
|
+
* unauthenticated message. Other status-less errors keep the existing
|
|
158
|
+
* classification because they may be quota text or a worker failure.
|
|
141
159
|
*/
|
|
142
160
|
export function parseClaudeResultRejection(text) {
|
|
143
161
|
// The last `result` decides: a success or a status-less error there is
|
|
@@ -148,9 +166,9 @@ export function parseClaudeResultRejection(text) {
|
|
|
148
166
|
if (rec.is_error !== true)
|
|
149
167
|
return NOT_REJECTED;
|
|
150
168
|
const status = statusOf(rec.api_error_status);
|
|
151
|
-
if (status === null)
|
|
152
|
-
return NOT_REJECTED;
|
|
153
169
|
const message = typeof rec.result === "string" ? rec.result : "";
|
|
170
|
+
if (status === null && !isProviderLoginMessage("claude", message))
|
|
171
|
+
return NOT_REJECTED;
|
|
154
172
|
return {
|
|
155
173
|
reason: PROVIDER_REJECTED_REASON,
|
|
156
174
|
provider: "claude",
|