@sentropic/h2a-runtime 0.93.5 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-help-groups.d.ts +1 -1
- package/dist/cli-help-groups.d.ts.map +1 -1
- package/dist/cli-help-groups.js +11 -16
- package/dist/cli-help-groups.js.map +1 -1
- package/dist/delegate.d.ts +1 -3
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +1 -9
- package/dist/delegate.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -528
- package/dist/index.js.map +1 -1
- package/dist/llm-mesh.d.ts +7 -2
- package/dist/llm-mesh.d.ts.map +1 -1
- package/dist/llm-mesh.js +12 -4
- package/dist/llm-mesh.js.map +1 -1
- package/dist/registry.d.ts +0 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js.map +1 -1
- package/package.json +1 -1
- package/dist/account-pool.d.ts +0 -168
- package/dist/account-pool.d.ts.map +0 -1
- package/dist/account-pool.js +0 -385
- package/dist/account-pool.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -65,7 +65,6 @@ 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";
|
|
@@ -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
|
|
1222
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,22 @@ 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
|
|
7549
|
-
llmMeshCommand
|
|
7055
|
+
.description("Manage the Sentropic llm-mesh gateway and account enrollment");
|
|
7056
|
+
const llmMeshAccountCommand = llmMeshCommand
|
|
7057
|
+
.command("account")
|
|
7058
|
+
.description("Manage accounts through the Sentropic llm-mesh facade");
|
|
7059
|
+
llmMeshAccountCommand
|
|
7550
7060
|
.command("enroll <provider>")
|
|
7551
7061
|
.description("Enroll through the sentropic-owned OAuth state machine " +
|
|
7552
7062
|
"(cloud-code or codex)")
|
|
7553
7063
|
.option("--config-ref <ref>", "sentropic configuration reference for OAuth")
|
|
7554
7064
|
.action(async (provider, opts) => {
|
|
7555
7065
|
if (provider !== "cloud-code" && provider !== "codex") {
|
|
7556
|
-
process.stderr.write(`[h2a] llm-mesh: unsupported provider "${provider}". ` +
|
|
7066
|
+
process.stderr.write(`[h2a] llm-mesh account: unsupported provider "${provider}". ` +
|
|
7557
7067
|
"Supported: cloud-code, codex\n");
|
|
7558
7068
|
process.exitCode = 1;
|
|
7559
7069
|
return;
|
|
@@ -7562,11 +7072,11 @@ export async function main(argv) {
|
|
|
7562
7072
|
const account = await enrollViaFacade(provider, {
|
|
7563
7073
|
...(opts.configRef ? { configRef: opts.configRef } : {}),
|
|
7564
7074
|
});
|
|
7565
|
-
process.stdout.write(`[h2a] llm-mesh: enrolled ${account.provider}; ` +
|
|
7075
|
+
process.stdout.write(`[h2a] llm-mesh account: enrolled ${account.provider}; ` +
|
|
7566
7076
|
"account inventory remains in Sentropic\n");
|
|
7567
7077
|
}
|
|
7568
7078
|
catch (error) {
|
|
7569
|
-
process.stderr.write(`[h2a] llm-mesh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
7079
|
+
process.stderr.write(`[h2a] llm-mesh account: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
7570
7080
|
process.exitCode = 1;
|
|
7571
7081
|
}
|
|
7572
7082
|
});
|