@sentropic/h2a-runtime 0.93.5 → 0.94.1

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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSyn
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, isAbsolute, join, resolve } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
- import { Command } from "commander";
8
+ import { Command, Help } from "commander";
9
9
  import { applyRuntimeHelpGroups, groupRuntimeHelpItems, } from "./cli-help-groups.js";
10
10
  import { attach, createRemoteSession, getRemoteSession, listRemoteSessions, refreshRemoteSession, renameRemoteSession, sessionTerminalHealth, stopRemoteSession, } from "./attach.js";
11
11
  import { authHeaders, clearDefaultRemote, getDefaultRemote, getDefaultTarget, getDefaultTools, getH2aConfig, getJobMaxAgeHours, getMaxConcurrent, getLlmMeshRuntimeConfig, getTmuxProfileConfig, getTunnel, resolveConfigPath, setDefaultRemote, setDefaultTarget, migrateConfigHomeIfNeeded, setDefaultTools, setLlmMeshRuntimeConfig, setTmuxProfileConfig, setToken, setTunnel, } from "./config.js";
@@ -65,11 +65,10 @@ import { listMigrationCandidates, humanSize, humanAge, } from "./migrate-candida
65
65
  import { acquireLease, createLineage, handoffLease, isIncarnationSuspended, listLineages, resumeLocalIncarnation, suspendLocalIncarnation, updateLineage, } from "./lineage-lease.js";
66
66
  import { checkReadiness } from "./readiness.js";
67
67
  import { createInterface } from "node:readline";
68
- import { appendSessionLogEntry, clearBinding, clearExhaustion, enrollAccount, listAccounts, listAccountsWithTokens, listAccountsWithStatus, listBindings, lookupBinding, markExhausted, readClaudeCredential, readCodexCredential, removeAccount, selectAccount, selectAccountWithFallback, stickyBind, loadCandidates, sessionLogPath, exportSessionLogToS3, QUOTA_WINDOW_5H_MS, QUOTA_WINDOW_WEEK_MS, } from "./account-pool.js";
69
68
  const H2A_RUN_API_VERSION = "h2a.run/v1";
70
69
  const H2A_RUNTIME_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
71
70
  import { CLI_PROFILES, gatewayModeForProfile, profileUsesLlmMeshGateway, } from "./protocol-local.js";
72
- import { enrollViaFacade, readLlmMeshConfig, startGateway, stopGateway, readGatewayPid, llmMeshLogPath, replaceAnthropicGatewayEnvironment, acquireLlmMeshSessionEnv, updateLlmMeshRoutingConfig, } from "./llm-mesh.js";
71
+ import { enrollViaFacade, formatLlmMeshAccountError, formatLlmMeshAccountList, listAccountsViaFacade, readLlmMeshConfig, startGateway, stopGateway, readGatewayPid, llmMeshLogPath, replaceAnthropicGatewayEnvironment, removeAccountViaFacade, acquireLlmMeshSessionEnv, updateLlmMeshRoutingConfig, } from "./llm-mesh.js";
73
72
  import { describeLlmMeshRoutingConfig, parseLlmMeshRoutingConfig, preferredRoutingConfig, strategyRoutingConfig, } from "./llm-routing-config.js";
74
73
  const KNOWN_PROFILE_HELP = `${CLI_PROFILES.join(", ")} (aliases: claude-code, antigravity, gemini-cli, mistralcli)`;
75
74
  export const packageName = "@sentropic/h2a-runtime";
@@ -833,51 +832,6 @@ export async function startJob(job) {
833
832
  catch (err) {
834
833
  return { started: false, error: err.message };
835
834
  }
836
- // Account-pool credential injection (WP16 Layer-C).
837
- // For claude-code and codex jobs, select the best available account and inject
838
- // its credential before spawning. tmux inherits process.env, so we set/restore
839
- // around the spawn (same pattern as DEPTH_ENV). agy has no pool → skip.
840
- // If no accounts are enrolled or all are exhausted, we proceed with existing env.
841
- const accountEnvOverrides = {};
842
- if (job.tool === "claude" || job.tool === "codex") {
843
- const preferredProvider = job.tool === "claude" ? "claude-code" : "codex";
844
- // Forced account (--account <id>): look it up directly, bypassing the pool.
845
- const forcedCandidate = job.accountId !== undefined
846
- ? loadCandidates(undefined).find((c) => c.id === job.accountId)
847
- : undefined;
848
- const sel = forcedCandidate !== undefined
849
- ? { candidate: forcedCandidate, crossProvider: false }
850
- : selectAccountWithFallback(preferredProvider, job.id);
851
- if (!("allExhausted" in sel) && sel.candidate !== undefined) {
852
- if (sel.crossProvider) {
853
- const msg = `[h2a] account-pool: all ${preferredProvider} accounts exhausted — ` +
854
- `falling back to ${sel.candidate.provider} (${sel.candidate.label})`;
855
- process.stderr.write(msg + "\n");
856
- // Notify the current tmux pane (non-blocking, best-effort).
857
- if (process.env.TMUX) {
858
- spawnSync("tmux", ["display-message", msg], { stdio: "ignore" });
859
- }
860
- }
861
- stickyBind(job.id, sel.candidate.id, sel.candidate.provider);
862
- // Append a line to the local session log for audit / future S3 export.
863
- appendSessionLogEntry({
864
- jobId: job.id,
865
- preferredProvider,
866
- selectedProvider: sel.candidate.provider,
867
- accountId: sel.candidate.id,
868
- accountLabel: sel.candidate.label,
869
- crossProvider: sel.crossProvider,
870
- });
871
- if (sel.candidate.provider === "codex") {
872
- accountEnvOverrides["OPENAI_API_KEY"] = sel.candidate.accessToken;
873
- }
874
- else if (sel.candidate.provider === "claude-code" &&
875
- sel.candidate.configDir) {
876
- accountEnvOverrides["CLAUDE_CONFIG_DIR"] = sel.candidate.configDir;
877
- }
878
- }
879
- // allExhausted: proceed with existing env (fail gracefully at the CLI level)
880
- }
881
835
  // Propagate the child's remaining spawn-depth budget through the env so a job
882
836
  // that itself runs `h2a delegate` inherits a DECREMENTED budget (depth=0 →
883
837
  // refuse). tmux inherits the spawning process's env, so set it around spawn.
@@ -886,13 +840,8 @@ export async function startJob(job) {
886
840
  // not the job slug), so an interactive tmux job actually completes.
887
841
  const prevDepth = process.env[DEPTH_ENV];
888
842
  const prevJobId = process.env[JOB_ID_ENV];
889
- const prevAccountEnvs = {};
890
843
  process.env[DEPTH_ENV] = childDepthEnvValue(job.depthBudget ?? clampDepth(undefined));
891
844
  process.env[JOB_ID_ENV] = job.id;
892
- for (const [k, v] of Object.entries(accountEnvOverrides)) {
893
- prevAccountEnvs[k] = process.env[k];
894
- process.env[k] = v;
895
- }
896
845
  // Inject llm-mesh gateway env if running. Do not trust a parent env that may
897
846
  // contain an old gateway token from a previous restart.
898
847
  // tmux inherits process.env, so claude + its subagents all get the gateway automatically.
@@ -913,12 +862,6 @@ export async function startJob(job) {
913
862
  : null;
914
863
  }
915
864
  catch (error) {
916
- for (const [key, previous] of Object.entries(prevAccountEnvs)) {
917
- if (previous === undefined)
918
- delete process.env[key];
919
- else
920
- process.env[key] = previous;
921
- }
922
865
  return {
923
866
  started: false,
924
867
  error: error instanceof Error ? error.message : String(error),
@@ -976,12 +919,6 @@ export async function startJob(job) {
976
919
  delete process.env[JOB_ID_ENV];
977
920
  else
978
921
  process.env[JOB_ID_ENV] = prevJobId;
979
- for (const [k, prev] of Object.entries(prevAccountEnvs)) {
980
- if (prev === undefined)
981
- delete process.env[k];
982
- else
983
- process.env[k] = prev;
984
- }
985
922
  }
986
923
  enroll({
987
924
  id: job.id,
@@ -1218,44 +1155,10 @@ export function resumeThrottledJob(job) {
1218
1155
  catch (err) {
1219
1156
  return { started: false, error: err.message };
1220
1157
  }
1221
- // Same env plumbing as startJob (depth budget + REMOTE_JOB_ID + account pool).
1222
- const preferredProvider = job.tool === "codex" ? "codex" : "claude-code";
1223
- const sel = selectAccountWithFallback(preferredProvider, job.id);
1224
- const accountEnvOverrides = {};
1225
- if ("allExhausted" in sel) {
1226
- process.stderr.write(`[h2a] account-pool: all accounts exhausted for job ${job.id} — resuming without account override (may throttle again)\n`);
1227
- }
1228
- else if (sel.candidate !== undefined) {
1229
- if (sel.crossProvider) {
1230
- const msg = `[h2a] account-pool: all ${preferredProvider} accounts exhausted — falling back to ${sel.candidate.provider} (${sel.candidate.label}) for resume`;
1231
- process.stderr.write(msg + "\n");
1232
- if (process.env.TMUX)
1233
- spawnSync("tmux", ["display-message", msg], { stdio: "ignore" });
1234
- }
1235
- stickyBind(job.id, sel.candidate.id, sel.candidate.provider);
1236
- appendSessionLogEntry({
1237
- jobId: job.id,
1238
- preferredProvider,
1239
- selectedProvider: sel.candidate.provider,
1240
- accountId: sel.candidate.id,
1241
- accountLabel: sel.candidate.label,
1242
- crossProvider: sel.crossProvider,
1243
- });
1244
- if (sel.candidate.provider === "codex") {
1245
- accountEnvOverrides["OPENAI_API_KEY"] = sel.candidate.accessToken;
1246
- }
1247
- else if (sel.candidate.provider === "claude-code" &&
1248
- sel.candidate.configDir) {
1249
- accountEnvOverrides["CLAUDE_CONFIG_DIR"] = sel.candidate.configDir;
1250
- }
1251
- }
1158
+ // Same env plumbing as startJob (depth budget + REMOTE_JOB_ID). Provider
1159
+ // credentials remain owned by the native CLI or by llm-mesh.
1252
1160
  const prevDepth = process.env[DEPTH_ENV];
1253
1161
  const prevJobId = process.env[JOB_ID_ENV];
1254
- const prevAccountEnvs = {};
1255
- for (const [k, v] of Object.entries(accountEnvOverrides)) {
1256
- prevAccountEnvs[k] = process.env[k];
1257
- process.env[k] = v;
1258
- }
1259
1162
  process.env[DEPTH_ENV] = childDepthEnvValue(job.depthBudget ?? clampDepth(undefined));
1260
1163
  process.env[JOB_ID_ENV] = job.id;
1261
1164
  let tmuxSession;
@@ -1287,12 +1190,6 @@ export function resumeThrottledJob(job) {
1287
1190
  delete process.env[JOB_ID_ENV];
1288
1191
  else
1289
1192
  process.env[JOB_ID_ENV] = prevJobId;
1290
- for (const [k, prev] of Object.entries(prevAccountEnvs)) {
1291
- if (prev === undefined)
1292
- delete process.env[k];
1293
- else
1294
- process.env[k] = prev;
1295
- }
1296
1193
  }
1297
1194
  // throttled → running, keeping the throttle bookkeeping (attempts/firstAt) so a
1298
1195
  // re-throttle on the resumed run accumulates toward the cap. Atomic.
@@ -1450,13 +1347,11 @@ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true
1450
1347
  if (mode === "direct") {
1451
1348
  delete process.env.ANTHROPIC_BASE_URL;
1452
1349
  delete process.env.ANTHROPIC_AUTH_TOKEN;
1453
- delete process.env.ANTHROPIC_API_KEY;
1454
1350
  return undefined;
1455
1351
  }
1456
1352
  if (mode !== "gateway" && !getLlmMeshRuntimeConfig().enabled) {
1457
1353
  delete process.env.ANTHROPIC_BASE_URL;
1458
1354
  delete process.env.ANTHROPIC_AUTH_TOKEN;
1459
- delete process.env.ANTHROPIC_API_KEY;
1460
1355
  return undefined;
1461
1356
  }
1462
1357
  // A new local CLI session needs its own gateway token so account affinity is
@@ -1474,14 +1369,21 @@ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true
1474
1369
  process.stderr.write(`[h2a] llm-mesh: gateway was stopped; started on ${meshEnv.ANTHROPIC_BASE_URL}\n`);
1475
1370
  }
1476
1371
  catch (err) {
1477
- if (allowDirectFallback) {
1372
+ if (allowDirectFallback && mode !== "gateway") {
1478
1373
  process.stderr.write(`[h2a] llm-mesh: gateway env unavailable (${String(err)}); Claude may ask for login.\n`);
1479
1374
  }
1480
1375
  }
1481
1376
  }
1482
1377
  if (!meshEnv) {
1483
- if (mode === "gateway" && allowDirectFallback) {
1484
- process.stderr.write("[h2a] llm-mesh: --gw requested but no gateway env is available; continuing direct. Run `h2a llm-mesh start` or enroll an account first.\n");
1378
+ // A failed or disabled local gateway must not leak a stale H2A bearer into
1379
+ // a direct child. A user-owned API key remains untouched.
1380
+ delete process.env.ANTHROPIC_BASE_URL;
1381
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
1382
+ if (mode === "gateway") {
1383
+ throw new Error("llm-mesh gateway is required but unavailable; no agent was started. Run `h2a llm-mesh start` or `h2a llm-mesh account enroll codex|cloud-code` first");
1384
+ }
1385
+ if (allowDirectFallback) {
1386
+ process.stderr.write("[h2a] llm-mesh: gateway unavailable; continuing with native authentication.\n");
1485
1387
  }
1486
1388
  return undefined;
1487
1389
  }
@@ -1502,7 +1404,7 @@ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true
1502
1404
  export async function prepareStructuredGateway(mode, inject = (selectedMode) => injectLlmMeshGatewayEnv(selectedMode, selectedMode !== "gateway")) {
1503
1405
  const gateway = await inject(mode);
1504
1406
  if (mode === "gateway" && !gateway) {
1505
- throw new Error("llm-mesh gateway is required but unavailable; no agent was started");
1407
+ throw new Error("llm-mesh gateway is required but unavailable; no agent was started. Run `h2a llm-mesh start` or `h2a llm-mesh account enroll codex|cloud-code` first");
1506
1408
  }
1507
1409
  return gateway;
1508
1410
  }
@@ -3759,7 +3661,14 @@ export async function main(argv) {
3759
3661
  if (explicitProfile &&
3760
3662
  liveActTarget.kind === "local-tmux" &&
3761
3663
  currentTmuxSessionIs(liveActTarget.name)) {
3762
- gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, liveActTarget.name);
3664
+ try {
3665
+ gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, liveActTarget.name);
3666
+ }
3667
+ catch (error) {
3668
+ process.stderr.write(`[h2a] ${error.message}\n`);
3669
+ process.exitCode = 1;
3670
+ return;
3671
+ }
3763
3672
  persistLaunchContext(liveActTarget.name, buildLaunchContext({
3764
3673
  profile,
3765
3674
  cwd: entry.cwd,
@@ -3867,7 +3776,14 @@ export async function main(argv) {
3867
3776
  }
3868
3777
  }
3869
3778
  }
3870
- gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, localSessionName(resumeSlug));
3779
+ try {
3780
+ gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, localSessionName(resumeSlug));
3781
+ }
3782
+ catch (error) {
3783
+ process.stderr.write(`[h2a] ${error.message}\n`);
3784
+ process.exitCode = 1;
3785
+ return;
3786
+ }
3871
3787
  const sessionClass = deriveSessionClass({
3872
3788
  background: opts.attach !== true,
3873
3789
  humanTerminal: opts.attach === true &&
@@ -4486,7 +4402,6 @@ export async function main(argv) {
4486
4402
  .option("--track <wpId>", "mirror this job as a track item under workpackage <wpId> (`track item new --parent`, realized on done/failed). Best-effort: skipped silently if track is absent.")
4487
4403
  .option("--model <model>", "model override for the spawned agent (claude: --model; codex: -m). E.g. claude-sonnet-4-6, claude-opus-4-8, o3.")
4488
4404
  .option("--effort <level>", "reasoning-effort override (claude: --effort low|medium|high|xhigh|max; codex: model_reasoning_effort low|medium|high|xhigh).")
4489
- .option("--account <id>", "force a specific account from the pool (bypass selectAccountWithFallback). E.g. claude-code-1234567890.")
4490
4405
  .action(async (type, task, opts) => {
4491
4406
  if (!isDelegateType(type)) {
4492
4407
  process.stderr.write(`[h2a] unknown agent type "${type}" (use: claude | codex | agy)\n`);
@@ -4601,7 +4516,6 @@ export async function main(argv) {
4601
4516
  ...(opts.track !== undefined ? { trackWp: opts.track } : {}),
4602
4517
  ...(opts.model !== undefined ? { model: opts.model } : {}),
4603
4518
  ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
4604
- ...(opts.account !== undefined ? { accountId: opts.account } : {}),
4605
4519
  });
4606
4520
  }
4607
4521
  catch {
@@ -4637,7 +4551,6 @@ export async function main(argv) {
4637
4551
  ...(opts.track !== undefined ? { trackWp: opts.track } : {}),
4638
4552
  ...(opts.model !== undefined ? { model: opts.model } : {}),
4639
4553
  ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
4640
- ...(opts.account !== undefined ? { accountId: opts.account } : {}),
4641
4554
  };
4642
4555
  let claimed;
4643
4556
  try {
@@ -4687,7 +4600,6 @@ export async function main(argv) {
4687
4600
  ...(opts.track !== undefined ? { trackWp: opts.track } : {}),
4688
4601
  ...(opts.model !== undefined ? { model: opts.model } : {}),
4689
4602
  ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
4690
- ...(opts.account !== undefined ? { accountId: opts.account } : {}),
4691
4603
  };
4692
4604
  const result = await startJob(launchEntry);
4693
4605
  if (!result.started) {
@@ -4816,26 +4728,6 @@ export async function main(argv) {
4816
4728
  const verdict = detectThrottle(readJobLogTail(job), job.tool);
4817
4729
  if (!verdict.throttled)
4818
4730
  return false;
4819
- // Auto-exhaust the account that was rate-limited so new jobs avoid it.
4820
- const binding = lookupBinding(job.id);
4821
- if (binding) {
4822
- markExhausted(binding.accountId, QUOTA_WINDOW_5H_MS, verdict.signature ?? "rate-limit");
4823
- const accounts = loadCandidates(undefined);
4824
- const accountLabel = accounts.find((a) => a.id === binding.accountId)?.label ??
4825
- binding.accountId.slice(0, 8);
4826
- appendSessionLogEntry({
4827
- kind: "exhaust",
4828
- jobId: job.id,
4829
- preferredProvider: binding.provider,
4830
- selectedProvider: binding.provider,
4831
- accountId: binding.accountId,
4832
- accountLabel,
4833
- crossProvider: false,
4834
- ...(verdict.signature !== undefined
4835
- ? { signature: verdict.signature }
4836
- : {}),
4837
- });
4838
- }
4839
4731
  const nowMs = Date.now();
4840
4732
  const prior = job.throttle
4841
4733
  ? { attempts: job.throttle.attempts, firstAt: job.throttle.firstAt }
@@ -4999,17 +4891,7 @@ export async function main(argv) {
4999
4891
  .description("List delegated jobs (id/type/state/age/cwd), live state reconciled against tmux (local) and the control-plane session list (remote).")
5000
4892
  .action(async () => {
5001
4893
  const jobs = await reconcileJobs();
5002
- const accounts = listAccounts();
5003
- const resolveAccountLabel = accounts.length > 0
5004
- ? (jobId) => {
5005
- const binding = lookupBinding(jobId);
5006
- if (!binding)
5007
- return undefined;
5008
- return (accounts.find((a) => a.id === binding.accountId)?.label ??
5009
- binding.accountId.slice(0, 8));
5010
- }
5011
- : undefined;
5012
- const rows = buildJobRows(jobs, jobLive, Date.now(), resolveAccountLabel);
4894
+ const rows = buildJobRows(jobs, jobLive, Date.now());
5013
4895
  process.stdout.write(`${renderJobsTable(rows)}\n`);
5014
4896
  // M3 — warn (don't self-heal) when queued jobs have no conductor draining.
5015
4897
  const advisory = conductorAdvisory(jobs, conductorRunning());
@@ -5143,11 +5025,6 @@ export async function main(argv) {
5143
5025
  displayState = "awaiting-decision";
5144
5026
  }
5145
5027
  }
5146
- const binding = lookupBinding(job.id);
5147
- const accountLabel = binding
5148
- ? (listAccounts().find((a) => a.id === binding.accountId)?.label ??
5149
- binding.accountId.slice(0, 8))
5150
- : undefined;
5151
5028
  const lines = [
5152
5029
  `id: ${job.id}`,
5153
5030
  `type: ${job.tool}`,
@@ -5160,7 +5037,6 @@ export async function main(argv) {
5160
5037
  `task: ${job.task ?? "-"}`,
5161
5038
  `parent: ${parentInstance(job) ?? "-"}`,
5162
5039
  `started: ${job.enrolledAt}`,
5163
- ...(accountLabel !== undefined ? [`account: ${accountLabel}`] : []),
5164
5040
  ];
5165
5041
  const resultPath = join(dir, "result.json");
5166
5042
  const logPath = join(dir, "output.log");
@@ -7172,388 +7048,28 @@ export async function main(argv) {
7172
7048
  process.stderr.write(`[h2a] session ${result.sessionId} renamed to "${result.displayName}" (accepted: ${String(result.accepted)})\n`);
7173
7049
  });
7174
7050
  // ---------------------------------------------------------------------------
7175
- // account — WP16 Layer-C: local LLM account pool (enroll / ls / rm / select)
7176
- // ---------------------------------------------------------------------------
7177
- const accountCommand = program
7178
- .command("account")
7179
- .description("Manage the local LLM account pool (WP16)");
7180
- accountCommand
7181
- .command("enroll")
7182
- .description("Register an LLM account. Pass the access token via REMOTE_ACCOUNT_TOKEN " +
7183
- "env var (never as a flag — that leaks to shell history), OR use " +
7184
- "--from-credentials to read from the CLI's own config file. " +
7185
- "Descriptor written to ~/.sentropic/accounts.json (0600); " +
7186
- "token written to ~/.sentropic/accounts-tokens.json (0600).")
7187
- .requiredOption("--provider <provider>", "LLM provider: claude-code | codex")
7188
- .requiredOption("--label <label>", "Human-readable account label")
7189
- .option("--id <id>", "Unique account id (default: <provider>-<epoch>)")
7190
- .option("--from-credentials", "Read access token from the CLI's local config file (~/.claude/.credentials.json " +
7191
- "for claude-code, ~/.codex/auth.json for codex) instead of REMOTE_ACCOUNT_TOKEN.")
7192
- .option("--config-dir <path>", "Custom config directory to read credentials from (used with --from-credentials).")
7193
- .action((opts) => {
7194
- const provider = opts.provider;
7195
- if (provider !== "claude-code" && provider !== "codex") {
7196
- process.stderr.write(`[h2a] account enroll: unknown provider "${opts.provider}" (known: claude-code, codex)\n`);
7197
- process.exitCode = 1;
7198
- return;
7199
- }
7200
- let accessToken;
7201
- if (opts.fromCredentials) {
7202
- const result = provider === "claude-code"
7203
- ? readClaudeCredential(opts.configDir)
7204
- : readCodexCredential(opts.configDir);
7205
- if (!result.ok) {
7206
- process.stderr.write(`[h2a] account enroll: ${result.error}\n`);
7207
- process.exitCode = 1;
7208
- return;
7209
- }
7210
- accessToken = result.accessToken;
7211
- process.stderr.write(`[h2a] account enroll: read token from ${provider === "claude-code" ? "~/.claude/.credentials.json" : "~/.codex/auth.json"}\n`);
7212
- }
7213
- else {
7214
- accessToken = process.env.REMOTE_ACCOUNT_TOKEN ?? "";
7215
- if (!accessToken.trim()) {
7216
- process.stderr.write("[h2a] account enroll: REMOTE_ACCOUNT_TOKEN env var is not set or empty\n" +
7217
- "[h2a] (tip: use --from-credentials to read from the CLI's local config)\n");
7218
- process.exitCode = 1;
7219
- return;
7220
- }
7221
- }
7222
- const result = enrollAccount({
7223
- provider,
7224
- label: opts.label,
7225
- accessToken,
7226
- ...(opts.id !== undefined ? { id: opts.id } : {}),
7227
- // For claude-code: store configDir so startJob can inject CLAUDE_CONFIG_DIR.
7228
- ...(provider === "claude-code" && opts.configDir !== undefined
7229
- ? { configDir: opts.configDir }
7230
- : {}),
7231
- });
7232
- if (!result.ok) {
7233
- process.stderr.write(`[h2a] account enroll: ${result.error}\n`);
7234
- process.exitCode = 1;
7235
- return;
7236
- }
7237
- process.stderr.write(`[h2a] account enrolled: ${result.descriptor.id} (${result.descriptor.provider}) "${result.descriptor.label}"\n`);
7238
- });
7239
- accountCommand
7240
- .command("ls")
7241
- .description("List enrolled LLM accounts with quota/exhaustion status (descriptors only — no tokens).")
7242
- .option("--json", "Output as JSON array")
7243
- .action((opts) => {
7244
- const accounts = listAccountsWithStatus();
7245
- if (opts.json) {
7246
- process.stdout.write(JSON.stringify(accounts, null, 2) + "\n");
7247
- return;
7248
- }
7249
- if (accounts.length === 0) {
7250
- process.stderr.write("[h2a] no accounts enrolled\n");
7251
- return;
7252
- }
7253
- const hasConfigDir = accounts.some((a) => a.configDir !== undefined);
7254
- const header = ["ID", "PROVIDER", "LABEL", "QUOTA", "ENROLLED"].concat(hasConfigDir ? ["CONFIG-DIR"] : []);
7255
- const rows = accounts.map((a) => {
7256
- const quota = a.exhausted
7257
- ? `QUOTA_EXCEEDED (resets ${(a.quotaResetsAt ?? "?").slice(0, 19)})${a.exhaustionReason ? ` [${a.exhaustionReason}]` : ""}`
7258
- : "ok";
7259
- return [
7260
- a.id,
7261
- a.provider,
7262
- a.label,
7263
- quota,
7264
- a.enrolledAt.slice(0, 19),
7265
- ].concat(hasConfigDir ? [a.configDir ?? ""] : []);
7266
- });
7267
- const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
7268
- const line = (cols) => cols
7269
- .map((c, i) => c.padEnd(widths[i]))
7270
- .join(" ")
7271
- .trimEnd();
7272
- process.stdout.write([line(header), ...rows.map(line)].join("\n") + "\n");
7273
- });
7274
- accountCommand
7275
- .command("rm <id>")
7276
- .description("Remove an enrolled account (descriptor + token).")
7277
- .action((id) => {
7278
- const result = removeAccount(id);
7279
- if (!result.ok) {
7280
- process.stderr.write(`[h2a] account rm: ${result.error}\n`);
7281
- process.exitCode = 1;
7282
- return;
7283
- }
7284
- process.stderr.write(`[h2a] account removed: ${result.id}\n`);
7285
- });
7286
- accountCommand
7287
- .command("exhausted <id>")
7288
- .description("Mark an account as quota-exhausted for a given window (default 5h). " +
7289
- "New sessions skip exhausted accounts; selectAccountWithFallback() " +
7290
- "tries the next same-provider account then falls back cross-provider.")
7291
- .option("--window <window>", 'Exhaustion window: "5h" (default), "week" (7d), or a raw number of hours.', "5h")
7292
- .option("--reason <reason>", 'Optional reason label (e.g. "429 rate-limit")')
7293
- .action((id, opts) => {
7294
- const windowMs = (() => {
7295
- if (!opts.window || opts.window === "5h")
7296
- return QUOTA_WINDOW_5H_MS;
7297
- if (opts.window === "week")
7298
- return QUOTA_WINDOW_WEEK_MS;
7299
- const h = Number(opts.window.replace(/h$/i, ""));
7300
- return Number.isFinite(h) && h > 0
7301
- ? h * 60 * 60 * 1_000
7302
- : QUOTA_WINDOW_5H_MS;
7303
- })();
7304
- const rec = markExhausted(id, windowMs, opts.reason);
7305
- const resetsAt = new Date(new Date(rec.exhaustedAt).getTime() + windowMs).toISOString();
7306
- process.stderr.write(`[h2a] account ${id} marked exhausted — resets at ${resetsAt}` +
7307
- (opts.reason ? ` (reason: ${opts.reason})` : "") +
7308
- "\n");
7309
- });
7310
- accountCommand
7311
- .command("clear-quota <id>")
7312
- .description("Clear the quota exhaustion for an account (manual override — the account " +
7313
- "becomes immediately available for selection again).")
7314
- .action((id) => {
7315
- clearExhaustion(id);
7316
- process.stderr.write(`[h2a] account ${id} quota cleared — available for selection\n`);
7317
- });
7318
- accountCommand
7319
- .command("select")
7320
- .description("Dry-run: show which account selectAccount() would pick for a new session " +
7321
- "(round-robin, no I/O, stub planner).")
7322
- .option("--provider <provider>", "Filter by provider (required with --fallback)")
7323
- .option("--last-used <id>", "Pretend this account was used last (simple round-robin only)")
7324
- .option("--fallback", "Use selectAccountWithFallback() — quota-aware, cross-provider fallback — instead of plain round-robin. Requires --provider.")
7325
- .option("--affinity-key <key>", "Sticky-binding key to test (used with --fallback, e.g. a job id)")
7326
- .action((opts) => {
7327
- if (opts.fallback) {
7328
- if (!opts.provider ||
7329
- (opts.provider !== "claude-code" && opts.provider !== "codex")) {
7330
- process.stderr.write("[h2a] account select --fallback requires --provider claude-code|codex\n");
7331
- process.exitCode = 1;
7332
- return;
7333
- }
7334
- const provider = opts.provider;
7335
- const sel = selectAccountWithFallback(provider, opts.affinityKey);
7336
- if (!sel.candidate) {
7337
- process.stderr.write("[h2a] account select: all accounts exhausted — no usable account available\n");
7338
- process.exitCode = 1;
7339
- return;
7340
- }
7341
- const { accessToken: _t, ...desc } = sel.candidate;
7342
- const crossProvider = "crossProvider" in sel ? sel.crossProvider : false;
7343
- const originalProvider = "originalProvider" in sel ? sel.originalProvider : undefined;
7344
- process.stdout.write(JSON.stringify({
7345
- selected: desc,
7346
- crossProvider,
7347
- ...(originalProvider !== undefined ? { originalProvider } : {}),
7348
- }, null, 2) + "\n");
7349
- return;
7350
- }
7351
- const provider = opts.provider;
7352
- const candidates = loadCandidates(provider).map(({ accessToken: _t, ...d }) => d);
7353
- const pick = selectAccount(candidates, opts.lastUsed);
7354
- if (!pick) {
7355
- process.stderr.write("[h2a] account select: no candidates available\n");
7356
- return;
7357
- }
7358
- process.stdout.write(JSON.stringify({ selected: pick, totalCandidates: candidates.length }, null, 2) + "\n");
7359
- });
7360
- accountCommand
7361
- .command("log")
7362
- .description("Show the local account selection log (~/.sentropic/session-log.jsonl). " +
7363
- "Records job launches (kind=launch) and auto-exhaustion events (kind=exhaust) from throttle detection.")
7364
- .option("-n, --last <n>", "Show last N entries (default: 20)")
7365
- .option("--json", "Output raw JSONL")
7366
- .option("--export-s3 [s3-uri]", "Upload the full log to an S3 URI (e.g. s3://my-bucket/session-log.jsonl). " +
7367
- "If omitted, reads REMOTE_ACCOUNT_LOG_S3 from the environment. " +
7368
- "Also reads AWS_ENDPOINT_URL (SCW: https://s3.fr-par.scw.cloud), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.")
7369
- .action(async (opts) => {
7370
- if (opts.exportS3 !== undefined) {
7371
- const uri = typeof opts.exportS3 === "string"
7372
- ? opts.exportS3
7373
- : process.env.REMOTE_ACCOUNT_LOG_S3;
7374
- if (!uri) {
7375
- process.stderr.write("[h2a] account log --export-s3: no S3 URI provided and REMOTE_ACCOUNT_LOG_S3 is not set\n");
7376
- process.exit(1);
7377
- }
7378
- try {
7379
- await exportSessionLogToS3(uri);
7380
- process.stdout.write(`[h2a] session log exported to ${uri}\n`);
7381
- }
7382
- catch (err) {
7383
- process.stderr.write(`[h2a] account log --export-s3: ${err instanceof Error ? err.message : String(err)}\n`);
7384
- process.exit(1);
7385
- }
7386
- return;
7387
- }
7388
- const logFile = sessionLogPath();
7389
- let raw;
7390
- try {
7391
- raw = readFileSync(logFile, "utf8");
7392
- }
7393
- catch {
7394
- process.stderr.write("[h2a] account log: no session log yet (no jobs launched with accounts enrolled)\n");
7395
- return;
7396
- }
7397
- const lines = raw.split("\n").filter(Boolean);
7398
- const n = opts.last !== undefined ? Number.parseInt(opts.last, 10) : 20;
7399
- const tail = Number.isFinite(n) && n > 0 ? lines.slice(-n) : lines;
7400
- if (opts.json) {
7401
- process.stdout.write(tail.join("\n") + "\n");
7402
- return;
7403
- }
7404
- if (tail.length === 0) {
7405
- process.stderr.write("[h2a] account log: empty\n");
7406
- return;
7407
- }
7408
- process.stdout.write([
7409
- "AT KIND JOB-ID ACCOUNT-LABEL NOTE",
7410
- ]
7411
- .concat(tail.map((line) => {
7412
- try {
7413
- const e = JSON.parse(line);
7414
- const kind = e.kind ?? "launch";
7415
- const note = kind === "exhaust"
7416
- ? `throttled (${e.signature ?? "rate-limit"})`
7417
- : e.crossProvider
7418
- ? `⚠ fallback ${e.preferredProvider}→${e.selectedProvider}`
7419
- : "";
7420
- return [
7421
- (e.at ?? "").slice(0, 23).padEnd(24),
7422
- kind.padEnd(9),
7423
- (e.jobId ?? "").slice(0, 16).padEnd(17),
7424
- (e.accountLabel ?? "").slice(0, 16).padEnd(17),
7425
- note,
7426
- ]
7427
- .join(" ")
7428
- .trimEnd();
7429
- }
7430
- catch {
7431
- return line;
7432
- }
7433
- }))
7434
- .join("\n") + "\n");
7435
- });
7436
- accountCommand
7437
- .command("rm-binding <affinityKey>")
7438
- .description("Remove a sticky session→account binding (forces re-selection on next launch for this affinity key). " +
7439
- "Use the job id or session slug as <affinityKey>.")
7440
- .action((affinityKey) => {
7441
- const existing = lookupBinding(affinityKey);
7442
- if (!existing) {
7443
- process.stderr.write(`[h2a] no binding for "${affinityKey}"\n`);
7444
- process.exitCode = 1;
7445
- return;
7446
- }
7447
- clearBinding(affinityKey);
7448
- const accounts = listAccounts();
7449
- const label = accounts.find((a) => a.id === existing.accountId)?.label ??
7450
- existing.accountId.slice(0, 8);
7451
- process.stderr.write(`[h2a] binding removed: ${affinityKey} → ${label} (${existing.provider})\n`);
7452
- });
7453
- accountCommand
7454
- .command("bindings")
7455
- .description("List current sticky session→account bindings (~/.sentropic/session-bindings.json). " +
7456
- "Each running job is bound to one account; the binding is used by selectAccountWithFallback() to honour affinity.")
7457
- .option("--json", "Output as JSON array")
7458
- .action((opts) => {
7459
- const bindings = listBindings();
7460
- if (opts.json) {
7461
- process.stdout.write(JSON.stringify(bindings, null, 2) + "\n");
7462
- return;
7463
- }
7464
- if (bindings.length === 0) {
7465
- process.stderr.write("[h2a] no active bindings (no jobs launched with accounts enrolled)\n");
7466
- return;
7467
- }
7468
- const accounts = listAccounts();
7469
- const header = ["AFFINITY-KEY", "PROVIDER", "ACCOUNT-LABEL", "BOUND-AT"];
7470
- const rows = bindings.map((b) => {
7471
- const label = accounts.find((a) => a.id === b.accountId)?.label ??
7472
- b.accountId.slice(0, 8);
7473
- return [
7474
- b.affinityKey.slice(0, 20),
7475
- b.provider,
7476
- label,
7477
- b.boundAt.slice(0, 19),
7478
- ];
7479
- });
7480
- const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
7481
- const line = (cols) => cols
7482
- .map((c, i) => c.padEnd(widths[i]))
7483
- .join(" ")
7484
- .trimEnd();
7485
- process.stdout.write([line(header), ...rows.map(line)].join("\n") + "\n");
7486
- });
7487
- // ---------------------------------------------------------------------------
7488
- // account push-cluster — push local pool to k8s Secret (WP16 Slice 2c)
7489
- // ---------------------------------------------------------------------------
7490
- const accountPushClusterCommand = accountCommand
7491
- .command("push-cluster")
7492
- .description("Push the local LLM account pool (with tokens) to a Kubernetes Secret " +
7493
- "(llm-gateway-accounts) so the in-cluster LLM gateway can use the same accounts. " +
7494
- "Requires kubectl on PATH with write access to the target namespace.")
7495
- .option("--namespace <ns>", "Kubernetes namespace for the Secret (default: sentropic-remote)", "sentropic-remote")
7496
- .option("--secret-name <name>", "Secret name (default: llm-gateway-accounts)", "llm-gateway-accounts")
7497
- .option("--dry-run", "Print the YAML to stdout instead of applying it")
7498
- .option("--json", "Print the accounts JSON only (no kubectl — for debugging)")
7499
- .action(async (opts) => {
7500
- const accounts = listAccountsWithTokens();
7501
- if (accounts.length === 0) {
7502
- process.stderr.write("[h2a] no enrolled accounts with tokens found — run `h2a account enroll` first\n");
7503
- process.exit(1);
7504
- }
7505
- // Build the gateway-format JSON (token field, not accessToken)
7506
- const gatewayAccounts = accounts.map((a) => ({
7507
- id: a.id,
7508
- provider: a.provider,
7509
- label: a.label,
7510
- token: a.accessToken,
7511
- enrolledAt: a.enrolledAt,
7512
- }));
7513
- const accountsJson = JSON.stringify(gatewayAccounts, null, 2);
7514
- if (opts.json) {
7515
- process.stdout.write(accountsJson + "\n");
7516
- return;
7517
- }
7518
- // Build the Secret YAML (accounts.json under stringData — kubectl encodes to base64)
7519
- const yaml = `apiVersion: v1\nkind: Secret\nmetadata:\n name: ${opts.secretName}\n namespace: ${opts.namespace}\ntype: Opaque\nstringData:\n accounts.json: |\n` +
7520
- accountsJson
7521
- .split("\n")
7522
- .map((line) => ` ${line}`)
7523
- .join("\n") +
7524
- "\n";
7525
- if (opts.dryRun) {
7526
- process.stdout.write(yaml);
7527
- return;
7528
- }
7529
- // Apply via kubectl with YAML on stdin (tokens never appear in process args)
7530
- const { spawnSync } = await import("node:child_process");
7531
- const result = spawnSync("kubectl", ["apply", "-f", "-"], {
7532
- input: yaml,
7533
- stdio: ["pipe", "inherit", "inherit"],
7534
- encoding: "utf-8",
7535
- });
7536
- if (result.status !== 0) {
7537
- process.stderr.write(`[h2a] kubectl apply failed (exit ${result.status ?? "?"}) — try --dry-run to inspect the YAML\n`);
7538
- process.exit(result.status ?? 1);
7539
- }
7540
- process.stdout.write(`[h2a] pushed ${accounts.length} account(s) to ${opts.namespace}/${opts.secretName}\n`);
7541
- });
7542
- void accountPushClusterCommand; // suppress unused-var lint
7543
- // ---------------------------------------------------------------------------
7544
7051
  // llm-mesh — local LLM gateway (solo-dev mode)
7545
7052
  // ---------------------------------------------------------------------------
7546
7053
  const llmMeshCommand = program
7547
7054
  .command("llm-mesh")
7548
- .description("Manage the local LLM gateway (solo-dev mesh: multi-account, cross-provider fallback)");
7549
- llmMeshCommand
7055
+ .description("Manage the Sentropic llm-mesh gateway and account lifecycle");
7056
+ const llmMeshAccountCommand = llmMeshCommand
7057
+ .command("account")
7058
+ .description("Manage accounts through the Sentropic llm-mesh facade");
7059
+ const defaultAccountHelp = new Help();
7060
+ llmMeshAccountCommand.configureHelp({
7061
+ subcommandTerm: (command) => command.name() === "remove"
7062
+ ? "remove|rm|unenroll <account-id>"
7063
+ : defaultAccountHelp.subcommandTerm(command),
7064
+ });
7065
+ llmMeshAccountCommand
7550
7066
  .command("enroll <provider>")
7551
7067
  .description("Enroll through the sentropic-owned OAuth state machine " +
7552
7068
  "(cloud-code or codex)")
7553
7069
  .option("--config-ref <ref>", "sentropic configuration reference for OAuth")
7554
7070
  .action(async (provider, opts) => {
7555
7071
  if (provider !== "cloud-code" && provider !== "codex") {
7556
- process.stderr.write(`[h2a] llm-mesh: unsupported provider "${provider}". ` +
7072
+ process.stderr.write(`[h2a] llm-mesh account: unsupported provider "${provider}". ` +
7557
7073
  "Supported: cloud-code, codex\n");
7558
7074
  process.exitCode = 1;
7559
7075
  return;
@@ -7562,11 +7078,40 @@ export async function main(argv) {
7562
7078
  const account = await enrollViaFacade(provider, {
7563
7079
  ...(opts.configRef ? { configRef: opts.configRef } : {}),
7564
7080
  });
7565
- process.stdout.write(`[h2a] llm-mesh: enrolled ${account.provider}; ` +
7081
+ process.stdout.write(`[h2a] llm-mesh account: enrolled ${account.provider}; ` +
7566
7082
  "account inventory remains in Sentropic\n");
7567
7083
  }
7568
7084
  catch (error) {
7569
- process.stderr.write(`[h2a] llm-mesh: ${error instanceof Error ? error.message : String(error)}\n`);
7085
+ process.stderr.write(`[h2a] llm-mesh account: ${error instanceof Error ? error.message : String(error)}\n`);
7086
+ process.exitCode = 1;
7087
+ }
7088
+ });
7089
+ llmMeshAccountCommand
7090
+ .command("list")
7091
+ .alias("ls")
7092
+ .description("List public accounts owned by this local llm-mesh scope")
7093
+ .option("--json", "emit public account metadata as JSON")
7094
+ .action(async (opts) => {
7095
+ try {
7096
+ const accounts = await listAccountsViaFacade();
7097
+ process.stdout.write(`${formatLlmMeshAccountList(accounts, opts.json === true)}\n`);
7098
+ }
7099
+ catch (error) {
7100
+ process.stderr.write(`[h2a] llm-mesh account: ${formatLlmMeshAccountError(error, "account inventory unavailable")}\n`);
7101
+ process.exitCode = 1;
7102
+ }
7103
+ });
7104
+ llmMeshAccountCommand
7105
+ .command("remove <account-id>")
7106
+ .aliases(["rm", "unenroll"])
7107
+ .description("Remove one account owned by this local llm-mesh scope")
7108
+ .action(async (accountId) => {
7109
+ try {
7110
+ const result = await removeAccountViaFacade(accountId);
7111
+ process.stdout.write(`[h2a] llm-mesh account: removed ${result.accountId}\n`);
7112
+ }
7113
+ catch (error) {
7114
+ process.stderr.write(`[h2a] llm-mesh account: ${formatLlmMeshAccountError(error, "account removal failed")}\n`);
7570
7115
  process.exitCode = 1;
7571
7116
  }
7572
7117
  });