@indigoai-us/hq-cli 5.187.0 → 5.188.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.
@@ -18,8 +18,9 @@ import { resolveProcessFlagGate } from "../lib/flag-registry.js";
18
18
  import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
19
19
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
20
20
  export { LANE_HIRE_GRANTS_FLAG };
21
- import { buildOutpostCapacityCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
21
+ import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
22
22
  import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
23
+ import { BALANCE_MOVABLE_STATES, CLAUDE_ACCOUNT_BALANCE_FLAG, accountLoadsForReport, assertBalanceApplyEnabled, planClaudeBalanceMoves, selectClaudeAccount, } from "../lib/lanes/claude-account-balance.js";
23
24
  import { parseUserYaml } from "../utils/user-yaml-error.js";
24
25
  import { backfillOutpostIdentityMarker as backfillOutpostIdentityMarkerImpl, createOutpostIdentityMarkerIfMissing, validateOutpostIdentityId, } from "../lib/lanes/outpost-identity.js";
25
26
  import { vaultApiFetch } from "../utils/vault-api.js";
@@ -53,6 +54,9 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
53
54
  function defaultLanePlacementEnabled() {
54
55
  return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
55
56
  }
57
+ function defaultLaneClaudeAccountBalanceEnabled() {
58
+ return resolveProcessFlagGate(CLAUDE_ACCOUNT_BALANCE_FLAG, { fallback: false }, () => false);
59
+ }
56
60
  function defaultLaneHireGrantsEnabled(company) {
57
61
  return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
58
62
  }
@@ -251,6 +255,7 @@ function resolveDeps(deps = {}) {
251
255
  fetchText: deps.fetchText ?? defaultFetchText,
252
256
  lanePlacementEnabled: deps.lanePlacementEnabled ?? defaultLanePlacementEnabled,
253
257
  laneHireGrantsEnabled: deps.laneHireGrantsEnabled ?? defaultLaneHireGrantsEnabled,
258
+ laneClaudeAccountBalanceEnabled: deps.laneClaudeAccountBalanceEnabled ?? defaultLaneClaudeAccountBalanceEnabled,
254
259
  };
255
260
  }
256
261
  function livenessDepsFromCtx(ctx) {
@@ -755,6 +760,198 @@ async function loadHostCapacitySnapshots(ctx, hqRoot, config = loadLanesConfig(h
755
760
  skippedHosts: unavailable.map(({ outpost_id, error }) => ({ outpost_id, error })),
756
761
  };
757
762
  }
763
+ async function readOutpostLaneRecords(ctx, outpostId) {
764
+ let result;
765
+ try {
766
+ result = await ctx.dispatchOutpostExec({
767
+ outpostId,
768
+ command: buildOutpostLaneListCommand(),
769
+ timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
770
+ waitMs: CAPACITY_PROBE_TIMEOUT_MS,
771
+ });
772
+ }
773
+ catch (err) {
774
+ throw new LanesError("outpost_lane_list_unavailable", `Could not read lanes from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId });
775
+ }
776
+ if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
777
+ throw new LanesError("outpost_lane_list_unavailable", `Outpost ${outpostId} did not return a successful lane list`, { outpost_id: outpostId, status: result.status, done: result.done ?? false, exit_code: result.exitCode });
778
+ }
779
+ let body;
780
+ try {
781
+ body = JSON.parse(result.stdout);
782
+ }
783
+ catch (err) {
784
+ throw new LanesError("outpost_lane_list_invalid", `Outpost ${outpostId} returned invalid lane-list JSON: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId });
785
+ }
786
+ if (body === null || typeof body !== "object" || Array.isArray(body) ||
787
+ body.ok !== true ||
788
+ body.action !== "list" ||
789
+ !Array.isArray(body.lanes)) {
790
+ throw new LanesError("outpost_lane_list_invalid", `Outpost ${outpostId} returned a malformed lane list`, {
791
+ outpost_id: outpostId,
792
+ });
793
+ }
794
+ const listed = body.lanes;
795
+ const lanes = [];
796
+ for (const [index, value] of listed.entries()) {
797
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
798
+ throw new LanesError("outpost_lane_list_invalid", `Outpost ${outpostId} lane row ${index} is malformed`, {
799
+ outpost_id: outpostId,
800
+ index,
801
+ });
802
+ }
803
+ const raw = value;
804
+ const host = raw.host;
805
+ if (host === null || typeof host !== "object" || Array.isArray(host)) {
806
+ throw new LanesError("outpost_lane_list_invalid", `Outpost ${outpostId} lane row ${index} has no host`, {
807
+ outpost_id: outpostId,
808
+ index,
809
+ });
810
+ }
811
+ const rowHost = host;
812
+ const belongsToOutpost = rowHost.kind === "outpost" && rowHost.id === outpostId;
813
+ // Lanes created before an Outpost received its identity marker retain the
814
+ // real local hostname in their record. The remote list comes from this
815
+ // Outpost's own store, so preserve those local identities for accounting.
816
+ const hasLocalHostIdentity = rowHost.kind === "local" &&
817
+ typeof rowHost.id === "string" && rowHost.id.trim() !== "";
818
+ if (!belongsToOutpost && !hasLocalHostIdentity)
819
+ continue;
820
+ const normalized = raw.state === "remote" && raw.remote_state !== undefined
821
+ ? { ...raw, state: raw.remote_state }
822
+ : raw;
823
+ try {
824
+ lanes.push(parseLaneRecord(normalized));
825
+ }
826
+ catch (err) {
827
+ throw new LanesError("outpost_lane_list_invalid", `Outpost ${outpostId} returned an invalid owned lane record: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId, index });
828
+ }
829
+ }
830
+ return lanes;
831
+ }
832
+ async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config) {
833
+ const localHost = laneHostFromCtx(ctx);
834
+ const localLanes = loadAllLanes(hqRoot);
835
+ const local = buildHostCapacitySnapshot({
836
+ host: localHost,
837
+ lanes: localLanes,
838
+ config,
839
+ existsSync: ctx.existsSync,
840
+ probe: ctx.probeProvider,
841
+ env: ctx.env,
842
+ now: ctx.now(),
843
+ });
844
+ const snapshots = [{
845
+ capacity: local,
846
+ lanes: localLanes.filter((lane) => lane.host.kind === localHost.kind && lane.host.id === localHost.id),
847
+ laneListAvailable: true,
848
+ }];
849
+ if (localHost.kind === "outpost") {
850
+ const hostname = ctx.hostname().trim();
851
+ const actualLocalHost = { kind: "local", id: hostname };
852
+ const actualLocalLanes = hostname
853
+ ? localLanes.filter((lane) => lane.host.kind === "local" && lane.host.id === hostname)
854
+ : [];
855
+ if (actualLocalLanes.length > 0) {
856
+ snapshots.push({
857
+ capacity: buildHostCapacitySnapshot({
858
+ host: actualLocalHost,
859
+ lanes: actualLocalLanes,
860
+ config,
861
+ existsSync: ctx.existsSync,
862
+ probe: ctx.probeProvider,
863
+ env: ctx.env,
864
+ now: ctx.now(),
865
+ }),
866
+ lanes: actualLocalLanes,
867
+ laneListAvailable: true,
868
+ });
869
+ }
870
+ }
871
+ const unavailableHosts = [];
872
+ let outpostIds;
873
+ try {
874
+ outpostIds = await ctx.listOutpostIds();
875
+ }
876
+ catch (err) {
877
+ unavailableHosts.push({
878
+ host: { kind: "unknown", id: "unknown" },
879
+ reason: `outpost_list_unavailable: ${dispatchErrorMessage(err)}`,
880
+ });
881
+ return { snapshots, unavailableHosts };
882
+ }
883
+ if (outpostIds.length > MAX_PLACEMENT_OUTPOSTS ||
884
+ outpostIds.some((id) => typeof id !== "string" || id.trim() === "") ||
885
+ new Set(outpostIds).size !== outpostIds.length ||
886
+ new Set(outpostIds.map((id) => id.trim())).size !== outpostIds.length) {
887
+ throw new LanesError("outpost_list_invalid", "Outpost list is malformed or exceeds the placement limit", {
888
+ count: outpostIds.length,
889
+ maximum: MAX_PLACEMENT_OUTPOSTS,
890
+ });
891
+ }
892
+ const remoteIds = outpostIds.map((id) => id.trim())
893
+ .filter((id) => id !== "primary" || (localHost.kind === "outpost" && localHost.id !== "primary"))
894
+ .filter((id) => localHost.kind !== "outpost" || id !== localHost.id)
895
+ .sort();
896
+ const remoteResults = await mapWithConcurrency(remoteIds, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (id) => {
897
+ let capacity;
898
+ try {
899
+ capacity = await readOutpostCapacity(ctx, id);
900
+ }
901
+ catch (err) {
902
+ if (isLanesError(err) && err.error !== "outpost_capacity_unavailable") {
903
+ throw new LanesError("claude_account_balance_host_invalid", `Outpost ${id} returned an invalid account-capacity snapshot: ${err.message}`, { outpost_id: id, source_error: err.error });
904
+ }
905
+ return { gap: { host: { kind: "outpost", id }, reason: isLanesError(err) ? err.error : "outpost_capacity_unavailable" } };
906
+ }
907
+ let lanes = [];
908
+ let laneListAvailable = false;
909
+ try {
910
+ lanes = await readOutpostLaneRecords(ctx, id);
911
+ laneListAvailable = true;
912
+ }
913
+ catch (err) {
914
+ if (isLanesError(err) && err.error === "outpost_lane_list_invalid")
915
+ throw err;
916
+ unavailableHosts.push({
917
+ host: { kind: "outpost", id },
918
+ reason: isLanesError(err) ? err.error : "outpost_lane_list_unavailable",
919
+ });
920
+ }
921
+ const hostSnapshots = [{
922
+ capacity,
923
+ lanes: laneListAvailable
924
+ ? lanes.filter((lane) => lane.host.kind === "outpost" && lane.host.id === id)
925
+ : lanes,
926
+ laneListAvailable,
927
+ }];
928
+ if (laneListAvailable) {
929
+ const localGroups = new Map();
930
+ for (const lane of lanes) {
931
+ if (lane.host.kind !== "local")
932
+ continue;
933
+ const group = localGroups.get(lane.host.id) ?? [];
934
+ group.push(lane);
935
+ localGroups.set(lane.host.id, group);
936
+ }
937
+ for (const [hostname, localHostLanes] of localGroups) {
938
+ hostSnapshots.push({
939
+ capacity: { ...capacity, host: { kind: "local", id: hostname } },
940
+ lanes: localHostLanes,
941
+ laneListAvailable: true,
942
+ });
943
+ }
944
+ }
945
+ return { snapshots: hostSnapshots };
946
+ });
947
+ for (const result of remoteResults) {
948
+ if ("snapshots" in result)
949
+ snapshots.push(...result.snapshots);
950
+ else
951
+ unavailableHosts.push(result.gap);
952
+ }
953
+ return { snapshots, unavailableHosts };
954
+ }
758
955
  function parsePlacementPolicy(raw) {
759
956
  const value = raw.trim();
760
957
  if (value === "local")
@@ -1955,6 +2152,8 @@ async function runCreate(ctx, opts) {
1955
2152
  let placementHostOverride;
1956
2153
  let autoPlacementChoice;
1957
2154
  let autoPlacementSkippedHosts = [];
2155
+ let balanceHostLoad;
2156
+ let balanceTargetHost;
1958
2157
  let inheritedPlacement;
1959
2158
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim() || undefined;
1960
2159
  if (requestedPlacement === undefined &&
@@ -2059,6 +2258,11 @@ async function runCreate(ctx, opts) {
2059
2258
  }
2060
2259
  let pinnedAccount;
2061
2260
  const accountWasExplicit = Boolean(opts.account && opts.account.trim() !== "");
2261
+ const explicitBalanceAccount = accountWasExplicit
2262
+ ? opts.account.trim()
2263
+ : profileAccounts === undefined
2264
+ ? profileAccount
2265
+ : undefined;
2062
2266
  if (autoPlacementChoice !== undefined) {
2063
2267
  pinnedAccount = autoPlacementChoice.account;
2064
2268
  }
@@ -2227,6 +2431,24 @@ async function runCreate(ctx, opts) {
2227
2431
  }
2228
2432
  }
2229
2433
  const allowedAccounts = validatedAccounts.size > 0 ? [...validatedAccounts] : profileAccounts;
2434
+ let accountSelection;
2435
+ if (provider === "claude" && ctx.laneClaudeAccountBalanceEnabled()) {
2436
+ const targetHost = placementHostOverride ?? (outpostId !== undefined
2437
+ ? { kind: "outpost", id: outpostId }
2438
+ : laneHostFromCtx(ctx));
2439
+ balanceTargetHost = targetHost;
2440
+ balanceHostLoad = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
2441
+ const selected = selectClaudeAccount({
2442
+ hosts: balanceHostLoad.snapshots,
2443
+ targetHost,
2444
+ config,
2445
+ ...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
2446
+ ...(explicitBalanceAccount !== undefined ? { explicitAccount: explicitBalanceAccount } : {}),
2447
+ unavailableHosts: balanceHostLoad.unavailableHosts,
2448
+ });
2449
+ pinnedAccount = selected.account;
2450
+ accountSelection = { ...selected, selected_at: ctx.now().toISOString() };
2451
+ }
2230
2452
  const balancedAdmissionAccounts = !outpostId && pinnedAccount === undefined
2231
2453
  ? [...new Set(allowedAccounts ??
2232
2454
  Object.entries(config.providers[provider].accounts)
@@ -2239,6 +2461,38 @@ async function runCreate(ctx, opts) {
2239
2461
  const persist = async () => {
2240
2462
  let admission;
2241
2463
  let validation;
2464
+ if (!outpostId && provider === "claude" && explicitBalanceAccount === undefined &&
2465
+ balanceHostLoad !== undefined && balanceTargetHost !== undefined) {
2466
+ const localHost = laneHostFromCtx(ctx);
2467
+ if (localHost.kind === balanceTargetHost.kind && localHost.id === balanceTargetHost.id) {
2468
+ const localLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === localHost.kind && lane.host.id === localHost.id);
2469
+ const refreshedLocal = {
2470
+ capacity: buildHostCapacitySnapshot({
2471
+ host: localHost,
2472
+ lanes: localLanes,
2473
+ config,
2474
+ existsSync: ctx.existsSync,
2475
+ probe: ctx.probeProvider,
2476
+ env: ctx.env,
2477
+ now: ctx.now(),
2478
+ }),
2479
+ lanes: localLanes,
2480
+ laneListAvailable: true,
2481
+ };
2482
+ const hosts = balanceHostLoad.snapshots.map((host) => host.capacity.host.kind === localHost.kind && host.capacity.host.id === localHost.id
2483
+ ? refreshedLocal
2484
+ : host);
2485
+ const selected = selectClaudeAccount({
2486
+ hosts,
2487
+ targetHost: balanceTargetHost,
2488
+ config,
2489
+ ...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
2490
+ unavailableHosts: balanceHostLoad.unavailableHosts,
2491
+ });
2492
+ pinnedAccount = selected.account;
2493
+ accountSelection = { ...selected, selected_at: ctx.now().toISOString() };
2494
+ }
2495
+ }
2242
2496
  if (!outpostId && pinnedAccount === undefined) {
2243
2497
  pinnedAccount = resolveAccount(provider, undefined, config, {
2244
2498
  hqRoot,
@@ -2337,6 +2591,7 @@ async function runCreate(ctx, opts) {
2337
2591
  : {}),
2338
2592
  provider,
2339
2593
  account: pinnedAccount,
2594
+ ...(accountSelection !== undefined ? { account_selection: accountSelection } : {}),
2340
2595
  ...(defaultPlacement !== undefined
2341
2596
  ? { placement_default: defaultPlacement }
2342
2597
  : {}),
@@ -3113,6 +3368,8 @@ async function runCapacity(ctx) {
3113
3368
  lanes: loadAllLanes(hqRoot),
3114
3369
  config: loadLanesConfig(hqRoot),
3115
3370
  existsSync: ctx.existsSync,
3371
+ probe: ctx.probeProvider,
3372
+ env: ctx.env,
3116
3373
  now: ctx.now(),
3117
3374
  });
3118
3375
  printJson(snapshot);
@@ -4062,13 +4319,15 @@ async function prepareAccountMove(ctx, lane, input) {
4062
4319
  // (`pending_resume` set by this write, cleared by the claim), so two moves
4063
4320
  // into one free slot cannot both start.
4064
4321
  return withLaneAdmissionLock(hqRoot, lane.provider, async () => {
4065
- const admission = await assessLaneAdmission(ctx, lane, {
4066
- account: target,
4067
- queueAt: at,
4068
- });
4069
- if (admission.action === "refuse" && admission.error)
4322
+ const admission = input.deferTurnStart
4323
+ ? undefined
4324
+ : await assessLaneAdmission(ctx, lane, {
4325
+ account: target,
4326
+ queueAt: at,
4327
+ });
4328
+ if (admission?.action === "refuse" && admission.error)
4070
4329
  throw admission.error;
4071
- const queued = admission.action === "queue";
4330
+ const queued = admission?.action === "queue";
4072
4331
  const moved = await withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
4073
4332
  // The copy and every check above were made from `lane`; a record
4074
4333
  // that moved on since (another move, a spawn, a cancel) invalidates
@@ -4080,6 +4339,7 @@ async function prepareAccountMove(ctx, lane, input) {
4080
4339
  const next = {
4081
4340
  ...current,
4082
4341
  account: target,
4342
+ ...(input.accountSelection !== undefined ? { account_selection: input.accountSelection } : {}),
4083
4343
  admission_accounts: undefined,
4084
4344
  provider_session_id: sessionId,
4085
4345
  provider_session_id_absent: undefined,
@@ -4092,11 +4352,16 @@ async function prepareAccountMove(ctx, lane, input) {
4092
4352
  at,
4093
4353
  reason: SESSION_MOVE_REASON,
4094
4354
  }),
4095
- pending_resume: { session_id: sessionId, at, ...input.pending },
4355
+ pending_resume: input.deferTurnStart
4356
+ ? undefined
4357
+ : { session_id: sessionId, at, ...input.pending },
4096
4358
  };
4097
- if (queued) {
4359
+ if (input.deferTurnStart) {
4360
+ next.state = current.state;
4361
+ }
4362
+ else if (queued) {
4098
4363
  next.state = "queued";
4099
- next.queued_reason = admission.reason ?? "throttle";
4364
+ next.queued_reason = admission?.reason ?? "throttle";
4100
4365
  next.queued_at = at;
4101
4366
  }
4102
4367
  else {
@@ -4107,6 +4372,54 @@ async function prepareAccountMove(ctx, lane, input) {
4107
4372
  return { lane: moved, from, to: target, copy, queued };
4108
4373
  }, ctx.sleep);
4109
4374
  }
4375
+ async function selectAutomaticClaudeResumeAccount(ctx, lane) {
4376
+ if (lane.provider !== "claude")
4377
+ return undefined;
4378
+ if (!ctx.laneClaudeAccountBalanceEnabled()) {
4379
+ if (lane.account === undefined)
4380
+ return undefined;
4381
+ const config = loadLanesConfig(ctx.hqRoot());
4382
+ const currentConfig = config.providers.claude.accounts[lane.account];
4383
+ if (currentConfig === undefined || !currentConfig.enabled) {
4384
+ throw new AccountResolutionError(currentConfig === undefined ? "unknown_account" : "account_disabled", currentConfig === undefined
4385
+ ? `Lane ${lane.lane_id} records account ${JSON.stringify(lane.account)} which is not configured for claude`
4386
+ : `Account ${JSON.stringify(lane.account)} is unavailable for claude`, { provider: "claude", account: lane.account });
4387
+ }
4388
+ if (accountStatus("claude", lane.account, config, {
4389
+ existsSync: ctx.existsSync,
4390
+ probe: ctx.probeProvider,
4391
+ env: ctx.env,
4392
+ }).logged_in === false) {
4393
+ throw new LanesError("account_not_logged_in", `Account ${lane.account} for claude is not logged in`, { provider: "claude", account: lane.account, lane_id: lane.lane_id });
4394
+ }
4395
+ return undefined;
4396
+ }
4397
+ const hqRoot = ctx.hqRoot();
4398
+ const config = loadLanesConfig(hqRoot);
4399
+ const currentConfig = lane.account === undefined
4400
+ ? undefined
4401
+ : config.providers.claude.accounts[lane.account];
4402
+ const currentLogin = lane.account === undefined || currentConfig === undefined
4403
+ ? false
4404
+ : accountStatus("claude", lane.account, config, {
4405
+ existsSync: ctx.existsSync,
4406
+ probe: ctx.probeProvider,
4407
+ env: ctx.env,
4408
+ }).logged_in;
4409
+ const accountNeedsRepair = currentConfig?.enabled !== true || currentLogin === false;
4410
+ const mayRebalance = BALANCE_MOVABLE_STATES.includes(lane.state);
4411
+ if (!accountNeedsRepair && !mayRebalance)
4412
+ return undefined;
4413
+ const balanceHosts = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
4414
+ const selected = selectClaudeAccount({
4415
+ hosts: balanceHosts.snapshots,
4416
+ targetHost: lane.host,
4417
+ config,
4418
+ ...(lane.admission_accounts !== undefined ? { allowedAccounts: lane.admission_accounts } : {}),
4419
+ unavailableHosts: balanceHosts.unavailableHosts,
4420
+ });
4421
+ return { ...selected, selected_at: ctx.now().toISOString() };
4422
+ }
4110
4423
  async function runMessage(ctx, laneId, opts) {
4111
4424
  const hqRoot = ctx.hqRoot();
4112
4425
  const text = opts.text;
@@ -4184,6 +4497,31 @@ async function runMessage(ctx, laneId, opts) {
4184
4497
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
4185
4498
  }
4186
4499
  }
4500
+ else if (mechanism === "resume") {
4501
+ const selection = await selectAutomaticClaudeResumeAccount(ctx, lane);
4502
+ if (selection !== undefined) {
4503
+ if (selection.account !== lane.account) {
4504
+ move = await prepareAccountMove(ctx, lane, {
4505
+ account: selection.account,
4506
+ cliModel: undefined,
4507
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
4508
+ pending: {
4509
+ trigger: "message",
4510
+ text,
4511
+ message_id: messageId,
4512
+ compact: opts.compact !== false,
4513
+ },
4514
+ accountSelection: selection,
4515
+ });
4516
+ lane = move.lane;
4517
+ if (move.queued)
4518
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
4519
+ }
4520
+ else {
4521
+ lane = persistClaudeAccountSelection(hqRoot, lane, selection);
4522
+ }
4523
+ }
4524
+ }
4187
4525
  if (resumeProfile !== undefined) {
4188
4526
  if (opts.account === undefined)
4189
4527
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
@@ -4357,6 +4695,150 @@ function accountMoveSummary(move) {
4357
4695
  target_dir: move.copy.target_dir,
4358
4696
  };
4359
4697
  }
4698
+ function persistClaudeAccountSelection(hqRoot, lane, selection) {
4699
+ return casUpdateLane(hqRoot, lane.lane_id, (current) => {
4700
+ if (current.version !== lane.version || current.state !== lane.state || current.account !== lane.account) {
4701
+ throw new LanesError("claude_account_balance_selection_conflict", `Lane ${lane.lane_id} changed while Claude account balance was being selected`, {
4702
+ lane_id: lane.lane_id,
4703
+ prepared_version: lane.version,
4704
+ version: current.version,
4705
+ state: current.state,
4706
+ account: current.account ?? null,
4707
+ });
4708
+ }
4709
+ return { ...current, account_selection: selection };
4710
+ });
4711
+ }
4712
+ async function applyClaudeBalanceMove(ctx, suggestion) {
4713
+ const currentHost = reconcileHostFromCtx(ctx);
4714
+ const suggestionRunsHere = suggestion.host.kind === "outpost"
4715
+ ? currentHost.outpostId === suggestion.host.id
4716
+ : currentHost.outpostId === null && currentHost.hostname === suggestion.host.id;
4717
+ if (suggestion.host.kind === "outpost" && !suggestionRunsHere) {
4718
+ let result;
4719
+ try {
4720
+ result = await ctx.dispatchOutpostExec({
4721
+ outpostId: suggestion.host.id,
4722
+ command: buildOutpostBalanceMoveCommand({
4723
+ laneId: suggestion.lane_id,
4724
+ account: suggestion.to,
4725
+ fromAccount: suggestion.from,
4726
+ expectedState: suggestion.state,
4727
+ }),
4728
+ timeoutSeconds: 120,
4729
+ waitMs: 120_000,
4730
+ });
4731
+ }
4732
+ catch (err) {
4733
+ throw new LanesError("claude_account_balance_move_failed", `Could not move lane ${suggestion.lane_id} on Outpost ${suggestion.host.id}: ${dispatchErrorMessage(err)}`, { lane_id: suggestion.lane_id, host: suggestion.host, account: suggestion.to });
4734
+ }
4735
+ if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
4736
+ throw new LanesError("claude_account_balance_move_failed", `Outpost ${suggestion.host.id} did not confirm the account move for lane ${suggestion.lane_id}`, { lane_id: suggestion.lane_id, host: suggestion.host, account: suggestion.to, status: result.status, exit_code: result.exitCode });
4737
+ }
4738
+ const remote = parseRemoteJson(result.stdout, "Claude account balance move");
4739
+ if (remote?.ok !== true || remote.action !== "balance-move-target" ||
4740
+ remote.lane_id !== suggestion.lane_id || remote.from !== suggestion.from ||
4741
+ remote.account !== suggestion.to ||
4742
+ remote.state !== suggestion.state) {
4743
+ throw new LanesError("claude_account_balance_move_unconfirmed", `Outpost ${suggestion.host.id} returned an invalid account-move result`, {
4744
+ lane_id: suggestion.lane_id,
4745
+ host: suggestion.host,
4746
+ account: suggestion.to,
4747
+ });
4748
+ }
4749
+ return { lane_id: suggestion.lane_id, host: suggestion.host, from: suggestion.from, to: suggestion.to, state: suggestion.state };
4750
+ }
4751
+ const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), suggestion.lane_id));
4752
+ if (lane.state === "running") {
4753
+ throw new LanesError("lane_running", `Lane ${lane.lane_id} is running; balance never moves a running turn`, {
4754
+ lane_id: lane.lane_id,
4755
+ state: lane.state,
4756
+ });
4757
+ }
4758
+ if (lane.host.kind !== suggestion.host.kind || lane.host.id !== suggestion.host.id ||
4759
+ lane.account !== suggestion.from || lane.state !== suggestion.state) {
4760
+ throw new LanesError("claude_account_balance_lane_changed", `Lane ${lane.lane_id} changed after balance was calculated`, {
4761
+ lane_id: lane.lane_id,
4762
+ host: lane.host,
4763
+ account: lane.account ?? null,
4764
+ });
4765
+ }
4766
+ if (!BALANCE_MOVABLE_STATES.includes(lane.state)) {
4767
+ throw new LanesError("claude_account_balance_state_not_movable", `Lane ${lane.lane_id} is no longer between turns`, {
4768
+ lane_id: lane.lane_id,
4769
+ state: lane.state,
4770
+ });
4771
+ }
4772
+ const moved = await prepareAccountMove(ctx, lane, {
4773
+ account: suggestion.to,
4774
+ cliModel: undefined,
4775
+ deferTurnStart: true,
4776
+ });
4777
+ return { lane_id: moved.lane.lane_id, host: moved.lane.host, from: moved.from ?? suggestion.from, to: moved.to, state: moved.lane.state };
4778
+ }
4779
+ async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
4780
+ assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
4781
+ const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), laneId));
4782
+ assertLaneRunsHereForOperatorMutation(ctx, lane, "balance-move-target");
4783
+ if (lane.provider !== "claude") {
4784
+ throw new LanesError("claude_account_balance_provider_mismatch", `Lane ${laneId} does not use Claude`, {
4785
+ lane_id: laneId,
4786
+ provider: lane.provider,
4787
+ });
4788
+ }
4789
+ const expectedFrom = opts.fromAccount?.trim();
4790
+ if (!expectedFrom) {
4791
+ throw new LanesError("claude_account_balance_source_invalid", "Balance move requires --from-account <id>", {
4792
+ lane_id: laneId,
4793
+ });
4794
+ }
4795
+ if (lane.account !== expectedFrom) {
4796
+ throw new LanesError("claude_account_balance_source_changed", `Lane ${laneId} no longer uses the planned source account`, {
4797
+ lane_id: laneId,
4798
+ expected_account: expectedFrom,
4799
+ actual_account: lane.account ?? null,
4800
+ });
4801
+ }
4802
+ const expectedState = opts.expectedState?.trim();
4803
+ if (!expectedState) {
4804
+ throw new LanesError("claude_account_balance_state_invalid", "Balance move requires --expected-state <state>", {
4805
+ lane_id: laneId,
4806
+ });
4807
+ }
4808
+ if (lane.state !== expectedState) {
4809
+ throw new LanesError("claude_account_balance_state_changed", `Lane ${laneId} changed state after balance was calculated`, {
4810
+ lane_id: laneId,
4811
+ expected_state: expectedState,
4812
+ actual_state: lane.state,
4813
+ });
4814
+ }
4815
+ if (!BALANCE_MOVABLE_STATES.includes(lane.state) || lane.pending_resume !== undefined) {
4816
+ throw new LanesError("claude_account_balance_state_not_movable", `Lane ${laneId} is not between turns`, {
4817
+ lane_id: laneId,
4818
+ state: lane.state,
4819
+ pending_resume: lane.pending_resume !== undefined,
4820
+ });
4821
+ }
4822
+ const account = opts.account?.trim();
4823
+ if (!account) {
4824
+ throw new LanesError("claude_account_balance_account_invalid", "Balance move requires --account <id>", {
4825
+ lane_id: laneId,
4826
+ });
4827
+ }
4828
+ const moved = await prepareAccountMove(ctx, lane, {
4829
+ account,
4830
+ cliModel: undefined,
4831
+ deferTurnStart: true,
4832
+ });
4833
+ printJson({
4834
+ ok: true,
4835
+ action: "balance-move-target",
4836
+ lane_id: laneId,
4837
+ from: moved.from,
4838
+ state: moved.lane.state,
4839
+ account: moved.lane.account,
4840
+ });
4841
+ }
4360
4842
  function laneWithRecoveredProviderSession(hqRoot, lane) {
4361
4843
  if (lane.provider === "grok")
4362
4844
  return lane;
@@ -4456,6 +4938,9 @@ async function runResume(ctx, laneId, opts) {
4456
4938
  if (resumeProfile !== undefined && opts.account === undefined) {
4457
4939
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
4458
4940
  }
4941
+ const automaticAccountSelection = opts.account === undefined
4942
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane)
4943
+ : undefined;
4459
4944
  let move = null;
4460
4945
  if (opts.account !== undefined) {
4461
4946
  move = await prepareAccountMove(ctx, lane, {
@@ -4472,6 +4957,27 @@ async function runResume(ctx, laneId, opts) {
4472
4957
  });
4473
4958
  lane = move.lane;
4474
4959
  }
4960
+ else if (automaticAccountSelection !== undefined) {
4961
+ if (automaticAccountSelection.account !== lane.account) {
4962
+ move = await prepareAccountMove(ctx, lane, {
4963
+ account: automaticAccountSelection.account,
4964
+ cliModel: undefined,
4965
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
4966
+ pending: {
4967
+ trigger: "resume",
4968
+ resumed_from: resumedFrom,
4969
+ ...(text !== undefined ? { text } : {}),
4970
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
4971
+ compact: opts.compact !== false,
4972
+ },
4973
+ accountSelection: automaticAccountSelection,
4974
+ });
4975
+ lane = move.lane;
4976
+ }
4977
+ else {
4978
+ lane = persistClaudeAccountSelection(hqRoot, lane, automaticAccountSelection);
4979
+ }
4980
+ }
4475
4981
  lane = recordResumeSetup(ctx, lane, {
4476
4982
  ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
4477
4983
  ...(requestedMeshCompany !== undefined
@@ -5750,6 +6256,105 @@ async function runAccountsList(ctx, opts) {
5750
6256
  accounts,
5751
6257
  });
5752
6258
  }
6259
+ async function runAccountsBalance(ctx, opts) {
6260
+ const provider = parseLaneProvider(opts.provider ?? "");
6261
+ if (provider !== "claude") {
6262
+ throw new LanesError("claude_account_balance_provider_unsupported", "Account balance is available only for --provider claude", {
6263
+ provider,
6264
+ accepted: ["claude"],
6265
+ });
6266
+ }
6267
+ if (opts.apply)
6268
+ assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
6269
+ const hqRoot = ctx.hqRoot();
6270
+ const config = loadLanesConfig(hqRoot);
6271
+ const loaded = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
6272
+ const loads = accountLoadsForReport({ hosts: loaded.snapshots, config });
6273
+ const suggestions = planClaudeBalanceMoves({ hosts: loaded.snapshots, config });
6274
+ const selections = loaded.snapshots.map((host) => {
6275
+ try {
6276
+ const selection = selectClaudeAccount({
6277
+ hosts: loaded.snapshots,
6278
+ targetHost: host.capacity.host,
6279
+ config,
6280
+ unavailableHosts: loaded.unavailableHosts,
6281
+ });
6282
+ return {
6283
+ target_host: host.capacity.host,
6284
+ account: selection.account,
6285
+ signal: selection.signal,
6286
+ skipped_accounts: selection.skipped_accounts,
6287
+ };
6288
+ }
6289
+ catch (err) {
6290
+ return {
6291
+ target_host: host.capacity.host,
6292
+ error: isLanesError(err) ? err.error : "claude_account_balance_selection_failed",
6293
+ message: dispatchErrorMessage(err),
6294
+ };
6295
+ }
6296
+ });
6297
+ const accounts = Object.keys(config.providers.claude.accounts).sort().map((account) => {
6298
+ const load = loads.find((row) => row.account === account);
6299
+ return {
6300
+ account,
6301
+ enabled: config.providers.claude.accounts[account]?.enabled === true,
6302
+ live_lanes: load?.live_lanes ?? 0,
6303
+ counts_complete: load?.counts_complete ?? false,
6304
+ hosts: loaded.snapshots.map((host) => {
6305
+ const accountStatus = host.capacity.providers.claude.accounts[account];
6306
+ const accountLoad = load?.hosts.find((row) => row.host.kind === host.capacity.host.kind && row.host.id === host.capacity.host.id);
6307
+ return {
6308
+ host: host.capacity.host,
6309
+ enabled: accountStatus?.enabled ?? false,
6310
+ logged_in: accountStatus?.logged_in ?? null,
6311
+ live_lanes: accountLoad?.live_lanes ?? null,
6312
+ lane_counts: accountLoad?.lane_counts ?? { running: null, queued: null, awaiting_input: null },
6313
+ count_source: accountLoad?.count_source ?? "unavailable",
6314
+ usage: accountStatus?.usage ?? null,
6315
+ };
6316
+ }),
6317
+ };
6318
+ });
6319
+ const moved = [];
6320
+ const skipped = [];
6321
+ if (opts.apply) {
6322
+ for (const suggestion of suggestions) {
6323
+ if (!suggestion.apply) {
6324
+ skipped.push({
6325
+ lane_id: suggestion.lane_id,
6326
+ host: suggestion.host,
6327
+ from: suggestion.from,
6328
+ to: suggestion.to,
6329
+ reason: suggestion.skip_reason ?? "not_movable",
6330
+ });
6331
+ continue;
6332
+ }
6333
+ try {
6334
+ moved.push(await applyClaudeBalanceMove(ctx, suggestion));
6335
+ }
6336
+ catch (err) {
6337
+ skipped.push({
6338
+ lane_id: suggestion.lane_id,
6339
+ host: suggestion.host,
6340
+ from: suggestion.from,
6341
+ to: suggestion.to,
6342
+ reason: isLanesError(err) ? `${err.error}: ${err.message}` : dispatchErrorMessage(err),
6343
+ });
6344
+ }
6345
+ }
6346
+ }
6347
+ printJson({
6348
+ ok: true,
6349
+ action: "accounts.balance",
6350
+ provider: "claude",
6351
+ accounts,
6352
+ selections,
6353
+ unavailable_hosts: loaded.unavailableHosts,
6354
+ suggestions,
6355
+ ...(opts.apply ? { moved, skipped } : {}),
6356
+ });
6357
+ }
5753
6358
  async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
5754
6359
  const callerCwd = ctx.callerCwd();
5755
6360
  const hqRoot = ctx.hqRoot();
@@ -6123,6 +6728,15 @@ export function registerLanesCommand(parent, deps = {}) {
6123
6728
  .action(wrap(async (laneId, opts) => {
6124
6729
  await runMoveTarget(ctx, laneId, opts);
6125
6730
  }));
6731
+ lanes
6732
+ .command("balance-move-target", { hidden: true })
6733
+ .argument("<lane-id>", "Lane id")
6734
+ .requiredOption("--account <id>", "Target Claude account id")
6735
+ .requiredOption("--from-account <id>", "Expected source Claude account id")
6736
+ .requiredOption("--expected-state <state>", "Expected lane state from the balance plan")
6737
+ .action(wrap(async (laneId, opts) => {
6738
+ await runClaudeBalanceMoveTarget(ctx, laneId, opts);
6739
+ }));
6126
6740
  lanes
6127
6741
  .command("admit")
6128
6742
  .description("Try to admit the oldest eligible queued lane")
@@ -6321,6 +6935,15 @@ export function registerLanesCommand(parent, deps = {}) {
6321
6935
  .action(wrap(async (opts) => {
6322
6936
  await runAccountsList(ctx, opts);
6323
6937
  }));
6938
+ accounts
6939
+ .command("balance")
6940
+ .description("Report Claude account load and suggest safe between-turn moves")
6941
+ .requiredOption("--provider <name>", "claude")
6942
+ .option("--apply", "Move eligible between-turn sessions (requires the Claude account balance gate)")
6943
+ .option("--json", "JSON on stdout (always)")
6944
+ .action(wrap(async (opts) => {
6945
+ await runAccountsBalance(ctx, opts);
6946
+ }));
6324
6947
  accounts
6325
6948
  .command("login")
6326
6949
  .description("Create a home, run the vendor login, and register the account")