@indigoai-us/hq-cli 5.345.124 → 5.345.126

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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.126] — 2026-10-14
6
+ - `hq lanes` resume no longer picks a stale transcript when the same session exists in more than one account home. It uses the copy from the home the lane last ran in, and otherwise refuses with `provider_transcript_ambiguous` naming the homes.
7
+
8
+ ## [5.345.125] — 2026-10-14
9
+ - `hq lanes` no longer sets `CLAUDE_CONFIG_DIR` for a Claude account whose
10
+ home is the default `~/.claude`, so lane workers, `claude auth status`
11
+ probes, and `hq lanes accounts login` read the same keychain credential as
12
+ the interactive `claude` session. `hq lanes accounts list` now reports each
13
+ Claude account's email and org, the interactive login, and
14
+ `credential_matches_interactive` with a warning when they differ.
15
+ - A single provider 429 now holds an account for `quota_default_hold_ms`
16
+ (15 minutes by default) instead of the provider's multi-day reset. The
17
+ provider's reset is kept as `provider_reset_at` and becomes the hold only
18
+ when a fresh probe is refused again before it. The structured `resetsAt`
19
+ on a Claude result is used when present, and "resets Oct 16 at 1am
20
+ (America/Denver)" style messages (the `at` form and IANA zones) parse
21
+ correctly. Quota refusals name the account's email and org and print the
22
+ `hq lanes accounts clear-quota` command.
23
+ - `hq lanes` resumes a lane that ran in a worktree. Spawn records the
24
+ directory each run started in and resumes there, and when a Claude
25
+ transcript is not under the expected project directory, the resume scans
26
+ every project directory in each account home before refusing. A truly
27
+ missing transcript still fails, and the error lists the directories scanned.
28
+ - Lead autopilot no longer starts worker lanes on codex with a fixed
29
+ `gpt-6-luna` model. The worker follows the same engine choice as
30
+ `hq lanes create` (the lead lane's provider, then the harness markers) and
31
+ that provider's default model and effort.
32
+
5
33
  ## [5.345.124] — 2026-10-14
6
34
  - Lanes no longer stop every lane on an account for days after a single weekly-limit 429. Reset times such as "resets Oct 16 at 1am (America/Denver)" are now read with their time and timezone. A reset more than a day away first gets only a short hold, and the full hold applies only if the next lane after that also gets a 429. When a hold is set, the lane's senior gets a message with the provider's text and `hq lanes accounts clear-quota <provider> <account>`.
7
35
 
@@ -4,7 +4,7 @@ import { buildOutpostBalanceMoveCommand, dispatchErrorMessage } from "../lib/lan
4
4
  import { BALANCE_MOVABLE_STATES, accountLoadsForReport, planClaudeBalanceMoves, selectClaudeAccount } from "../lib/lanes/claude-balance.js";
5
5
  import { CompanySelectionError } from "../utils/company-selection-error.js";
6
6
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
7
- import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, PROVIDER_HOME_ENV, accountStatus, canonicalizeHome, installDrainHook, isLanesError, listAccountStatuses, loadLanesConfig, parseLaneProvider, readLane, writeLanesConfig } from "../lib/lanes/index.js";
7
+ import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, accountStatus, canonicalizeHome, claudeIdentityDiffers, installDrainHook, isLanesError, listAccountStatuses, loadLanesConfig, parseLaneProvider, probeInteractiveClaudeIdentity, readLane, withProviderHomeEnv, writeLanesConfig } from "../lib/lanes/index.js";
8
8
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
9
9
  import { resolveLaneEffort } from "../lib/lanes/effort.js";
10
10
  import { LanesError } from "../lib/lanes/errors.js";
@@ -113,9 +113,16 @@ export function createLaneAccountsHandlers(deps) {
113
113
  // model and effort are what a spawn on each account would start from
114
114
  // (spec: --model/--effort, then the account, then the provider).
115
115
  const quota = loadQuotaState(hqRoot);
116
+ // The login the interactive `claude` uses (no CLAUDE_CONFIG_DIR), so a
117
+ // lane account that resolves to a different keychain credential is
118
+ // visible here instead of only as a 429 on the wrong subscription.
119
+ const interactiveIdentity = providers.includes("claude") && ctx.probeProvider
120
+ ? probeInteractiveClaudeIdentity(ctx.probeProvider, ctx.env)
121
+ : null;
116
122
  const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config, {
117
123
  existsSync: ctx.existsSync,
118
124
  hostname: ctx.hostname(),
125
+ homedir: ctx.homedir,
119
126
  probe: ctx.probeProvider,
120
127
  env: ctx.env,
121
128
  }).map((status) => {
@@ -129,6 +136,9 @@ export function createLaneAccountsHandlers(deps) {
129
136
  providerEffort: config.providers[provider].effort,
130
137
  model,
131
138
  });
139
+ const differs = provider === "claude"
140
+ ? claudeIdentityDiffers(status.identity, interactiveIdentity)
141
+ : null;
132
142
  return {
133
143
  ...status,
134
144
  effective_home: status.home,
@@ -136,6 +146,13 @@ export function createLaneAccountsHandlers(deps) {
136
146
  error: "home_missing",
137
147
  fix_command: `hq lanes accounts set-home ${provider} ${status.id} <host-home>`,
138
148
  } : {}),
149
+ ...(provider === "claude" ? {
150
+ identity: status.identity ?? null,
151
+ credential_matches_interactive: differs === null ? null : !differs,
152
+ ...(differs === true ? {
153
+ warning: `Lane credential ${status.identity?.email ?? status.identity?.org_id ?? "(unknown)"} differs from the interactive claude login ${interactiveIdentity?.email ?? interactiveIdentity?.org_id ?? "(unknown)"}; lanes on this account bill that other subscription`,
154
+ } : {}),
155
+ } : {}),
139
156
  model: accountCfg?.model ?? null,
140
157
  effort: accountCfg?.effort ?? null,
141
158
  effective_model: model ?? null,
@@ -154,6 +171,7 @@ export function createLaneAccountsHandlers(deps) {
154
171
  ok: true,
155
172
  action: "accounts.list",
156
173
  providers: providerDefaults,
174
+ ...(providers.includes("claude") ? { interactive_identity: interactiveIdentity } : {}),
157
175
  accounts,
158
176
  });
159
177
  }
@@ -387,15 +405,16 @@ export function createLaneAccountsHandlers(deps) {
387
405
  });
388
406
  const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
389
407
  installDrainHook(provider, home);
390
- const envVar = PROVIDER_HOME_ENV[provider];
391
408
  const login = VENDOR_LOGIN[provider];
392
409
  const loginArgs = provider === "codex" && opts.deviceAuth
393
410
  ? [...login.args, "--device-auth"]
394
411
  : login.args;
412
+ // Same rule as the worker spawn: the default Claude home logs in with
413
+ // CLAUDE_CONFIG_DIR unset so the credential matches the interactive one.
395
414
  const result = await ctx.spawnLogin({
396
415
  command: login.command,
397
416
  args: loginArgs,
398
- env: { ...ctx.env, [envVar]: home },
417
+ env: withProviderHomeEnv(ctx.env, provider, home, ctx.homedir),
399
418
  cwd: home,
400
419
  });
401
420
  if (result.status !== 0) {
@@ -2331,6 +2331,27 @@ function orchestratorEngine(hqRoot, senior, ctx) {
2331
2331
  return "claude";
2332
2332
  return undefined;
2333
2333
  }
2334
+ /**
2335
+ * Starts the worker lane a lead autopilot fill-grant wake asks for. Provider,
2336
+ * model and effort are left to `runCreate`, so the worker follows the
2337
+ * orchestrator engine (the lead lane provider, then the harness markers) and
2338
+ * that provider default model, and fails with `provider_required` when no
2339
+ * engine is known. There is no hard-coded engine.
2340
+ */
2341
+ function autopilotWorkerStarter(ctx) {
2342
+ return async ({ lead, story }) => {
2343
+ const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
2344
+ const started = await runCreate(workerCtx, {
2345
+ company: lead.company?.slug,
2346
+ project: lead.project_id,
2347
+ story: story.id,
2348
+ briefFile: story.briefPath,
2349
+ senior: "lane:" + lead.lane_id,
2350
+ quiet: true,
2351
+ });
2352
+ return { lane_id: started.lane_id };
2353
+ };
2354
+ }
2334
2355
  function defaultSenior(ctx) {
2335
2356
  const parent = ctx.env.HQ_PARENT_SESSION_ID?.trim();
2336
2357
  if (parent)
@@ -7389,7 +7410,7 @@ async function prepareAccountMove(ctx, lane, input) {
7389
7410
  });
7390
7411
  const block = quotaBlockFor(loadQuotaState(hqRoot), lane.provider, target, model, now);
7391
7412
  if (block.blocked) {
7392
- throw new LanesError("account_quota_held", `Account ${target} for ${lane.provider} is held${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}${block.marker ? ` (${block.marker})` : ""}`, { provider: lane.provider, account: target, quota_reset_at: block.quota_reset_at ?? null, marker: block.marker ?? null, model: model ?? null });
7413
+ throw new LanesError("account_quota_held", `Account ${target} for ${lane.provider} is held${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}${block.marker ? ` (${block.marker})` : ""}. Clear the hold with: hq lanes accounts clear-quota ${lane.provider} ${target}`, { provider: lane.provider, account: target, quota_reset_at: block.quota_reset_at ?? null, marker: block.marker ?? null, model: model ?? null, clear_command: `hq lanes accounts clear-quota ${lane.provider} ${target}` });
7393
7414
  }
7394
7415
  if (model !== undefined) {
7395
7416
  validateLaneModel(lane.provider, model, targetHome, {
@@ -7407,7 +7428,7 @@ async function prepareAccountMove(ctx, lane, input) {
7407
7428
  const copy = ensureSessionHome({
7408
7429
  provider: lane.provider,
7409
7430
  sessionId,
7410
- cwd: hqRoot,
7431
+ cwd: lane.working_directory_at_spawn ?? hqRoot,
7411
7432
  targetHome,
7412
7433
  registeredHomes: Object.values(providerCfg.accounts).map((account) => effectiveAccountHome(account, ctx.hostname()).home),
7413
7434
  providerHomeAtSpawn: lane.provider_home_at_spawn ?? sourceHome,
@@ -9399,21 +9420,7 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
9399
9420
  dryRun: Boolean(opts.dryRun),
9400
9421
  readFlag: readLanesRegistryFlag,
9401
9422
  keepAliveResumedLeads: new Set(summary.resumed.filter((entry) => entry.reason === "keep_alive").map((entry) => entry.id)),
9402
- startWorker: async ({ lead, story }) => {
9403
- const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
9404
- const started = await runCreate(workerCtx, {
9405
- company: lead.company?.slug,
9406
- project: lead.project_id,
9407
- story: story.id,
9408
- briefFile: story.briefPath,
9409
- provider: "codex",
9410
- model: "gpt-6-luna",
9411
- effort: "high",
9412
- senior: `lane:${lead.lane_id}`,
9413
- quiet: true,
9414
- });
9415
- return { lane_id: started.lane_id };
9416
- },
9423
+ startWorker: autopilotWorkerStarter(ctx),
9417
9424
  });
9418
9425
  }
9419
9426
  catch (error) {
@@ -10446,21 +10453,7 @@ export function registerLanesCommand(parent, deps = {}) {
10446
10453
  now: ctx.now(),
10447
10454
  dryRun: Boolean(opts.dryRun),
10448
10455
  readFlag: readLanesRegistryFlag,
10449
- startWorker: async ({ lead, story }) => {
10450
- const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
10451
- const started = await runCreate(workerCtx, {
10452
- company: lead.company?.slug,
10453
- project: lead.project_id,
10454
- story: story.id,
10455
- briefFile: story.briefPath,
10456
- provider: "codex",
10457
- model: "gpt-6-luna",
10458
- effort: "high",
10459
- senior: `lane:${lead.lane_id}`,
10460
- quiet: true,
10461
- });
10462
- return { lane_id: started.lane_id };
10463
- },
10456
+ startWorker: autopilotWorkerStarter(ctx),
10464
10457
  });
10465
10458
  printJson(result);
10466
10459
  }));
@@ -10,6 +10,45 @@
10
10
  import { type LaneRecord, type LaneProvider } from "./types.js";
11
11
  import type { LanesConfig } from "./types.js";
12
12
  export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
13
+ /**
14
+ * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
15
+ * the variable unset it reads the plain `Claude Code-credentials` item, and
16
+ * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
17
+ * for that path. Setting the variable for the default home therefore makes a
18
+ * lane use a different login than the interactive session (incident
19
+ * 2026-10-13). A Claude account whose home is the default is launched with
20
+ * the variable unset instead.
21
+ */
22
+ export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
23
+ /**
24
+ * Return a copy of `base` with the provider home variable set to `home`,
25
+ * or, for a Claude account on the default home, with the variable removed
26
+ * so the worker and the interactive session share one credential.
27
+ */
28
+ export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
29
+ /** Who `claude auth status` says a home is logged in as. Never a token. */
30
+ export interface ClaudeIdentity {
31
+ email: string | null;
32
+ org_id: string | null;
33
+ org_name: string | null;
34
+ subscription_type: string | null;
35
+ }
36
+ /**
37
+ * Email and org from a `claude auth status` JSON payload. Only identity
38
+ * fields are kept; everything else in the payload is dropped.
39
+ */
40
+ export declare function claudeIdentityFromProbe(result: AccountStatusProbeResult): ClaudeIdentity | null;
41
+ /** "me@example.com (Acme)" label for hold and failover messages. */
42
+ export declare function describeClaudeIdentity(identity: ClaudeIdentity | undefined | null): string | null;
43
+ /**
44
+ * The identity the interactive session uses: `claude auth status` with no
45
+ * `CLAUDE_CONFIG_DIR`. Null when the probe fails or names nobody.
46
+ */
47
+ export declare function probeInteractiveClaudeIdentity(probe: NonNullable<AccountDeps["probe"]>, base?: NodeJS.ProcessEnv): ClaudeIdentity | null;
48
+ /** True when both identities are known and name different logins; null when unknown. */
49
+ export declare function claudeIdentityDiffers(a: ClaudeIdentity | undefined | null, b: ClaudeIdentity | undefined | null): boolean | null;
50
+ /** The operator command that lifts a recorded quota hold. */
51
+ export declare function clearQuotaCommand(provider: LaneProvider, accountId: string): string;
13
52
  export declare const AUTH_JSON_NAME = "auth.json";
14
53
  export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
15
54
  /**
@@ -28,6 +67,7 @@ export interface AccountStatusProbeResult {
28
67
  export interface AccountDeps {
29
68
  existsSync: (p: string) => boolean;
30
69
  hostname?: string;
70
+ homedir?: () => string;
31
71
  /** Provider status output is parsed for a boolean only; it is never printed. */
32
72
  probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => AccountStatusProbeResult;
33
73
  env?: NodeJS.ProcessEnv;
@@ -64,6 +104,8 @@ export interface AccountStatus {
64
104
  id: string;
65
105
  home: string;
66
106
  home_source?: "host" | "default";
107
+ /** Email and org from the provider status command, when it was consulted. */
108
+ identity?: ClaudeIdentity;
67
109
  enabled: boolean;
68
110
  disabled_reason?: string;
69
111
  /** Present in personal/lanes/config.json. */
@@ -112,6 +154,10 @@ export declare function resolveAccount(provider: LaneProvider, requestedId: stri
112
154
  export declare function authJsonPath(home: string): string;
113
155
  export declare function accountStatus(provider: LaneProvider, accountId: string, config: LanesConfig, deps?: AccountDeps): AccountStatus;
114
156
  export declare function listAccountStatuses(provider: LaneProvider, config: LanesConfig, deps?: AccountDeps): AccountStatus[];
115
- /** Environment assignment the worker process should receive. */
116
- export declare function accountEnv(resolved: ResolvedAccount): Record<string, string>;
157
+ /**
158
+ * Environment assignment the worker process should receive. Empty for a
159
+ * Claude account on the default home, which runs with the variable unset
160
+ * (see isDefaultProviderHome).
161
+ */
162
+ export declare function accountEnv(resolved: ResolvedAccount, homedir?: () => string): Record<string, string>;
117
163
  //# sourceMappingURL=accounts.d.ts.map
@@ -8,11 +8,12 @@
8
8
  * module never reads, prints, copies, or hashes credential files.
9
9
  */
10
10
  import * as fs from "node:fs";
11
+ import * as os from "node:os";
11
12
  import { effectiveAccountHome } from "./account-home.js";
12
13
  import * as path from "node:path";
13
14
  import { AccountResolutionError } from "./errors.js";
14
15
  import { isTerminalLaneState } from "./types.js";
15
- import { accountMaxRunning } from "./config.js";
16
+ import { DEFAULT_ACCOUNT_HOMES, accountMaxRunning, canonicalizeHome } from "./config.js";
16
17
  import { listLaneIds, readLane } from "./store.js";
17
18
  import { loadQuotaState, quotaBlockFor } from "./quota.js";
18
19
  export const PROVIDER_HOME_ENV = {
@@ -20,6 +21,112 @@ export const PROVIDER_HOME_ENV = {
20
21
  codex: "CODEX_HOME",
21
22
  grok: "GROK_HOME",
22
23
  };
24
+ /**
25
+ * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
26
+ * the variable unset it reads the plain `Claude Code-credentials` item, and
27
+ * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
28
+ * for that path. Setting the variable for the default home therefore makes a
29
+ * lane use a different login than the interactive session (incident
30
+ * 2026-10-13). A Claude account whose home is the default is launched with
31
+ * the variable unset instead.
32
+ */
33
+ export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
34
+ const deps = {
35
+ homedir,
36
+ realpathSync: (p) => fs.realpathSync(p),
37
+ existsSync: (p) => fs.existsSync(p),
38
+ };
39
+ try {
40
+ return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Return a copy of `base` with the provider home variable set to `home`,
48
+ * or, for a Claude account on the default home, with the variable removed
49
+ * so the worker and the interactive session share one credential.
50
+ */
51
+ export function withProviderHomeEnv(base, provider, home, homedir) {
52
+ const next = { ...base };
53
+ const envVar = PROVIDER_HOME_ENV[provider];
54
+ if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
55
+ delete next[envVar];
56
+ }
57
+ else {
58
+ next[envVar] = home;
59
+ }
60
+ return next;
61
+ }
62
+ function optionalString(value) {
63
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
64
+ }
65
+ /**
66
+ * Email and org from a `claude auth status` JSON payload. Only identity
67
+ * fields are kept; everything else in the payload is dropped.
68
+ */
69
+ export function claudeIdentityFromProbe(result) {
70
+ const text = result.stdout.trim();
71
+ if (!text.startsWith("{"))
72
+ return null;
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
81
+ return null;
82
+ const rec = parsed;
83
+ const identity = {
84
+ email: optionalString(rec.email),
85
+ org_id: optionalString(rec.orgId),
86
+ org_name: optionalString(rec.orgName),
87
+ subscription_type: optionalString(rec.subscriptionType),
88
+ };
89
+ if (!identity.email && !identity.org_id && !identity.org_name)
90
+ return null;
91
+ return identity;
92
+ }
93
+ /** "me@example.com (Acme)" label for hold and failover messages. */
94
+ export function describeClaudeIdentity(identity) {
95
+ if (!identity)
96
+ return null;
97
+ const org = identity.org_name ?? identity.org_id;
98
+ if (identity.email && org)
99
+ return `${identity.email} (${org})`;
100
+ return identity.email ?? org;
101
+ }
102
+ /**
103
+ * The identity the interactive session uses: `claude auth status` with no
104
+ * `CLAUDE_CONFIG_DIR`. Null when the probe fails or names nobody.
105
+ */
106
+ export function probeInteractiveClaudeIdentity(probe, base = process.env) {
107
+ const bare = { ...base };
108
+ delete bare[PROVIDER_HOME_ENV.claude];
109
+ try {
110
+ return claudeIdentityFromProbe(probe("claude", ["auth", "status"], bare));
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ /** True when both identities are known and name different logins; null when unknown. */
117
+ export function claudeIdentityDiffers(a, b) {
118
+ if (!a || !b)
119
+ return null;
120
+ if (a.email && b.email)
121
+ return a.email.toLowerCase() !== b.email.toLowerCase();
122
+ if (a.org_id && b.org_id)
123
+ return a.org_id !== b.org_id;
124
+ return null;
125
+ }
126
+ /** The operator command that lifts a recorded quota hold. */
127
+ export function clearQuotaCommand(provider, accountId) {
128
+ return `hq lanes accounts clear-quota ${provider} ${accountId}`;
129
+ }
23
130
  export const AUTH_JSON_NAME = "auth.json";
24
131
  export const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
25
132
  /**
@@ -161,23 +268,34 @@ function modelForAccount(provider, accountId, config, ctx) {
161
268
  return providerModel.trim();
162
269
  return undefined;
163
270
  }
164
- function throwIfQuotaBlocked(block, provider, accountId) {
271
+ /** "account "a" (me@example.com (Acme))" when the identity is known. */
272
+ function accountLabel(accountId, identity) {
273
+ const who = describeClaudeIdentity(identity);
274
+ return `Account ${JSON.stringify(accountId)}${who ? ` (${who})` : ""}`;
275
+ }
276
+ function throwIfQuotaBlocked(block, provider, accountId, identity) {
165
277
  if (!block.blocked || !block.reason)
166
278
  return;
167
279
  const model = block.model ?? "(no model)";
280
+ const label = accountLabel(accountId, identity);
281
+ const identityFields = identity ? { identity } : {};
168
282
  if (block.reason === "account_model_unavailable") {
169
- throw new AccountResolutionError("account_model_unavailable", `Account ${JSON.stringify(accountId)} cannot run model ${model}: ${block.marker ?? "needs credits"}`, {
283
+ throw new AccountResolutionError("account_model_unavailable", `${label} cannot run model ${model}: ${block.marker ?? "needs credits"}`, {
170
284
  provider,
171
285
  account: accountId,
172
286
  model: block.model ?? null,
173
287
  marker: block.marker ?? null,
288
+ ...identityFields,
174
289
  });
175
290
  }
176
- throw new AccountResolutionError("account_quota_exhausted", `Account ${JSON.stringify(accountId)} is out of quota${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""} for model ${model}`, {
291
+ const clear = clearQuotaCommand(provider, accountId);
292
+ throw new AccountResolutionError("account_quota_exhausted", `${label} is out of quota${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""} for model ${model}. Clear the hold with: ${clear}`, {
177
293
  provider,
178
294
  account: accountId,
179
295
  model: block.model ?? null,
180
296
  quota_reset_at: block.quota_reset_at ?? null,
297
+ clear_command: clear,
298
+ ...identityFields,
181
299
  });
182
300
  }
183
301
  function boundAccount(provider, id, account, hostname) {
@@ -205,15 +323,19 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
205
323
  throw new AccountResolutionError("no_enabled_account", `No enabled account for provider ${provider}`, { provider, ineligible: "none_enabled" });
206
324
  }
207
325
  const proven = [];
326
+ const identities = new Map();
208
327
  for (const id of enabled) {
209
328
  const status = accountStatus(provider, id, config, {
210
329
  existsSync,
211
330
  ...(ctx.accountDeps?.probe ? { probe: ctx.accountDeps.probe } : {}),
212
331
  ...(ctx.accountDeps?.env ? { env: ctx.accountDeps.env } : {}),
332
+ ...(ctx.accountDeps?.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
213
333
  ...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
214
334
  });
215
335
  if (status.logged_in === true)
216
336
  proven.push(id);
337
+ if (status.identity)
338
+ identities.set(id, status.identity);
217
339
  }
218
340
  if (proven.length === 0) {
219
341
  throw new AccountResolutionError("no_enabled_account", `No logged-in account for provider ${provider}`, { provider, ineligible: "none_logged_in", enabled });
@@ -247,17 +369,25 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
247
369
  account: id,
248
370
  reason: block.reason,
249
371
  quota_reset_at: block.quota_reset_at ?? null,
372
+ identity: identities.get(id) ?? null,
373
+ clear_command: clearQuotaCommand(provider, id),
250
374
  }));
251
- throw new AccountResolutionError("account_quota_exhausted", `All logged-in accounts for ${provider} are quota-limited for the selected model`, {
375
+ const held = blocked
376
+ .filter(({ block }) => block.reason === "account_quota_exhausted")
377
+ .map(({ id, block }) => `${accountLabel(id, identities.get(id))}${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}; clear with: ${clearQuotaCommand(provider, id)}`)
378
+ .join("; ");
379
+ throw new AccountResolutionError("account_quota_exhausted", `All logged-in accounts for ${provider} are quota-limited for the selected model. ${held}`, {
252
380
  provider,
253
381
  account: earliest.id,
254
382
  model: earliest.block.model ?? null,
255
383
  quota_reset_at: resetTimes[0] ?? null,
256
384
  account_reasons: resetsByAccount,
385
+ ...(identities.get(earliest.id) ? { identity: identities.get(earliest.id) } : {}),
386
+ clear_command: clearQuotaCommand(provider, earliest.id),
257
387
  });
258
388
  }
259
389
  const sample = proven[0];
260
- throwIfQuotaBlocked(blocked[0].block, provider, sample);
390
+ throwIfQuotaBlocked(blocked[0].block, provider, sample, identities.get(sample));
261
391
  }
262
392
  const underCap = runnable.filter((id) => {
263
393
  const cap = accountMaxRunning(providerCfg, id);
@@ -298,6 +428,16 @@ export function resolveAccount(provider, requestedId, config, ctx = {}) {
298
428
  if (ctx.hqRoot !== undefined) {
299
429
  const now = ctx.now ?? new Date();
300
430
  const block = quotaBlockFor(loadQuotaState(ctx.hqRoot), provider, requestedId, modelForAccount(provider, requestedId, config, ctx), now);
431
+ if (block.blocked && provider === "claude" && ctx.accountDeps?.probe) {
432
+ const status = accountStatus(provider, requestedId, config, {
433
+ existsSync: ctx.existsSync ?? ((p) => fs.existsSync(p)),
434
+ probe: ctx.accountDeps.probe,
435
+ ...(ctx.accountDeps.env ? { env: ctx.accountDeps.env } : {}),
436
+ ...(ctx.accountDeps.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
437
+ ...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
438
+ });
439
+ throwIfQuotaBlocked(block, provider, requestedId, status.identity);
440
+ }
301
441
  throwIfQuotaBlocked(block, provider, requestedId);
302
442
  }
303
443
  return boundAccount(provider, requestedId, account, ctx.hostname);
@@ -328,14 +468,16 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
328
468
  const present = homeExists && hasCredentialFile(provider, effective.home, deps.existsSync);
329
469
  let auth_state = homeExists ? authStateFor(provider, present) : "home_missing";
330
470
  let logged_in = homeExists ? loggedInFromAuthState(auth_state) : false;
471
+ let identity;
331
472
  if (homeExists && !present && provider === "claude" && deps.probe !== undefined) {
332
473
  try {
333
- const result = deps.probe("claude", ["auth", "status"], { ...(deps.env ?? process.env), CLAUDE_CONFIG_DIR: effective.home });
474
+ const result = deps.probe("claude", ["auth", "status"], withProviderHomeEnv(deps.env ?? process.env, "claude", effective.home, deps.homedir));
334
475
  const providerStatus = claudeLoginStatus(result);
335
476
  if (providerStatus !== null) {
336
477
  auth_state = "status";
337
478
  logged_in = providerStatus;
338
479
  }
480
+ identity = claudeIdentityFromProbe(result) ?? undefined;
339
481
  }
340
482
  catch {
341
483
  process.stderr.write(`hq lanes: Claude auth status probe failed for account ${accountId}\n`);
@@ -352,6 +494,8 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
352
494
  auth_state,
353
495
  envVar,
354
496
  };
497
+ if (identity)
498
+ status.identity = identity;
355
499
  if (account.disabled_reason !== undefined) {
356
500
  status.disabled_reason = account.disabled_reason;
357
501
  }
@@ -381,8 +525,15 @@ function claudeLoginStatus(result) {
381
525
  return false;
382
526
  return null;
383
527
  }
384
- /** Environment assignment the worker process should receive. */
385
- export function accountEnv(resolved) {
528
+ /**
529
+ * Environment assignment the worker process should receive. Empty for a
530
+ * Claude account on the default home, which runs with the variable unset
531
+ * (see isDefaultProviderHome).
532
+ */
533
+ export function accountEnv(resolved, homedir) {
534
+ if (resolved.provider === "claude" && isDefaultProviderHome("claude", resolved.home, homedir)) {
535
+ return {};
536
+ }
386
537
  return { [resolved.envVar]: resolved.home };
387
538
  }
388
539
  //# sourceMappingURL=accounts.js.map
@@ -40,8 +40,8 @@ export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO,
40
40
  export type { CpuTimes, ThrottleAdmitReason, ThrottleDecision, ThrottleDeferReason, ThrottleDeps, ThrottleState, } from "./throttle.js";
41
41
  export { effectiveAccountHome, } from "./account-home.js";
42
42
  export type { EffectiveAccountHome, } from "./account-home.js";
43
- export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
44
- export type { AccountResolveContext, AccountStatus, AuthState, ResolvedAccount, } from "./accounts.js";
43
+ export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, claudeIdentityDiffers, claudeIdentityFromProbe, clearQuotaCommand, credentialPaths, describeClaudeIdentity, hasCredentialFile, isCredentialProven, isDefaultProviderHome, isLoggedOut, probeInteractiveClaudeIdentity, withProviderHomeEnv, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
44
+ export type { AccountResolveContext, AccountStatus, AuthState, ClaudeIdentity, ResolvedAccount, } from "./accounts.js";
45
45
  export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
46
46
  export type { LivenessDeps, LivenessEventKind, LivenessIndex, SessionIndexEntry, } from "./liveness.js";
47
47
  export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
@@ -22,7 +22,7 @@ export { ENVELOPE_INVALID_REASON, decideEnvelopeRepair, emitMeshSoft, envelopeQu
22
22
  export { LANE_MESH_EVENTS, WIRE_EVENT_KINDS, createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, laneCanEmit, parseLaneMeshEvent, progressEventDue, toWireThreadEvent, } from "./mesh-events.js";
23
23
  export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO, STARVATION_MAX_DEFERRALS, STARVATION_MAX_MS, VM_STAT_BIN, applyThrottleDecision, busyFractionFromDeltas, cpuTimesSnapshot, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, parseMemAvailableKb, parseVmStatAvailableMb, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyFraction, starvationBoundReached, throttleStatePath, writeThrottleState, } from "./throttle.js";
24
24
  export { effectiveAccountHome, } from "./account-home.js";
25
- export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
25
+ export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, claudeIdentityDiffers, claudeIdentityFromProbe, clearQuotaCommand, credentialPaths, describeClaudeIdentity, hasCredentialFile, isCredentialProven, isDefaultProviderHome, isLoggedOut, probeInteractiveClaudeIdentity, withProviderHomeEnv, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
26
26
  export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
27
27
  export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
28
28
  export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
5
6
  import { LanesError } from "./errors.js";
@@ -55,7 +56,11 @@ export function writeMessageReplayState(filePath, state) {
55
56
  function safeProviderTranscriptPath(transcriptPath, provider, env) {
56
57
  if (provider === "grok")
57
58
  return transcriptPath;
58
- const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
59
+ // A Claude worker on the default home runs with CLAUDE_CONFIG_DIR unset
60
+ // (accounts.ts isDefaultProviderHome); its transcripts live under ~/.claude.
61
+ const home = provider === "claude"
62
+ ? env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
63
+ : env.CODEX_HOME;
59
64
  if (!home) {
60
65
  throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
61
66
  }
@@ -38,6 +38,8 @@ export interface ProviderRejection {
38
38
  event: "codex.turn.failed" | "claude.result.is_error" | "claude.result.login_failure" | "grok.error";
39
39
  /** Filesystem time when the provider output was appended, when read from worker.out. */
40
40
  output_at?: string;
41
+ /** The provider's structured reset (`resetsAt`), when the result carried one. */
42
+ resets_at?: unknown;
41
43
  }
42
44
  export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
43
45
  /**
@@ -56,12 +58,6 @@ export declare function retryableFromStatus(status: number | null): boolean;
56
58
  * read when it parses; otherwise the outer text is the message.
57
59
  */
58
60
  export declare function parseCodexTurnFailed(text: string): ProviderRejection | null;
59
- /**
60
- * Claude `-p --output-format json`: the last `type: "result"` object with
61
- * `is_error: true` and a numeric `api_error_status`, or the CLI's explicit
62
- * unauthenticated message. Other status-less errors keep the existing
63
- * classification because they may be quota text or a worker failure.
64
- */
65
61
  export declare function parseClaudeResultRejection(text: string): ProviderRejection | null;
66
62
  /**
67
63
  * Grok `--output-format json` prints one object. Measured 2026-09-22 (lane
@@ -166,6 +166,23 @@ export function parseCodexTurnFailed(text) {
166
166
  * unauthenticated message. Other status-less errors keep the existing
167
167
  * classification because they may be quota text or a worker failure.
168
168
  */
169
+ function isRecord(value) {
170
+ return value !== null && typeof value === "object" && !Array.isArray(value);
171
+ }
172
+ /** `resetsAt` on the result, or under `rate_limit_info` (directly or in the API error params). */
173
+ function claudeStructuredReset(rec) {
174
+ const direct = rec.resets_at ?? rec.resetsAt;
175
+ if (direct !== undefined)
176
+ return direct;
177
+ for (const holder of [rec, rec.api_error_params, rec.apiErrorParams]) {
178
+ if (!isRecord(holder))
179
+ continue;
180
+ const info = holder.rate_limit_info ?? holder.rateLimitInfo;
181
+ if (isRecord(info) && info.resetsAt !== undefined)
182
+ return info.resetsAt;
183
+ }
184
+ return undefined;
185
+ }
169
186
  export function parseClaudeResultRejection(text) {
170
187
  // The last `result` decides: a success or a status-less error there is
171
188
  // final (NOT_REJECTED), so the scan does not continue to earlier results.
@@ -182,6 +199,7 @@ export function parseClaudeResultRejection(text) {
182
199
  if (status === null && !loginFailure && !retryableProviderFailure(status, message)) {
183
200
  return NOT_REJECTED;
184
201
  }
202
+ const resetsAt = claudeStructuredReset(rec);
185
203
  return {
186
204
  reason: PROVIDER_REJECTED_REASON,
187
205
  provider: "claude",
@@ -192,6 +210,7 @@ export function parseClaudeResultRejection(text) {
192
210
  event: loginFailure && rec.is_error !== true
193
211
  ? "claude.result.login_failure"
194
212
  : "claude.result.is_error",
213
+ ...(resetsAt !== undefined ? { resets_at: resetsAt } : {}),
195
214
  };
196
215
  });
197
216
  return found === NOT_REJECTED ? null : found;
@@ -107,16 +107,20 @@ export declare function parseProviderResetTime(text: string, now: Date): string
107
107
  * `type: "result"` object. Nothing else from the payload is returned, and
108
108
  * this function does not log.
109
109
  */
110
- export declare function quotaFieldsFromOutput(text: string): {
110
+ export interface QuotaResultFields {
111
111
  api_error_status: unknown;
112
112
  is_error: unknown;
113
113
  result: unknown;
114
- } | null;
115
- export declare function quotaSignalFromFields(fields: {
116
- api_error_status: unknown;
117
- is_error: unknown;
118
- result: unknown;
119
- }, model: string | undefined, now: Date): QuotaSignal | null;
114
+ /**
115
+ * The provider's structured reset (`resetsAt`, epoch seconds or ISO) when
116
+ * the result carries one; it beats the wording in `result`.
117
+ */
118
+ resets_at?: unknown;
119
+ }
120
+ /** Epoch seconds, epoch milliseconds, or an ISO string, to ISO; null otherwise. */
121
+ export declare function normalizeStructuredReset(value: unknown): string | null;
122
+ export declare function quotaFieldsFromOutput(text: string): QuotaResultFields | null;
123
+ export declare function quotaSignalFromFields(fields: QuotaResultFields, model: string | undefined, now: Date): QuotaSignal | null;
120
124
  export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
121
125
  export interface RecordAccountQuotaOptions {
122
126
  /** When the 429 was observed. Enables the far-reset confirmation gate. */
@@ -258,7 +258,7 @@ export function parseProviderResetTime(text, now) {
258
258
  }
259
259
  return null;
260
260
  }
261
- const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})\s+)?|\s+at\s+)(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*${ZONE_PATTERN})?`, "i").exec(text);
261
+ const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})(?:,|\s+at)?\s+)?|\s+at\s+)(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*${ZONE_PATTERN})?`, "i").exec(text);
262
262
  const codexMatch = /\btry\s+again\s+at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s*(\d{4})\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\((UTC)\))?/i.exec(text);
263
263
  const match = resetMatch ?? codexMatch;
264
264
  if (!match)
@@ -320,10 +320,47 @@ function isUsageLimitShape(text) {
320
320
  return /weekly limit|usage limit|rate limit|quota|usage credits|hit your .+ limit/i.test(text);
321
321
  }
322
322
  /**
323
- * Read only `api_error_status`, `is_error`, and `result` off the last
324
- * `type: "result"` object. Nothing else from the payload is returned, and
325
- * this function does not log.
323
+ * A structured reset from a result object: `resets_at` / `resetsAt` at the
324
+ * top level, or `rate_limit_info.resetsAt` directly or under
325
+ * `api_error_params` / `apiErrorParams`. Undefined when none is present.
326
326
  */
327
+ function structuredResetFrom(rec) {
328
+ const direct = rec.resets_at ?? rec.resetsAt;
329
+ if (direct !== undefined)
330
+ return direct;
331
+ const holders = [rec, rec.api_error_params, rec.apiErrorParams];
332
+ for (const holder of holders) {
333
+ if (!isPlainObject(holder))
334
+ continue;
335
+ const info = holder.rate_limit_info ?? holder.rateLimitInfo;
336
+ if (isPlainObject(info) && info.resetsAt !== undefined)
337
+ return info.resetsAt;
338
+ }
339
+ return undefined;
340
+ }
341
+ /** Epoch seconds, epoch milliseconds, or an ISO string, to ISO; null otherwise. */
342
+ export function normalizeStructuredReset(value) {
343
+ let ms;
344
+ if (typeof value === "number" && Number.isFinite(value)) {
345
+ ms = value < 1e12 ? value * 1000 : value;
346
+ }
347
+ else if (typeof value === "string" && value.trim().length > 0) {
348
+ const trimmed = value.trim();
349
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
350
+ const n = Number(trimmed);
351
+ ms = n < 1e12 ? n * 1000 : n;
352
+ }
353
+ else {
354
+ ms = Date.parse(trimmed);
355
+ }
356
+ }
357
+ else {
358
+ return null;
359
+ }
360
+ if (!Number.isFinite(ms) || ms <= 0)
361
+ return null;
362
+ return new Date(ms).toISOString();
363
+ }
327
364
  export function quotaFieldsFromOutput(text) {
328
365
  const lines = text.split("\n");
329
366
  for (let i = lines.length - 1; i >= 0; i -= 1) {
@@ -339,10 +376,12 @@ export function quotaFieldsFromOutput(text) {
339
376
  }
340
377
  if (!isPlainObject(parsed) || parsed.type !== "result")
341
378
  continue;
379
+ const resets_at = structuredResetFrom(parsed);
342
380
  return {
343
381
  api_error_status: parsed.api_error_status,
344
382
  is_error: parsed.is_error,
345
383
  result: parsed.result,
384
+ ...(resets_at !== undefined ? { resets_at } : {}),
346
385
  };
347
386
  }
348
387
  return null;
@@ -362,7 +401,8 @@ export function quotaSignalFromFields(fields, model, now) {
362
401
  marker,
363
402
  };
364
403
  }
365
- const quota_reset_at = text.length > 0 ? parseProviderResetTime(text, now) ?? undefined : undefined;
404
+ const structured = normalizeStructuredReset(fields.resets_at) ?? undefined;
405
+ const quota_reset_at = structured ?? (text.length > 0 ? parseProviderResetTime(text, now) ?? undefined : undefined);
366
406
  return {
367
407
  reason: "account_quota_exhausted",
368
408
  quota_reset_at,
@@ -12,6 +12,8 @@ export interface EnsureSessionHomeResult extends SessionCopyResult {
12
12
  source_home: string;
13
13
  transcript_path: string;
14
14
  homes_searched: string[];
15
+ /** Set when the transcript was found outside the expected project directory. */
16
+ found_outside_cwd?: true;
15
17
  }
16
18
  /**
17
19
  * Older Claude account homes can share a registered `account-N/.claude`
@@ -117,8 +117,120 @@ function uniqueHomes(homes) {
117
117
  }
118
118
  return [...unique];
119
119
  }
120
- function missingTranscript(input, homes) {
121
- return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}`, { provider: input.provider, session_id: input.sessionId, homes_searched: homes });
120
+ function missingTranscript(input, homes, dirsScanned) {
121
+ return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}. Directories scanned: ${dirsScanned.length > 0 ? dirsScanned.join(", ") : "none"}`, {
122
+ provider: input.provider,
123
+ session_id: input.sessionId,
124
+ homes_searched: homes,
125
+ directories_scanned: dirsScanned,
126
+ });
127
+ }
128
+ /**
129
+ * Every `<home>/projects/<dir>/` that could hold a claude transcript. A lane
130
+ * can record one cwd and have run in another (a worktree), so a resume looks
131
+ * in every project directory before refusing.
132
+ */
133
+ function claudeProjectDirs(home) {
134
+ const root = path.join(home, "projects");
135
+ let entries;
136
+ try {
137
+ entries = fs.readdirSync(root, { withFileTypes: true });
138
+ }
139
+ catch (err) {
140
+ const code = err.code;
141
+ if (code === "ENOENT" || code === "ENOTDIR")
142
+ return [];
143
+ throw new LanesError("provider_home_search_failed", `Could not search provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider: "claude", home });
144
+ }
145
+ return entries
146
+ .filter((entry) => entry.isDirectory())
147
+ .map((entry) => path.join(root, entry.name))
148
+ .sort();
149
+ }
150
+ function isFile(target) {
151
+ try {
152
+ return fs.statSync(target).isFile();
153
+ }
154
+ catch (err) {
155
+ const code = err.code;
156
+ if (code === "ENOENT" || code === "ENOTDIR")
157
+ return false;
158
+ throw err;
159
+ }
160
+ }
161
+ /**
162
+ * Last resort for a claude session that is not under the expected project
163
+ * directory in any home: scan every project directory of every searched home,
164
+ * then copy the transcript (and its sibling subagent directory) into the
165
+ * target home under the project directory for `input.cwd`, which is where
166
+ * `claude --resume` started in that cwd looks for it.
167
+ */
168
+ function relinkFromAnyProject(input, targetHome, searched) {
169
+ const scanned = [];
170
+ if (input.provider !== "claude") {
171
+ for (const home of searched)
172
+ scanned.push(path.join(home, "sessions"));
173
+ throw missingTranscript(input, searched, scanned);
174
+ }
175
+ const matches = [];
176
+ for (const home of searched) {
177
+ const found = [];
178
+ const dirs = claudeProjectDirs(home);
179
+ if (dirs.length === 0)
180
+ scanned.push(path.join(home, "projects"));
181
+ for (const dir of dirs) {
182
+ scanned.push(dir);
183
+ const candidate = path.join(dir, `${input.sessionId}.jsonl`);
184
+ if (isFile(candidate))
185
+ found.push(candidate);
186
+ }
187
+ if (found.length > 1) {
188
+ throw new LanesError("provider_transcript_ambiguous", `Multiple claude transcripts for session ${input.sessionId} exist in ${home}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: found });
189
+ }
190
+ if (found.length === 1)
191
+ matches.push({ home, source: found[0] });
192
+ }
193
+ if (matches.length === 0)
194
+ throw missingTranscript(input, searched, scanned);
195
+ // The last-run home holds the newest copy; any other home may hold a stale
196
+ // copy from an earlier account move, so more than one is ambiguous.
197
+ const lastRunHome = input.providerHomeAtSpawn ? path.resolve(input.providerHomeAtSpawn) : undefined;
198
+ const chosen = matches.find((match) => match.home === lastRunHome) ??
199
+ (matches.length === 1 ? matches[0] : undefined);
200
+ if (chosen === undefined) {
201
+ throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
202
+ provider: input.provider,
203
+ session_id: input.sessionId,
204
+ homes_searched: searched,
205
+ matching_homes: matches.map((match) => match.home),
206
+ });
207
+ }
208
+ {
209
+ const home = chosen.home;
210
+ const source = chosen.source;
211
+ const targetDir = path.join(targetHome, "projects", encodeClaudeProjectDir(input.cwd));
212
+ const target = path.join(targetDir, `${input.sessionId}.jsonl`);
213
+ const subagents = path.join(path.dirname(source), input.sessionId);
214
+ const hasSubagents = statDirectory(subagents);
215
+ const plan = {
216
+ provider: "claude",
217
+ sources: [source],
218
+ targets: [target],
219
+ source_dir: hasSubagents ? subagents : null,
220
+ target_dir: hasSubagents ? path.join(targetDir, input.sessionId) : null,
221
+ };
222
+ const copied = copySessionFiles(plan);
223
+ if (!isFile(target)) {
224
+ throw new LanesError("provider_transcript_relink_failed", `Could not verify session ${input.sessionId} in target home ${targetHome}`, { provider: input.provider, session_id: input.sessionId, target_home: targetHome });
225
+ }
226
+ return {
227
+ ...copied,
228
+ source_home: home,
229
+ transcript_path: target,
230
+ homes_searched: searched,
231
+ found_outside_cwd: true,
232
+ };
233
+ }
122
234
  }
123
235
  function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
124
236
  let copied;
@@ -132,7 +244,7 @@ function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
132
244
  }
133
245
  catch (err) {
134
246
  if (err instanceof LanesError && err.error === "provider_session_missing") {
135
- throw missingTranscript(input, searched);
247
+ return relinkFromAnyProject(input, targetHome, searched);
136
248
  }
137
249
  throw err;
138
250
  }
@@ -202,7 +314,7 @@ export function ensureSessionHome(input) {
202
314
  matches.push({ home, transcript: found[0] });
203
315
  }
204
316
  if (matches.length === 0)
205
- throw missingTranscript(input, searched);
317
+ return relinkFromAnyProject(input, targetHome, searched);
206
318
  if (matches.length > 1) {
207
319
  throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
208
320
  provider: input.provider,
@@ -255,7 +255,7 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
255
255
  export declare function workerEnv(base: NodeJS.ProcessEnv, resolved: {
256
256
  envVar: string;
257
257
  home: string;
258
- }, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"]): NodeJS.ProcessEnv;
258
+ }, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"], homedir?: () => string): NodeJS.ProcessEnv;
259
259
  export declare const WORKER_POLICY_HOOK_CAPABILITY = "# hq-capability: worker-policy-dir v1";
260
260
  /** Grok does not run HQ hooks; Claude and Codex use the installed capability marker. */
261
261
  export declare function workerPolicyDeliveryMode(hqRoot: string, provider: LaneProvider): "hook" | "prompt";
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { configureRemoteControlChildEnv, prepareRemoteControlLane, remoteControlSelectionConfig } from "./remote-control-lane.js";
16
- import { PROVIDER_HOME_ENV, accountStatus, isLoggedOut, resolveAccount, selectBalancedAccount, } from "./accounts.js";
16
+ import { PROVIDER_HOME_ENV, accountStatus, isDefaultProviderHome, isLoggedOut, resolveAccount, selectBalancedAccount, } from "./accounts.js";
17
17
  import { effectiveAccountHome } from "./account-home.js";
18
18
  import { buildHostCapacitySnapshot } from "./host-capacity.js";
19
19
  import { selectClaudeAccount } from "./claude-balance.js";
@@ -1661,7 +1661,7 @@ export function buildProviderCommand(provider, input) {
1661
1661
  args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
1662
1662
  };
1663
1663
  }
1664
- export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker) {
1664
+ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir) {
1665
1665
  const accepted = Object.values(PROVIDER_HOME_ENV);
1666
1666
  if (!accepted.includes(resolved.envVar)) {
1667
1667
  throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
@@ -1672,7 +1672,16 @@ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, mes
1672
1672
  delete env.HQ_POLICY_WORKER_DIR;
1673
1673
  delete env.HQ_LANE_APPROVAL_REQUIRED;
1674
1674
  delete env.HQ_LANE_HUMAN_CHECKPOINTS;
1675
- env[resolved.envVar] = resolved.home;
1675
+ if (resolved.envVar === PROVIDER_HOME_ENV.claude &&
1676
+ isDefaultProviderHome("claude", resolved.home, homedir)) {
1677
+ // The default ~/.claude home runs with CLAUDE_CONFIG_DIR unset so the
1678
+ // worker reads the same keychain credential as the interactive session
1679
+ // (see isDefaultProviderHome). An inherited value is dropped too.
1680
+ delete env[resolved.envVar];
1681
+ }
1682
+ else {
1683
+ env[resolved.envVar] = resolved.home;
1684
+ }
1676
1685
  delete env[CLAUDE_AUTO_COMPACT_ENV];
1677
1686
  if (resolved.envVar === PROVIDER_HOME_ENV.claude && autoCompactTokens !== undefined) {
1678
1687
  env[CLAUDE_AUTO_COMPACT_ENV] = String(autoCompactTokens);
@@ -2075,6 +2084,14 @@ export function readSpawnManifest(runDir) {
2075
2084
  }
2076
2085
  return out;
2077
2086
  }
2087
+ function isExistingDirectory(target) {
2088
+ try {
2089
+ return fs.statSync(target).isDirectory();
2090
+ }
2091
+ catch {
2092
+ return false;
2093
+ }
2094
+ }
2078
2095
  function laneLaunchResult(lane) {
2079
2096
  const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
2080
2097
  return safe;
@@ -2327,6 +2344,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2327
2344
  : existing;
2328
2345
  throw new LanesError("account_not_logged_in", `Account ${resolved.id} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: resolved.id, lane_id: lane.lane_id, lane: failed });
2329
2346
  }
2347
+ // The worker starts here, and claude keys its transcript by this directory,
2348
+ // so the transcript checks below use the same value. A resume reuses the
2349
+ // directory the last run started in (a worktree for pipeline lanes) when it
2350
+ // still exists.
2351
+ const recordedWorkingDirectory = plan.mode === "resume" &&
2352
+ typeof existing.working_directory_at_spawn === "string" &&
2353
+ existing.working_directory_at_spawn.trim() !== "" &&
2354
+ isExistingDirectory(existing.working_directory_at_spawn)
2355
+ ? existing.working_directory_at_spawn
2356
+ : undefined;
2357
+ const workerCwd = deps.workingDirectory ?? pendingResume?.working_directory ?? recordedWorkingDirectory ?? hqRoot;
2330
2358
  if (existing.account !== resolved.id) {
2331
2359
  const source = existing.account
2332
2360
  ? config.providers[existing.provider].accounts[existing.account]
@@ -2335,12 +2363,22 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2335
2363
  source &&
2336
2364
  existing.provider_session_id &&
2337
2365
  providerSupportsSessionResume(existing.provider)) {
2338
- copySessionFiles(planSessionCopy(existing.provider, {
2339
- sourceHome: existing.provider_home_at_spawn ?? effectiveAccountHome(source, hostname).home,
2340
- targetHome: resolved.home,
2341
- cwd: hqRoot,
2342
- sessionId: existing.provider_session_id,
2343
- }));
2366
+ try {
2367
+ copySessionFiles(planSessionCopy(existing.provider, {
2368
+ sourceHome: existing.provider_home_at_spawn ?? effectiveAccountHome(source, hostname).home,
2369
+ targetHome: resolved.home,
2370
+ cwd: workerCwd,
2371
+ sessionId: existing.provider_session_id,
2372
+ }));
2373
+ }
2374
+ catch (err) {
2375
+ // A resume below searches every home and project directory for the
2376
+ // session, so a transcript under another cwd is handled there.
2377
+ const resumeFollows = plan.mode === "resume" && existing.provider !== "grok";
2378
+ if (!(resumeFollows && err instanceof LanesError && err.error === "provider_session_missing")) {
2379
+ throw err;
2380
+ }
2381
+ }
2344
2382
  }
2345
2383
  }
2346
2384
  if (plan.mode === "resume" && existing.provider !== "grok") {
@@ -2350,7 +2388,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2350
2388
  ensureSessionHome({
2351
2389
  provider: existing.provider,
2352
2390
  sessionId: plan.session_id,
2353
- cwd: hqRoot,
2391
+ cwd: workerCwd,
2354
2392
  targetHome,
2355
2393
  registeredHomes: Object.values(config.providers[existing.provider].accounts)
2356
2394
  .map((account) => effectiveAccountHome(account, hostname).home),
@@ -2644,6 +2682,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2644
2682
  pending_settings: undefined,
2645
2683
  account: resolved.id,
2646
2684
  provider_home_at_spawn: remoteControlHome ?? resolved.home,
2685
+ working_directory_at_spawn: workerCwd,
2647
2686
  ...(model !== undefined ? { model } : {}),
2648
2687
  ...(model !== undefined
2649
2688
  ? {
@@ -2789,7 +2828,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2789
2828
  let watcherPid;
2790
2829
  try {
2791
2830
  noteCapacityAdvisory(lane, env);
2792
- workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, deps.workingDirectory ?? pendingResume?.working_directory ?? hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
2831
+ workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, workerCwd, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
2793
2832
  writePidRecord(path.join(runDir, "worker.pid"), {
2794
2833
  pid: workerPid,
2795
2834
  starttime: readProcStarttime(workerPid) ?? undefined,
@@ -3095,12 +3134,19 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
3095
3134
  ? { resume: { session_id: sessionId, prompt: repairPrompt } }
3096
3135
  : { freshTurnPrompt: repairPrompt }),
3097
3136
  });
3137
+ // The repair turn resumes the run's session, which claude keys by the
3138
+ // directory that run started in.
3139
+ const repairCwd = mode === "resume" &&
3140
+ typeof lane.working_directory_at_spawn === "string" &&
3141
+ isExistingDirectory(lane.working_directory_at_spawn)
3142
+ ? lane.working_directory_at_spawn
3143
+ : hqRoot;
3098
3144
  const workerOutOffset = fileSizeOrZero(path.join(logsDir, "worker.out"));
3099
3145
  const previous = readSpawnManifest(runDir);
3100
3146
  let workerPid;
3101
3147
  try {
3102
3148
  noteCapacityAdvisory(lane, env);
3103
- workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
3149
+ workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, repairCwd, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
3104
3150
  writePidRecord(path.join(runDir, "worker.pid"), {
3105
3151
  pid: workerPid,
3106
3152
  starttime: readProcStarttime(workerPid) ?? undefined,
@@ -772,6 +772,11 @@ export interface LaneRecord {
772
772
  provider_session_id_absent?: string;
773
773
  /** Provider home used by the most recently spawned run, for transcript recovery. */
774
774
  provider_home_at_spawn?: string;
775
+ /**
776
+ * Working directory the most recently spawned run started in. Claude keys
777
+ * its transcript by this directory, so a later resume starts there too.
778
+ */
779
+ working_directory_at_spawn?: string;
775
780
  /** Set at spawn when this run continued a recorded session instead of starting fresh. */
776
781
  last_resume?: LaneResumeRecord;
777
782
  /** Last 50 lane worker starts after the initial start, including actor provenance. */
@@ -242,7 +242,7 @@ export const LANE_FIELD_WRITERS = {
242
242
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
243
243
  admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile", "spawn"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
244
244
  quota_reset_at: ["watcher", "reconcile", "spawn", "command"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
245
- provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
245
+ provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
246
246
  };
247
247
  function isPlainObject(value) {
248
248
  return value !== null && typeof value === "object" && !Array.isArray(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.124",
3
+ "version": "5.345.126",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {