@sentropic/h2a-runtime 0.98.7-rc.8 → 0.98.7-rc.9

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.
Files changed (50) hide show
  1. package/dist/attach.d.ts +3 -0
  2. package/dist/attach.d.ts.map +1 -1
  3. package/dist/attach.js +2 -0
  4. package/dist/attach.js.map +1 -1
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +169 -69
  8. package/dist/index.js.map +1 -1
  9. package/dist/launch-layout.d.ts.map +1 -1
  10. package/dist/launch-layout.js +10 -2
  11. package/dist/launch-layout.js.map +1 -1
  12. package/dist/native-host.d.ts.map +1 -1
  13. package/dist/native-host.js +9 -1
  14. package/dist/native-host.js.map +1 -1
  15. package/dist/native-restart.d.ts +2 -0
  16. package/dist/native-restart.d.ts.map +1 -1
  17. package/dist/native-restart.js +7 -0
  18. package/dist/native-restart.js.map +1 -1
  19. package/dist/protocol-local.d.ts +16 -0
  20. package/dist/protocol-local.d.ts.map +1 -1
  21. package/dist/protocol-local.js +14 -0
  22. package/dist/protocol-local.js.map +1 -1
  23. package/dist/registry.d.ts +11 -0
  24. package/dist/registry.d.ts.map +1 -1
  25. package/dist/registry.js +19 -2
  26. package/dist/registry.js.map +1 -1
  27. package/dist/relaunch.d.ts +16 -0
  28. package/dist/relaunch.d.ts.map +1 -1
  29. package/dist/relaunch.js +42 -1
  30. package/dist/relaunch.js.map +1 -1
  31. package/dist/restore.d.ts +4 -0
  32. package/dist/restore.d.ts.map +1 -1
  33. package/dist/restore.js +4 -0
  34. package/dist/restore.js.map +1 -1
  35. package/dist/resume-launch-options.d.ts +3 -3
  36. package/dist/resume-launch-options.d.ts.map +1 -1
  37. package/dist/resume-launch-options.js +7 -4
  38. package/dist/resume-launch-options.js.map +1 -1
  39. package/dist/run.d.ts +2 -0
  40. package/dist/run.d.ts.map +1 -1
  41. package/dist/run.js +6 -1
  42. package/dist/run.js.map +1 -1
  43. package/dist/soft-refresh.d.ts.map +1 -1
  44. package/dist/soft-refresh.js +6 -0
  45. package/dist/soft-refresh.js.map +1 -1
  46. package/dist/tmux.d.ts +27 -3
  47. package/dist/tmux.d.ts.map +1 -1
  48. package/dist/tmux.js +49 -9
  49. package/dist/tmux.js.map +1 -1
  50. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ import { projectStatusForH2a } from "./status-projection.js";
31
31
  import { buildAgentLaunchArgs, buildAgentLaunchStdin, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
32
32
  import { resolveGatewayModelAlias } from "./gateway-model-catalog/model-catalog.js";
33
33
  import { deriveSessionClass } from "./session-class.js";
34
- import { isRelaunchKillable, planRelaunch, relaunchContinuationPrompt, tryAcquireResumeLaunchClaim, wakeRelaunchedSession, } from "./relaunch.js";
34
+ import { isRelaunchKillable, planRelaunch, relaunchContinuationPrompt, scrubbedRetypeCommand, prepareGatewayRetypeCommand, tryAcquireResumeLaunchClaim, wakeRelaunchedSession, } from "./relaunch.js";
35
35
  import { isHumanFacingSession, legacySessionEvidence, readConversationCustomTitle, readLastLayout, reconcileRunConvIds, restore as restoreLayout, } from "./restore.js";
36
36
  import { getLayoutConfig } from "./config.js";
37
37
  import { resolvePinnedLaunchEntries } from "./resume-launch-options.js";
@@ -81,7 +81,7 @@ import { createInterface } from "node:readline";
81
81
  const H2A_RUN_API_VERSION = "h2a.run/v1";
82
82
  const STRUCTURED_LAUNCH_PHASE_PREFIX = "[h2a] h2a.run.phase/v1 ";
83
83
  const H2A_RUNTIME_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
84
- import { CLI_PROFILES, gatewayModeForProfile, profileUsesLlmMeshGateway, } from "./protocol-local.js";
84
+ import { CLI_PROFILES, gatewayModeForProfile, profileUsesLlmMeshGateway, resolveResumedGatewayMode, } from "./protocol-local.js";
85
85
  import { readLlmMeshConfig, updateLlmMeshRoutingConfig, } from "./gateway-host/config-file.js";
86
86
  import { startGateway, stopGateway, readGatewayPid, llmMeshLogPath, replaceAnthropicGatewayEnvironment, acquireLlmMeshSessionEnv, } from "./gateway-host/daemon.js";
87
87
  import { describeLlmMeshRoutingConfig, parseLlmMeshRoutingConfig, preferredRoutingConfig, strategyRoutingConfig, } from "./routing-preferences.js";
@@ -141,6 +141,7 @@ async function startRemoteFanout(remote, profileName, members, spec) {
141
141
  ? { startupArgs: spec.startupArgs }
142
142
  : {}),
143
143
  ...(spec.credentials ? { credentials: spec.credentials } : {}),
144
+ ...(spec.llmGateway === true ? { llmGateway: true } : {}),
144
145
  });
145
146
  return { name: member.name, sessionId: session.id, workspaceId: ws.id };
146
147
  });
@@ -206,6 +207,20 @@ async function runProfile(profileName, opts, commandArgs = []) {
206
207
  if (opts.resume !== undefined && resumeArgs.length === 0) {
207
208
  throw new Error(`profile "${profileName}" has no verified resume argv; start it without -r/--resume`);
208
209
  }
210
+ // Remote sessions are direct unless the gateway is explicitly asked for.
211
+ // Only an explicit --gw/--llm-gateway transmits the opt-in; the server
212
+ // ignores a globally configured gateway URL without it.
213
+ let remoteGatewayMode;
214
+ try {
215
+ remoteGatewayMode = gatewayModeFromOptions(opts);
216
+ }
217
+ catch (error) {
218
+ throw new Error(`[h2a] ${error.message}.`);
219
+ }
220
+ if (remoteGatewayMode === "gateway" && !profileUsesLlmMeshGateway(profileName)) {
221
+ throw new Error(`[h2a] --llm-gateway/--gw is unsupported for ${profileName}; this gateway is Anthropic-compatible and only Claude profiles consume it.`);
222
+ }
223
+ const remoteGatewayChoice = remoteGatewayMode === "gateway" ? { llmGateway: true } : {};
209
224
  const startupArgs = [...resumeArgs, ...commandArgs];
210
225
  // WP6 — REMOTE fan-out: --count N spawns N concurrent remote sessions, each
211
226
  // on its OWN workspace subPath of the shared RWX volume. count<=1 falls
@@ -235,6 +250,7 @@ async function runProfile(profileName, opts, commandArgs = []) {
235
250
  target: opts.target,
236
251
  startupArgs,
237
252
  ...(credentials ? { credentials } : {}),
253
+ ...remoteGatewayChoice,
238
254
  });
239
255
  return;
240
256
  }
@@ -253,6 +269,7 @@ async function runProfile(profileName, opts, commandArgs = []) {
253
269
  ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}),
254
270
  ...(opts.browser ? { metadata: { browser: true } } : {}),
255
271
  ...(opts.agentImage ? { agentImage: opts.agentImage } : {}),
272
+ ...remoteGatewayChoice,
256
273
  });
257
274
  if (archive) {
258
275
  await uploadWorkspaceArchive(opts.remote, session.id, archive);
@@ -276,7 +293,21 @@ async function runProfile(profileName, opts, commandArgs = []) {
276
293
  ...(opts.port !== undefined ? { port: opts.port } : {}),
277
294
  ...(commandArgs.length > 0 ? { startupArgs: commandArgs } : {}),
278
295
  };
279
- const result = await run(runOptions);
296
+ const gatewayMode = gatewayModeFromOptions(opts);
297
+ if (gatewayMode === "gateway" && !profileUsesLlmMeshGateway(profileName)) {
298
+ throw new Error(`--llm-gateway/--gw is unsupported for ${profileName}`);
299
+ }
300
+ // Resolve local PTY routing before spawning, just as for managed sessions.
301
+ // Restore the caller's native auth even if acquisition or spawning fails.
302
+ const restoreEnv = replaceAnthropicGatewayEnvironment(process.env, captureAnthropicGatewayEnvironment());
303
+ let result;
304
+ try {
305
+ await injectLlmMeshGatewayEnv(gatewayMode, false);
306
+ result = await run({ ...runOptions, gatewayMode: gatewayMode === "gateway" ? "gateway" : "direct" });
307
+ }
308
+ finally {
309
+ restoreEnv();
310
+ }
280
311
  process.stderr.write(`[h2a] session ${result.sessionId} attach at http://127.0.0.1:${result.port}\n`);
281
312
  const { exitCode } = await result.exit;
282
313
  process.exitCode = exitCode;
@@ -858,32 +889,12 @@ export async function startJob(job) {
858
889
  const prevJobId = process.env[JOB_ID_ENV];
859
890
  process.env[DEPTH_ENV] = childDepthEnvValue(job.depthBudget ?? clampDepth(undefined));
860
891
  process.env[JOB_ID_ENV] = job.id;
861
- // Inject llm-mesh gateway env if running. Do not trust a parent env that may
862
- // contain an old gateway token from a previous restart.
863
- // tmux inherits process.env, so claude + its subagents all get the gateway automatically.
864
- const jobSessionSlug = slugify(job.id);
865
- const jobSessionResolution = resolveLocalSession(jobSessionSlug);
866
- const gatewayClientSessionId = jobSessionResolution.kind === "found"
867
- ? jobSessionResolution.session.name
868
- : jobSessionResolution.kind === "missing"
869
- ? localSessionName(jobSessionSlug)
870
- : undefined;
871
- let meshEnv;
872
- try {
873
- meshEnv =
874
- profileUsesLlmMeshGateway(job.tool) &&
875
- getLlmMeshRuntimeConfig().enabled &&
876
- gatewayClientSessionId
877
- ? await acquireLlmMeshSessionEnv(undefined, gatewayClientSessionId)
878
- : null;
879
- }
880
- catch (error) {
881
- return {
882
- started: false,
883
- error: error instanceof Error ? error.message : String(error),
884
- };
885
- }
886
- const restoreGatewayEnvironment = replaceAnthropicGatewayEnvironment(process.env, meshEnv ?? undefined);
892
+ // Gateway off unless explicitly asked: jobs never acquire the gateway.
893
+ // A legacy llmMesh.enabled=true, a running service, enrolled accounts or a
894
+ // parent env never select gateway routing for immediate or deferred jobs.
895
+ // Strip any stale h2a routing vars so the child starts direct; a user-owned
896
+ // ANTHROPIC_API_KEY survives for native authentication.
897
+ const restoreGatewayEnvironment = replaceAnthropicGatewayEnvironment(process.env, undefined);
887
898
  let tmuxSession;
888
899
  let workerPid;
889
900
  let agentPane;
@@ -1405,13 +1416,12 @@ export function bareChoiceFromOptions(opts) {
1405
1416
  return false;
1406
1417
  return undefined;
1407
1418
  }
1408
- async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true, clientSessionId) {
1409
- if (mode === "direct") {
1410
- delete process.env.ANTHROPIC_BASE_URL;
1411
- delete process.env.ANTHROPIC_AUTH_TOKEN;
1412
- return undefined;
1413
- }
1414
- if (mode !== "gateway" && !getLlmMeshRuntimeConfig().enabled) {
1419
+ export async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true, clientSessionId) {
1420
+ if (mode !== "gateway") {
1421
+ // Gateway off unless explicitly asked: auto resolves to direct BEFORE any
1422
+ // acquire/start. A legacy llmMesh.enabled=true, a running service, enrolled
1423
+ // accounts or a parent env never select gateway routing. A user-owned
1424
+ // ANTHROPIC_API_KEY is preserved for native authentication.
1415
1425
  delete process.env.ANTHROPIC_BASE_URL;
1416
1426
  delete process.env.ANTHROPIC_AUTH_TOKEN;
1417
1427
  return undefined;
@@ -1535,7 +1545,9 @@ export async function prepareLlmMeshForRestore(opts = {}, context = {
1535
1545
  injectGateway: (mode) => injectLlmMeshGatewayEnv(mode, mode !== "gateway"),
1536
1546
  }) {
1537
1547
  const mode = opts.mode ?? "auto";
1538
- if (mode !== "gateway" && !context.runtimeEnabled)
1548
+ // Global enablement is not restore consent. Pinned sessions request --gw
1549
+ // individually through their launch command; only --gw prewarms globally.
1550
+ if (mode !== "gateway")
1539
1551
  return;
1540
1552
  const config = context.config ?? {};
1541
1553
  const port = config.port ?? 3002;
@@ -2016,6 +2028,10 @@ export async function main(argv) {
2016
2028
  .option("--name <label>", "base label for the --count fan-out fleet names (default: cwd basename)")
2017
2029
  .option("--browser", "add the headful-browser noVNC sidecar (WP7) — enables 2FA / authenticated-site flows from inside the pod")
2018
2030
  .option("--image <ref>", "override the session-agent container image (fully-qualified registry ref, e.g. rg.fr-par.scw.cloud/sentropic-geo/geo-acquisition:0.1.0)")
2031
+ .option("--llm-gateway", "route this remote session through the llm-mesh gateway (explicit opt-in; default is direct)")
2032
+ .option("--gw", "alias for --llm-gateway")
2033
+ .option("--no-llm-gateway", "run this remote session without llm-mesh gateway env")
2034
+ .option("--no-gw", "alias for --no-llm-gateway")
2019
2035
  .action(async (commandArgs, opts) => {
2020
2036
  const { remote: remoteOverride, local, workspace, ...rest } = opts;
2021
2037
  if (local) {
@@ -3701,6 +3717,8 @@ export async function main(argv) {
3701
3717
  process.exitCode = 1;
3702
3718
  return;
3703
3719
  }
3720
+ const sameResumeIdentity = resolvedConvId !== undefined && registryEntry?.convId === resolvedConvId &&
3721
+ registryEntry?.cwd === process.cwd() && registryEntry?.tool === explicitProfile;
3704
3722
  const entry = explicitProfile
3705
3723
  ? {
3706
3724
  id: displaySlug,
@@ -3715,8 +3733,9 @@ export async function main(argv) {
3715
3733
  enrolledAt: new Date().toISOString(),
3716
3734
  lastSeenAt: new Date().toISOString(),
3717
3735
  source: "run",
3718
- bare: registryEntry?.convId === resolvedConvId ? registryEntry?.bare : undefined,
3719
- gatewayMode: registryEntry?.convId === resolvedConvId ? registryEntry?.gatewayMode : undefined,
3736
+ bare: sameResumeIdentity ? registryEntry?.bare : undefined,
3737
+ gatewayMode: sameResumeIdentity ? registryEntry?.gatewayMode : undefined,
3738
+ gatewayProvenance: sameResumeIdentity ? registryEntry?.gatewayProvenance : undefined,
3720
3739
  }
3721
3740
  : registryEntry;
3722
3741
  // WHICH host serves this resume — resolved BEFORE ANY act (F1): every
@@ -3814,7 +3833,7 @@ export async function main(argv) {
3814
3833
  process.exitCode = 2;
3815
3834
  return;
3816
3835
  }
3817
- const effectiveGatewayMode = gatewayModeForProfile(profile, gatewayMode === "auto" ? entry.gatewayMode ?? "auto" : gatewayMode);
3836
+ const effectiveGatewayMode = resolveResumedGatewayMode(profile, gatewayMode, entry.gatewayMode, entry.gatewayProvenance);
3818
3837
  let gateway;
3819
3838
  // Resolve against the selected mode, not the parent's environment:
3820
3839
  // required gateway injection below must succeed before either launch.
@@ -4027,7 +4046,11 @@ export async function main(argv) {
4027
4046
  cwd: entry.cwd,
4028
4047
  sessionClass,
4029
4048
  ...(entry.convId ? { convId: entry.convId } : {}),
4030
- gatewayMode: effectiveGatewayMode,
4049
+ // Only an explicit CLI choice becomes a new proven pin; otherwise
4050
+ // the previous pin (if any) is preserved untouched by the merge.
4051
+ ...(gatewayMode !== "auto"
4052
+ ? { gatewayMode, gatewayProvenance: "explicit" }
4053
+ : {}),
4031
4054
  bare: useBare,
4032
4055
  });
4033
4056
  process.stderr.write(`[h2a] resumed local session ${resumeSlug} (${profile} resume${entry.convId ? ` ${entry.convId}` : ""} in ${entry.cwd})\n`);
@@ -4383,8 +4406,11 @@ export async function main(argv) {
4383
4406
  for (const prunedId of resumeResolution.prunedIds) {
4384
4407
  process.stderr.write(`[h2a] pruned dead registry row ${prunedId} that pinned conflicting launch options for this resume\n`);
4385
4408
  }
4386
- const pinnedLaunch = resumeResolution.entries[0];
4387
- const launchGatewayMode = gatewayModeForProfile(profile, gatewayMode === "auto" ? pinnedLaunch?.gatewayMode ?? "auto" : gatewayMode);
4409
+ // Conflicting pins have already failed closed above. For agreeing
4410
+ // pins, prefer the proven choice over an equivalent legacy row.
4411
+ const pinnedLaunch = resumeResolution.entries.find(entry => entry.gatewayProvenance === "explicit")
4412
+ ?? resumeResolution.entries[0];
4413
+ const launchGatewayMode = resolveResumedGatewayMode(profile, gatewayMode, pinnedLaunch?.gatewayMode, pinnedLaunch?.gatewayProvenance);
4388
4414
  const command = localCliCommand(profile);
4389
4415
  // A detached/background or run-once launch is a worker, even when its
4390
4416
  // agent later emits a Claude SessionStart hook. Stamp this through tmux
@@ -4428,6 +4454,11 @@ export async function main(argv) {
4428
4454
  return;
4429
4455
  }
4430
4456
  const useBare = resolveClaudeBare(profile, bareChoiceFromOptions(opts) ?? pinnedLaunch?.bare);
4457
+ // Preserve the conversation's bare pin, including absence on a
4458
+ // legacy row: materializing it only on the new row creates a conflict.
4459
+ const enrollmentBare = pinnedLaunch && bareChoiceFromOptions(opts) === undefined
4460
+ ? pinnedLaunch.bare
4461
+ : useBare;
4431
4462
  // H0 (#662): normalize readable gateway aliases (argon-high, wonder,
4432
4463
  // wonder-full, spark) to their exact canonical model id. Unknown
4433
4464
  // names pass through unchanged for exact-model validation downstream.
@@ -4755,8 +4786,8 @@ export async function main(argv) {
4755
4786
  return;
4756
4787
  }
4757
4788
  // Auto-enroll in the live-session registry (feeds `h2a ls`/`restore`).
4758
- // Pin the gateway posture ONLY when the user was explicit (--gw/--no-gw);
4759
- // an "auto" launch stays unpinned so restore follows the live default.
4789
+ // A CLI choice or a proven pin for this exact convId/cwd/tool
4790
+ // survives enrollment under a new name. Legacy/auto stays unpinned.
4760
4791
  enrollFromRun({
4761
4792
  profile,
4762
4793
  slug,
@@ -4768,8 +4799,10 @@ export async function main(argv) {
4768
4799
  cwd,
4769
4800
  sessionClass,
4770
4801
  ...(opts.resume !== undefined ? { convId: opts.resume } : {}),
4771
- gatewayMode: launchGatewayMode,
4772
- bare: useBare,
4802
+ ...(gatewayMode !== "auto" || pinnedLaunch?.gatewayProvenance === "explicit"
4803
+ ? { gatewayMode: launchGatewayMode, gatewayProvenance: "explicit" }
4804
+ : {}),
4805
+ ...(enrollmentBare !== undefined ? { bare: enrollmentBare } : {}),
4773
4806
  });
4774
4807
  const nativeOwnerSocket = launchOwnership?.host === "native"
4775
4808
  ? launchOwnership.sessions.find(owned => owned.name === name)?.socketPath : undefined;
@@ -6295,6 +6328,9 @@ export async function main(argv) {
6295
6328
  ...(entry.gatewayMode !== undefined
6296
6329
  ? { gatewayMode: entry.gatewayMode }
6297
6330
  : {}),
6331
+ ...(entry.gatewayProvenance !== undefined
6332
+ ? { gatewayProvenance: entry.gatewayProvenance }
6333
+ : {}),
6298
6334
  ...(entry.bare !== undefined ? { bare: entry.bare } : {}),
6299
6335
  }));
6300
6336
  const result = await executeNativeRestart({
@@ -6349,7 +6385,9 @@ export async function main(argv) {
6349
6385
  ...(candidate.convId !== undefined
6350
6386
  ? { convId: candidate.convId }
6351
6387
  : {}),
6352
- ...(mode !== "auto" ? { gatewayMode: mode } : {}),
6388
+ ...(mode !== "auto"
6389
+ ? { gatewayMode: mode, gatewayProvenance: "explicit" }
6390
+ : {}),
6353
6391
  ...(candidate.bare !== undefined ? { bare: candidate.bare } : {}),
6354
6392
  });
6355
6393
  }
@@ -6462,7 +6500,7 @@ export async function main(argv) {
6462
6500
  process.exitCode = 1;
6463
6501
  return;
6464
6502
  }
6465
- const relaunchMode = gatewayModeForProfile(nativeEntry.tool, nativeEntry.gatewayMode ?? "auto");
6503
+ const relaunchMode = resolveResumedGatewayMode(nativeEntry.tool, "auto", nativeEntry.gatewayMode, nativeEntry.gatewayProvenance);
6466
6504
  await injectLlmMeshGatewayEnv(relaunchMode, relaunchMode !== "gateway", actName);
6467
6505
  if (!killNativeSessionTree(actName)) {
6468
6506
  process.stderr.write(`[h2a] native session ${slug} could not be killed; nothing was relaunched.\n`);
@@ -6489,7 +6527,15 @@ export async function main(argv) {
6489
6527
  ...(relaunched.pid !== undefined ? { pid: relaunched.pid } : {}),
6490
6528
  cwd: nativeEntry.cwd,
6491
6529
  sessionClass: nativeEntry.sessionClass ?? "human",
6492
- gatewayMode: relaunchMode,
6530
+ // No new CLI choice here: preserve the recorded pin verbatim so a
6531
+ // proven choice survives the relaunch and a legacy row keeps its
6532
+ // (unproven) data without gaining authority.
6533
+ ...(nativeEntry.gatewayMode !== undefined
6534
+ ? { gatewayMode: nativeEntry.gatewayMode }
6535
+ : {}),
6536
+ ...(nativeEntry.gatewayProvenance !== undefined
6537
+ ? { gatewayProvenance: nativeEntry.gatewayProvenance }
6538
+ : {}),
6493
6539
  bare: useBare,
6494
6540
  ...(nativeEntry.convId ? { convId: nativeEntry.convId } : {}),
6495
6541
  });
@@ -6619,14 +6665,55 @@ export async function main(argv) {
6619
6665
  if (!forced) {
6620
6666
  let ok = 0;
6621
6667
  for (const action of plan.actions) {
6622
- const sessionId = entryByTmuxSession.get(action.name)?.id;
6623
- const relaunched = sessionId !== undefined
6624
- ? relaunchSessionWithLease({
6625
- sessionId,
6626
- tmuxSession: action.name,
6627
- command: action.cmd,
6628
- })
6629
- : relaunchInSession(action.name, action.cmd);
6668
+ // The pane is NOT recreated, so apply the routing policy to the
6669
+ // retyped command itself: resolve the session's consent, scrub the
6670
+ // routing vars for direct, or pass the freshly acquired gateway
6671
+ // route: the idle shell may carry an expired bearer or no route.
6672
+ const retypeEntry = entryByTmuxSession.get(action.name);
6673
+ const retypeMode = resolveResumedGatewayMode(action.profile, "auto", retypeEntry?.gatewayMode, retypeEntry?.gatewayProvenance);
6674
+ let retypeCmd = action.cmd;
6675
+ let cleanupRetypeEnv = () => { };
6676
+ if (retypeMode === "gateway") {
6677
+ // Validate the explicit choice before retyping: fail closed when
6678
+ // the gateway is unavailable instead of typing a doomed command.
6679
+ // Hand off privately before restoring this process's environment.
6680
+ const restoreRetypeEnv = replaceAnthropicGatewayEnvironment(process.env, captureAnthropicGatewayEnvironment());
6681
+ try {
6682
+ await injectLlmMeshGatewayEnv("gateway", false, localSessionName(action.slug));
6683
+ const prepared = prepareGatewayRetypeCommand(action.cmd, {
6684
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
6685
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
6686
+ });
6687
+ retypeCmd = prepared.command;
6688
+ cleanupRetypeEnv = prepared.cleanup;
6689
+ }
6690
+ catch (error) {
6691
+ process.stderr.write(`[h2a] FAILED to relaunch ${action.slug}: ${error.message}\n`);
6692
+ continue;
6693
+ }
6694
+ finally {
6695
+ restoreRetypeEnv();
6696
+ }
6697
+ }
6698
+ else {
6699
+ retypeCmd = scrubbedRetypeCommand(action.cmd);
6700
+ }
6701
+ const sessionId = retypeEntry?.id;
6702
+ let relaunched = false;
6703
+ try {
6704
+ relaunched = sessionId !== undefined
6705
+ ? relaunchSessionWithLease({
6706
+ sessionId,
6707
+ tmuxSession: action.name,
6708
+ command: retypeCmd,
6709
+ })
6710
+ : relaunchInSession(action.name, retypeCmd);
6711
+ }
6712
+ finally {
6713
+ // A successful retype transfers cleanup to the private reader.
6714
+ if (!relaunched)
6715
+ cleanupRetypeEnv();
6716
+ }
6630
6717
  if (relaunched) {
6631
6718
  ok += 1;
6632
6719
  process.stderr.write(`[h2a] relaunched ${action.slug}: ${action.cmd}\n`);
@@ -6702,6 +6789,11 @@ export async function main(argv) {
6702
6789
  }
6703
6790
  finalReady.push(item);
6704
6791
  }
6792
+ // Isolate the routing env per session: a gateway relaunch deletes the
6793
+ // native ANTHROPIC_API_KEY from the shared process env, so without a
6794
+ // per-iteration restore the next (direct) session would lose native
6795
+ // auth. Every iteration starts from the loop-entry environment.
6796
+ const loopEntryGatewayEnv = captureAnthropicGatewayEnvironment();
6705
6797
  let ok = 0;
6706
6798
  for (const { action, entry, sessionClass } of finalReady) {
6707
6799
  const safety = sessionRelaunchSafety(action.name);
@@ -6728,8 +6820,9 @@ export async function main(argv) {
6728
6820
  process.stderr.write(`[h2a] skipped ${action.slug}: tmux session ${action.name} changed or was no longer proven dead immediately before kill.\n`);
6729
6821
  continue;
6730
6822
  }
6823
+ const restoreLoopEnv = replaceAnthropicGatewayEnvironment(process.env, loopEntryGatewayEnv);
6731
6824
  try {
6732
- const gatewayMode = gatewayModeForProfile(action.profile, entry.gatewayMode ?? "auto");
6825
+ const gatewayMode = resolveResumedGatewayMode(action.profile, "auto", entry.gatewayMode, entry.gatewayProvenance);
6733
6826
  await injectLlmMeshGatewayEnv(gatewayMode, true, localSessionName(action.slug));
6734
6827
  const { name, agentPane } = startLocalSession(action.profile, action.command, entry.cwd, localResumeArgs(action.profile, action.convId, {
6735
6828
  bare: resolveClaudeBare(action.profile, entry.bare),
@@ -6751,6 +6844,9 @@ export async function main(argv) {
6751
6844
  ...(entry.gatewayMode !== undefined
6752
6845
  ? { gatewayMode: entry.gatewayMode }
6753
6846
  : {}),
6847
+ ...(entry.gatewayProvenance !== undefined
6848
+ ? { gatewayProvenance: entry.gatewayProvenance }
6849
+ : {}),
6754
6850
  ...(entry.bare !== undefined ? { bare: entry.bare } : {}),
6755
6851
  });
6756
6852
  const wake = wakeRelaunchedSession(relaunchContinuationPrompt(entry.task), {
@@ -6783,6 +6879,9 @@ export async function main(argv) {
6783
6879
  catch (error) {
6784
6880
  process.stderr.write(`[h2a] FAILED to force-restart ${action.slug} after killing ${action.name}: ${error.message}\n`);
6785
6881
  }
6882
+ finally {
6883
+ restoreLoopEnv();
6884
+ }
6786
6885
  }
6787
6886
  process.stderr.write(`[h2a] force-restarted ${ok}/${plan.actions.length}${plan.skipped.length ? `, ${plan.skipped.length} skipped` : ""}\n`);
6788
6887
  if (ok !== plan.actions.length)
@@ -7146,13 +7245,13 @@ export async function main(argv) {
7146
7245
  }
7147
7246
  if (opts.reattach)
7148
7247
  restoreOpts.reattach = true;
7149
- // Forced-direct restore must NOT spin up the gateway; otherwise prepare
7150
- // it as before (no-op when the gateway is disabled).
7151
- if (forceGateway !== "direct") {
7248
+ // Only explicit --gw prewarms globally. In auto mode, any pinned
7249
+ // gateway session acquires its gateway through its own launch.
7250
+ if (forceGateway === "gateway") {
7152
7251
  try {
7153
7252
  await prepareLlmMeshForRestore({
7154
7253
  dryRun: Boolean(opts.dryRun),
7155
- mode: forceGateway ?? "auto",
7254
+ mode: forceGateway,
7156
7255
  });
7157
7256
  }
7158
7257
  catch (error) {
@@ -7851,17 +7950,15 @@ export async function main(argv) {
7851
7950
  const config = readLlmMeshConfig() ?? {};
7852
7951
  const existing = readGatewayPid();
7853
7952
  if (existing) {
7854
- setLlmMeshRuntimeConfig({ enabled: true });
7953
+ // Starting the service is not routing consent: launches stay direct
7954
+ // unless --gw is passed, so the durable flag is left untouched.
7855
7955
  process.stdout.write(`[h2a] llm-mesh: already running (pid ${existing})\n`);
7856
- process.stdout.write(`[h2a] llm-mesh: restore config enabled\n`);
7857
7956
  return;
7858
7957
  }
7859
7958
  process.stdout.write(`[h2a] llm-mesh: starting gateway…\n`);
7860
7959
  const result = await startGateway(config, { verbose: opts.verbose });
7861
- setLlmMeshRuntimeConfig({ enabled: true });
7862
7960
  const port = result.port;
7863
7961
  process.stdout.write(`[h2a] llm-mesh: gateway started (pid ${result.pid}, port ${port})\n`);
7864
- process.stdout.write(`[h2a] llm-mesh: restore config enabled\n`);
7865
7962
  process.stdout.write("[h2a] llm-mesh: use `h2a run claude --gw`; the opaque bearer is not printed\n");
7866
7963
  });
7867
7964
  llmMeshCommand
@@ -7889,6 +7986,9 @@ export async function main(argv) {
7889
7986
  else {
7890
7987
  process.stdout.write(`[h2a] llm-mesh: not running\n`);
7891
7988
  }
7989
+ // A previous start must not keep authorizing reactivation: stopping the
7990
+ // service clears the durable flag so a later restore stays direct.
7991
+ setLlmMeshRuntimeConfig({ enabled: false });
7892
7992
  });
7893
7993
  llmMeshCommand
7894
7994
  .command("restart")
@@ -7905,9 +8005,9 @@ export async function main(argv) {
7905
8005
  }
7906
8006
  process.stdout.write(`[h2a] llm-mesh: starting gateway…\n`);
7907
8007
  const result = await startGateway(config, { verbose: opts.verbose });
7908
- setLlmMeshRuntimeConfig({ enabled: true });
8008
+ // Restarting the service is not routing consent: the durable flag is
8009
+ // left untouched so a later restore stays direct unless --gw is passed.
7909
8010
  process.stdout.write(`[h2a] llm-mesh: gateway restarted (pid ${result.pid}, port ${result.port})\n`);
7910
- process.stdout.write(`[h2a] llm-mesh: restore config enabled\n`);
7911
8011
  process.stdout.write(`[h2a] llm-mesh: no tmux/Claude session was restarted; active sessions may keep their old gateway token until explicitly relaunched.\n`);
7912
8012
  process.stdout.write("[h2a] llm-mesh: use `h2a run claude --gw`; the opaque bearer is not printed\n");
7913
8013
  });