@sentropic/h2a-runtime 0.91.1 → 0.93.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.
Files changed (108) hide show
  1. package/dist/config.d.ts +2 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +16 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/conv-guard.d.ts +6 -1
  6. package/dist/conv-guard.d.ts.map +1 -1
  7. package/dist/conv-guard.js +33 -8
  8. package/dist/conv-guard.js.map +1 -1
  9. package/dist/enroll.d.ts.map +1 -1
  10. package/dist/enroll.js +12 -2
  11. package/dist/enroll.js.map +1 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +819 -155
  15. package/dist/index.js.map +1 -1
  16. package/dist/launch-layout.d.ts +74 -0
  17. package/dist/launch-layout.d.ts.map +1 -0
  18. package/dist/launch-layout.js +324 -0
  19. package/dist/launch-layout.js.map +1 -0
  20. package/dist/llm-gateway-runtime/index.d.ts +6 -0
  21. package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
  22. package/dist/llm-gateway-runtime/index.js +134 -60
  23. package/dist/llm-gateway-runtime/index.js.map +1 -1
  24. package/dist/llm-gateway-runtime/session-ledger.d.ts +38 -55
  25. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -1
  26. package/dist/llm-gateway-runtime/session-ledger.js +102 -250
  27. package/dist/llm-gateway-runtime/session-ledger.js.map +1 -1
  28. package/dist/llm-gateway-runtime/sticky.d.ts +25 -52
  29. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
  30. package/dist/llm-gateway-runtime/sticky.js +36 -605
  31. package/dist/llm-gateway-runtime/sticky.js.map +1 -1
  32. package/dist/llm-mesh.d.ts +19 -72
  33. package/dist/llm-mesh.d.ts.map +1 -1
  34. package/dist/llm-mesh.js +42 -399
  35. package/dist/llm-mesh.js.map +1 -1
  36. package/dist/llm-routing-config.d.ts +28 -0
  37. package/dist/llm-routing-config.d.ts.map +1 -0
  38. package/dist/llm-routing-config.js +100 -0
  39. package/dist/llm-routing-config.js.map +1 -0
  40. package/dist/native-host.d.ts +146 -0
  41. package/dist/native-host.d.ts.map +1 -0
  42. package/dist/native-host.js +456 -0
  43. package/dist/native-host.js.map +1 -0
  44. package/dist/native-terminal/host.d.ts +317 -1
  45. package/dist/native-terminal/host.d.ts.map +1 -1
  46. package/dist/native-terminal/host.js +696 -3
  47. package/dist/native-terminal/host.js.map +1 -1
  48. package/dist/native-terminal/op.d.ts +5 -0
  49. package/dist/native-terminal/op.d.ts.map +1 -0
  50. package/dist/native-terminal/op.js +408 -0
  51. package/dist/native-terminal/op.js.map +1 -0
  52. package/dist/native-terminal/process.d.ts +2 -0
  53. package/dist/native-terminal/process.d.ts.map +1 -1
  54. package/dist/native-terminal/process.js +16 -2
  55. package/dist/native-terminal/process.js.map +1 -1
  56. package/dist/native-terminal/server.d.ts.map +1 -1
  57. package/dist/native-terminal/server.js +6 -2
  58. package/dist/native-terminal/server.js.map +1 -1
  59. package/dist/native-terminal/socket-path.d.ts +8 -0
  60. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  61. package/dist/native-terminal/socket-path.js +31 -1
  62. package/dist/native-terminal/socket-path.js.map +1 -1
  63. package/dist/native-terminal/supervisor.d.ts +13 -0
  64. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  65. package/dist/native-terminal/supervisor.js +45 -0
  66. package/dist/native-terminal/supervisor.js.map +1 -1
  67. package/dist/pty.d.ts +23 -0
  68. package/dist/pty.d.ts.map +1 -1
  69. package/dist/pty.js +35 -0
  70. package/dist/pty.js.map +1 -1
  71. package/dist/readiness.d.ts +7 -0
  72. package/dist/readiness.d.ts.map +1 -1
  73. package/dist/readiness.js +12 -9
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/registry.d.ts +324 -2
  76. package/dist/registry.d.ts.map +1 -1
  77. package/dist/registry.js +659 -20
  78. package/dist/registry.js.map +1 -1
  79. package/dist/restore.d.ts +91 -32
  80. package/dist/restore.d.ts.map +1 -1
  81. package/dist/restore.js +226 -186
  82. package/dist/restore.js.map +1 -1
  83. package/dist/status-projection.d.ts.map +1 -1
  84. package/dist/tmux.d.ts +9 -6
  85. package/dist/tmux.d.ts.map +1 -1
  86. package/dist/tmux.js +34 -8
  87. package/dist/tmux.js.map +1 -1
  88. package/package.json +3 -3
  89. package/dist/llm-gateway-runtime/accounts.d.ts +0 -62
  90. package/dist/llm-gateway-runtime/accounts.d.ts.map +0 -1
  91. package/dist/llm-gateway-runtime/accounts.js +0 -323
  92. package/dist/llm-gateway-runtime/accounts.js.map +0 -1
  93. package/dist/llm-gateway-runtime/model-catalog.d.ts +0 -44
  94. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +0 -1
  95. package/dist/llm-gateway-runtime/model-catalog.js +0 -191
  96. package/dist/llm-gateway-runtime/model-catalog.js.map +0 -1
  97. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +0 -3
  98. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +0 -1
  99. package/dist/llm-gateway-runtime/proxy-anthropic.js +0 -318
  100. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +0 -1
  101. package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts +0 -11
  102. package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts.map +0 -1
  103. package/dist/llm-gateway-runtime/proxy-cloud-code.js +0 -215
  104. package/dist/llm-gateway-runtime/proxy-cloud-code.js.map +0 -1
  105. package/dist/llm-gateway-runtime/proxy-openai.d.ts +0 -118
  106. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +0 -1
  107. package/dist/llm-gateway-runtime/proxy-openai.js +0 -1305
  108. package/dist/llm-gateway-runtime/proxy-openai.js.map +0 -1
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { syncConversation } from "./sync.js";
18
18
  import { attachLocalSession, attachPodTmux, capturePane, capturePaneVisible, clearPaneComposer, conductorRunning, currentTmuxSessionName, currentTmuxSessionIs, ensureManagedTmuxProfile, ensureHeadlessTerminal, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, listH2aStatusSurfaces, listLocalSessionsWithDiagnostics, localSessionIdle, localSessionPanePid, localSessionName, paneProcessObservation, paneTreeCpuMs, paneWorkerAttribution, paneWorkerAttributionFromView, paneWorkerPid, pasteLiteralBlock, submitPane, installH2aStatusSurface, managedSessionCandidates, parseManagedSessionName, openH2aStatusWindow, persistLaunchContext, readLaunchContext, readFleetProcView, relaunchInSession, resolveAgentPane, resolveAgentPaneForInstance, resolveLocalSession, sessionRelaunchSafety, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, uninstallH2aStatusSurface, } from "./tmux.js";
19
19
  import { deliverInitialPrompt, sleepSync, } from "./prompt-delivery.js";
20
20
  import { buildLaunchContext } from "./launch-context.js";
21
+ import { attachNativeSession, killNativeSessionTree, nativeHostAvailable, nativePromptDeliveryDeps, nativeSessionLiveness, nativeSessionPid, nativeTreeCpuMs, nativeWorkerPid, resolveSessionHostKind, startNativeH2aSidecar, startNativeHeadlessSession, startNativeSession, } from "./native-host.js";
21
22
  import { migrateTmuxNames } from "./tmux-name-migration.js";
22
23
  import { projectStatusForH2a } from "./status-projection.js";
23
24
  import { buildAgentLaunchArgs, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
@@ -25,7 +26,7 @@ import { deriveSessionClass } from "./session-class.js";
25
26
  import { isRelaunchKillable, planRelaunch, relaunchContinuationPrompt, wakeRelaunchedSession, } from "./relaunch.js";
26
27
  import { isHumanFacingSession, legacySessionEvidence, readConversationCustomTitle, readLastLayout, reconcileRunConvIds, restore as restoreLayout, } from "./restore.js";
27
28
  import { getLayoutConfig } from "./config.js";
28
- import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, loadRegistryWithDiagnostics, resolveLocalTmuxSessionForName, tryClaimSlot, withRegistryLock, resolveRegistryPath, } from "./registry.js";
29
+ import { isManagedLocalKind, advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, loadRegistryWithDiagnostics, probeNativeSession, probeTmuxSession, registryEntriesForNativeTarget, resolveLocalTmuxSessionForName, resolveManagedHost, tryClaimSlot, unreadableRegistryRowsForTarget, withRegistryLock, resolveRegistryPath, } from "./registry.js";
29
30
  import { auditSessionLeaseFleet, countLiveSessionLeases, SessionLeaseStore, decideSessionBeat, isSessionLeaseAlive, isSessionLeaseAbandoned, isSessionLeaseDeadBoot, planLeaseSupervision, reclaimProposals, resolveSessionLeasePath, } from "./session-lease.js";
30
31
  import { aimdEffectiveCap, assertDelegateEffort, assertSafeName, buildDelegateArgs, buildJobRows, buildRemoteDelegate, buildThrottleResumeArgs, canDelegateAtDepth, childDepthEnvValue, clampDepth, clampRemoteDepthBudget, conductorAdvisory, DEFAULT_MAX_CONCURRENT, DEPTH_ENV, inheritedDepthBudget, isDelegateType, isThrottleResumeDue, jitteredBackoffMs, JOB_ID_ENV, jobDir, planNextStarts, planThrottleStep, readJobResult, THROTTLE_MAX_ATTEMPTS, reconcileRemoteJobs, renderJobsTable, resolveJobCwd, runTrackMirror, sweepStaleJobs, trackItemNewArgs, trackItemRealizeArgs, } from "./delegate.js";
31
32
  import { detectThrottle, THROTTLE_TAIL_LINES } from "./throttle-signatures.js";
@@ -68,7 +69,8 @@ import { appendSessionLogEntry, clearBinding, clearExhaustion, enrollAccount, li
68
69
  const H2A_RUN_API_VERSION = "h2a.run/v1";
69
70
  const H2A_RUNTIME_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
70
71
  import { CLI_PROFILES, gatewayModeForProfile, profileUsesLlmMeshGateway, } from "./protocol-local.js";
71
- import { enrollViaFacade, recordFacadeEnrollment, readLlmMeshConfig, startGateway, stopGateway, readGatewayPid, llmMeshLogPath, replaceAnthropicGatewayEnvironment, readLlmMeshSessionEnv, acquireLlmMeshSessionEnv, } from "./llm-mesh.js";
72
+ import { enrollViaFacade, readLlmMeshConfig, startGateway, stopGateway, readGatewayPid, llmMeshLogPath, replaceAnthropicGatewayEnvironment, acquireLlmMeshSessionEnv, updateLlmMeshRoutingConfig, } from "./llm-mesh.js";
73
+ import { describeLlmMeshRoutingConfig, parseLlmMeshRoutingConfig, preferredRoutingConfig, strategyRoutingConfig, } from "./llm-routing-config.js";
72
74
  const KNOWN_PROFILE_HELP = `${CLI_PROFILES.join(", ")} (aliases: claude-code, antigravity, gemini-cli, mistralcli)`;
73
75
  export const packageName = "@sentropic/h2a-runtime";
74
76
  export const H2A_RUNTIME_CLI_API_VERSION = 1;
@@ -792,9 +794,25 @@ export async function startJob(job) {
792
794
  }
793
795
  }
794
796
  // --- LOCAL (P1 path), now also queue-driven. ------------------------------
795
- if (!tmuxAvailable()) {
797
+ // Honor the job's RECORDED host kind (a pending job enqueued before the
798
+ // native default keeps tmux); jobs without a local kind take the default.
799
+ const jobHostKind = job.kind === "local-native" || job.kind === "local-tmux"
800
+ ? job.kind
801
+ : resolveSessionHostKind({}) === "native"
802
+ ? "local-native"
803
+ : "local-tmux";
804
+ if (jobHostKind === "local-tmux" && !tmuxAvailable()) {
796
805
  return { started: false, error: "tmux is not installed locally" };
797
806
  }
807
+ if (jobHostKind === "local-native") {
808
+ const native = nativeHostAvailable();
809
+ if (!native.ok) {
810
+ return {
811
+ started: false,
812
+ error: `the native PTY host is unavailable: ${native.reason} (delegate with --tmux to use tmux)`,
813
+ };
814
+ }
815
+ }
798
816
  const originCwd = job.originCwd ?? process.cwd();
799
817
  let argv;
800
818
  try {
@@ -913,12 +931,26 @@ export async function startJob(job) {
913
931
  try {
914
932
  if (headless) {
915
933
  const dir = jobDir(originCwd, job.id);
916
- const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
934
+ if (jobHostKind === "local-native") {
935
+ const launched = startNativeHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, false, "background");
936
+ tmuxSession = launched.name;
937
+ workerPid = nativeWorkerPid(launched.name);
938
+ }
939
+ else {
940
+ const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
941
+ tmuxSession = launched.name;
942
+ agentPane = launched.agentPane;
943
+ workerPid = launched.agentPane
944
+ ? localSessionPanePid(launched.agentPane)
945
+ : undefined;
946
+ }
947
+ }
948
+ else if (jobHostKind === "local-native") {
949
+ const launched = startNativeSession(job.tool, argv.command, runCwd, argv.args, job.id, { sessionClass: "background" });
917
950
  tmuxSession = launched.name;
918
- agentPane = launched.agentPane;
919
- workerPid = launched.agentPane
920
- ? localSessionPanePid(launched.agentPane)
921
- : undefined;
951
+ workerPid = nativeWorkerPid(launched.name);
952
+ const h2a = getH2aConfig();
953
+ startNativeH2aSidecar(tmuxSession, runCwd, h2a.command);
922
954
  }
923
955
  else {
924
956
  const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background", attachedTerminal: true });
@@ -954,7 +986,7 @@ export async function startJob(job) {
954
986
  enroll({
955
987
  id: job.id,
956
988
  tool: job.tool,
957
- kind: "local-tmux",
989
+ kind: jobHostKind,
958
990
  cwd: runCwd,
959
991
  source: "run",
960
992
  sessionClass: "background",
@@ -1156,15 +1188,24 @@ function formatIdle(ms) {
1156
1188
  * (control-plane) are phase 2 — see the TODOs at the call site.
1157
1189
  */
1158
1190
  export function resumeThrottledJob(job) {
1159
- if (job.kind !== "local-tmux" || job.headless !== true) {
1191
+ if (!isManagedLocalKind(job.kind) || job.headless !== true) {
1160
1192
  return {
1161
1193
  started: false,
1162
1194
  error: "throttle-resume is headless-local only (phase 1)",
1163
1195
  };
1164
1196
  }
1165
- if (!tmuxAvailable()) {
1197
+ if (job.kind === "local-tmux" && !tmuxAvailable()) {
1166
1198
  return { started: false, error: "tmux is not installed locally" };
1167
1199
  }
1200
+ if (job.kind === "local-native") {
1201
+ const native = nativeHostAvailable();
1202
+ if (!native.ok) {
1203
+ return {
1204
+ started: false,
1205
+ error: `the native PTY host is unavailable: ${native.reason}`,
1206
+ };
1207
+ }
1208
+ }
1168
1209
  const task = job.task ?? "";
1169
1210
  const originCwd = job.originCwd ?? process.cwd();
1170
1211
  // The job's recorded `cwd` IS the runCwd it ran in (the worktree, or the
@@ -1221,11 +1262,18 @@ export function resumeThrottledJob(job) {
1221
1262
  let workerPid;
1222
1263
  try {
1223
1264
  const dir = jobDir(originCwd, job.id);
1224
- const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
1225
- tmuxSession = launched.name;
1226
- workerPid = launched.agentPane
1227
- ? localSessionPanePid(launched.agentPane)
1228
- : undefined;
1265
+ if (job.kind === "local-native") {
1266
+ const launched = startNativeHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, false, "background");
1267
+ tmuxSession = launched.name;
1268
+ workerPid = nativeWorkerPid(launched.name);
1269
+ }
1270
+ else {
1271
+ const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
1272
+ tmuxSession = launched.name;
1273
+ workerPid = launched.agentPane
1274
+ ? localSessionPanePid(launched.agentPane)
1275
+ : undefined;
1276
+ }
1229
1277
  }
1230
1278
  catch (err) {
1231
1279
  return { started: false, error: err.message };
@@ -1258,7 +1306,7 @@ export function resumeThrottledJob(job) {
1258
1306
  enroll({
1259
1307
  id: job.id,
1260
1308
  tool: job.tool,
1261
- kind: "local-tmux",
1309
+ kind: job.kind,
1262
1310
  cwd: runCwd,
1263
1311
  source: "run",
1264
1312
  sessionClass: "background",
@@ -1414,25 +1462,20 @@ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true
1414
1462
  // A new local CLI session needs its own gateway token so account affinity is
1415
1463
  // keyed by that session's stable identity. The cached token remains useful
1416
1464
  // for callers that do not have a session identity (for example restore).
1417
- let meshEnv = clientSessionId
1418
- ? await acquireLlmMeshSessionEnv(undefined, clientSessionId)
1419
- : readLlmMeshSessionEnv() ?? (await acquireLlmMeshSessionEnv());
1465
+ let meshEnv = await acquireLlmMeshSessionEnv(undefined, clientSessionId);
1420
1466
  if (!meshEnv) {
1421
- const config = readLlmMeshConfig();
1422
- if (config?.meshAccounts?.length) {
1423
- try {
1424
- const result = await startGateway(config, { clientSessionId });
1425
- meshEnv = {
1426
- ANTHROPIC_BASE_URL: `http://localhost:${result.port}`,
1427
- ANTHROPIC_AUTH_TOKEN: result.gatewayToken,
1428
- ANTHROPIC_API_KEY: result.gatewayToken,
1429
- };
1430
- process.stderr.write(`[h2a] llm-mesh: gateway was stopped; started on ${meshEnv.ANTHROPIC_BASE_URL}\n`);
1431
- }
1432
- catch (err) {
1433
- if (allowDirectFallback) {
1434
- process.stderr.write(`[h2a] llm-mesh: gateway env unavailable (${String(err)}); Claude may ask for login.\n`);
1435
- }
1467
+ const config = readLlmMeshConfig() ?? {};
1468
+ try {
1469
+ const result = await startGateway(config, { clientSessionId });
1470
+ meshEnv = {
1471
+ ANTHROPIC_BASE_URL: `http://localhost:${result.port}`,
1472
+ ANTHROPIC_AUTH_TOKEN: result.gatewayToken,
1473
+ };
1474
+ process.stderr.write(`[h2a] llm-mesh: gateway was stopped; started on ${meshEnv.ANTHROPIC_BASE_URL}\n`);
1475
+ }
1476
+ catch (err) {
1477
+ if (allowDirectFallback) {
1478
+ process.stderr.write(`[h2a] llm-mesh: gateway env unavailable (${String(err)}); Claude may ask for login.\n`);
1436
1479
  }
1437
1480
  }
1438
1481
  }
@@ -1444,7 +1487,11 @@ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true
1444
1487
  }
1445
1488
  const alreadyCurrent = process.env.ANTHROPIC_BASE_URL === meshEnv.ANTHROPIC_BASE_URL &&
1446
1489
  process.env.ANTHROPIC_AUTH_TOKEN === meshEnv.ANTHROPIC_AUTH_TOKEN &&
1447
- process.env.ANTHROPIC_API_KEY === meshEnv.ANTHROPIC_API_KEY;
1490
+ process.env.ANTHROPIC_API_KEY === undefined;
1491
+ // Claude Code treats ANTHROPIC_API_KEY as a user-supplied third-party key
1492
+ // and interrupts startup with a confirmation modal. The local gateway uses
1493
+ // the documented custom-base-url lane: AUTH_TOKEN only.
1494
+ delete process.env.ANTHROPIC_API_KEY;
1448
1495
  for (const [k, v] of Object.entries(meshEnv))
1449
1496
  process.env[k] = v;
1450
1497
  if (!alreadyCurrent) {
@@ -1468,14 +1515,7 @@ export async function prepareLlmMeshForRestore(opts = {}, context = {
1468
1515
  const mode = opts.mode ?? "auto";
1469
1516
  if (mode !== "gateway" && !context.runtimeEnabled)
1470
1517
  return;
1471
- const config = context.config;
1472
- if (!config?.meshAccounts?.length) {
1473
- if (mode === "gateway") {
1474
- throw new Error("llm-mesh gateway is required but unavailable: no Cloud Code enrollment; no agent was started");
1475
- }
1476
- process.stderr.write("[h2a] llm-mesh: restore config enabled, but no llm-mesh account is enrolled; Claude may ask for login.\n");
1477
- return;
1478
- }
1518
+ const config = context.config ?? {};
1479
1519
  const port = config.port ?? 3002;
1480
1520
  const pid = context.gatewayPid;
1481
1521
  if (opts.dryRun) {
@@ -1489,7 +1529,9 @@ export async function prepareLlmMeshForRestore(opts = {}, context = {
1489
1529
  process.stderr.write(`[h2a] llm-mesh: restore context active (${gateway})\n`);
1490
1530
  }
1491
1531
  }
1492
- function registryEntriesForLocalTmuxTarget(target, local, entries = loadRegistry()) {
1532
+ function registryEntriesForLocalTmuxTarget(target, local,
1533
+ // No default read: the caller owns the registry READ STATE (F2).
1534
+ entries) {
1493
1535
  const parsedTarget = parseManagedSessionName(target);
1494
1536
  const canonicalSlug = local?.slug ?? parsedTarget?.slug ?? target;
1495
1537
  const tmuxSession = local?.name ?? target;
@@ -1515,9 +1557,21 @@ function registryEntriesForLocalTmuxTarget(target, local, entries = loadRegistry
1515
1557
  (e.tmuxSession !== undefined && candidates.includes(e.tmuxSession)));
1516
1558
  });
1517
1559
  }
1518
- function registryEntryForResumeTarget(target, local) {
1519
- const evidence = legacySessionEvidence(homedir(), local === undefined ? listLocalSessions() : [local]);
1520
- const matches = registryEntriesForLocalTmuxTarget(target, local).filter((e) => {
1560
+ export function registryEntryForResumeTarget(target, local,
1561
+ // No default read: resume performs ONE registry read and hands the same
1562
+ // snapshot to this lookup AND the host resolver — an unknown read refuses
1563
+ // upstream instead of flattening into "no entry" here (F1/F2).
1564
+ entries, evidence = legacySessionEvidence(homedir(), local === undefined ? listLocalSessions() : [local])) {
1565
+ const matches = [
1566
+ ...registryEntriesForLocalTmuxTarget(target, local, entries),
1567
+ // A persisted kind:"local-native" session has no tmux-side identity, so
1568
+ // the (deliberately tmux-scoped) filter above can never return it. This
1569
+ // second leg resolves it by its own persisted identity — without it,
1570
+ // resume falls through to the default-host path and a recorded native
1571
+ // session would be re-created on tmux. The host is then chosen from
1572
+ // entry.kind (never liveness) further down the resume verb.
1573
+ ...registryEntriesForNativeTarget(target, entries),
1574
+ ].filter((e) => {
1521
1575
  // Resume is a human-facing operation, not a promotion path. Share the
1522
1576
  // durable class test with restore so legacy human rows remain resumable,
1523
1577
  // while delegated jobs and explicit background launches stay excluded.
@@ -1537,7 +1591,20 @@ function registryEntryForResumeTarget(target, local) {
1537
1591
  * that names both prefix generations to fall through to a remote session.
1538
1592
  */
1539
1593
  function resolveManagedLocalTarget(target) {
1540
- const live = resolveLocalSession(target);
1594
+ // CONJUNCTION RULE (F2): "no local session" may only be concluded when
1595
+ // local absence is POSITIVELY known EVERYWHERE — the tmux inventory read
1596
+ // succeeded and has no session, the registry read succeeded and has no
1597
+ // row, and the native probe positively answered dead. ONE unknown
1598
+ // anywhere poisons the conjunction: the result is "unknown", never
1599
+ // "missing" (a destructive caller must refuse, not fall through).
1600
+ const tmuxView = listLocalSessionsWithDiagnostics();
1601
+ if (!tmuxView.known) {
1602
+ return {
1603
+ kind: "unknown",
1604
+ reason: `tmux inventory unavailable${tmuxView.reason ? ` (${tmuxView.reason})` : ""}`,
1605
+ };
1606
+ }
1607
+ const live = resolveLocalSession(target, tmuxView.sessions);
1541
1608
  if (live.kind === "found") {
1542
1609
  return { kind: "found", name: live.session.name, local: live.session };
1543
1610
  }
@@ -1547,13 +1614,40 @@ function resolveManagedLocalTarget(target) {
1547
1614
  names: live.sessions.map((session) => session.name).sort(),
1548
1615
  };
1549
1616
  }
1550
- const registered = resolveLocalTmuxSessionForName(target, loadRegistry());
1617
+ const registryRead = loadRegistry();
1618
+ if (registryRead.state === "unknown") {
1619
+ // The durable record cannot be read: a local session may exist behind
1620
+ // this name — transported as "unknown", never flattened to "missing".
1621
+ return {
1622
+ kind: "unknown",
1623
+ reason: `registry read failed: ${registryRead.reason}`,
1624
+ };
1625
+ }
1626
+ const registered = resolveLocalTmuxSessionForName(target, registryRead.entries);
1551
1627
  if (registered.kind === "found") {
1552
1628
  return { kind: "found", name: registered.name };
1553
1629
  }
1554
1630
  if (registered.kind === "ambiguous") {
1555
1631
  return { kind: "ambiguous", names: [...registered.names].sort() };
1556
1632
  }
1633
+ // Self-healing for native sessions: the native HOST is the terminal's
1634
+ // source of truth (the tmux-server twin). A registry row lost to a
1635
+ // pre-native h2a write (old validators drop kind:"local-native" rows on
1636
+ // rewrite) must not strand a live session, so probe the host directly.
1637
+ // The probe is 3-state: a FAILED probe is "unknown" (a live native
1638
+ // session cannot be ruled out), never a fall-through to "missing".
1639
+ for (const candidate of [target, localSessionName(slugify(target))]) {
1640
+ const alive = nativeSessionLiveness(candidate);
1641
+ if (alive === true) {
1642
+ return { kind: "found", name: candidate };
1643
+ }
1644
+ if (alive === "unknown") {
1645
+ return {
1646
+ kind: "unknown",
1647
+ reason: `native host probe failed for ${candidate}`,
1648
+ };
1649
+ }
1650
+ }
1557
1651
  return { kind: "missing" };
1558
1652
  }
1559
1653
  function reportAmbiguousLocalTarget(target, action, names) {
@@ -1977,6 +2071,10 @@ export async function main(argv) {
1977
2071
  }
1978
2072
  }
1979
2073
  finally {
2074
+ // REMOTE identity from THIS pull's own marker record (F1
2075
+ // sorting): the local managed-host resolver has no remote leg —
2076
+ // the session id comes from the marker this flow created and is
2077
+ // stopped as the flow's own completion step, not re-resolved.
1980
2078
  await stopRemoteSession(marker.remote, session.id, "pull-complete");
1981
2079
  }
1982
2080
  if (!remoteArchive) {
@@ -3399,17 +3497,16 @@ export async function main(argv) {
3399
3497
  .option("--codex [convId]", "resume/enroll a Codex conversation from the current directory; omit convId to let Codex show its selector")
3400
3498
  .option("--last", "with --claude/--codex, resume the last conversation for the current directory instead of showing a selector")
3401
3499
  .option("--attach", "attach after a successful resume, or attach an already-active session")
3500
+ .option("--tmux", "host a BRAND-NEW resumed session in tmux instead of the native PTY host (permanent opt-in; native is only the default). An existing session never re-routes: it always resumes on its recorded host")
3402
3501
  .option("--replace", "replace an existing idle local tmux session without interactive confirmation")
3403
3502
  .option("--llm-gateway", "launch the CLI through the local llm-mesh gateway")
3404
3503
  .option("--gw", "alias for --llm-gateway")
3405
3504
  .option("--no-llm-gateway", "launch without llm-mesh gateway env")
3406
3505
  .option("--no-gw", "alias for --no-llm-gateway")
3407
3506
  .action(async (slug, opts) => {
3408
- if (!tmuxAvailable()) {
3409
- process.stderr.write("[h2a] tmux is not installed locally — `h2a resume` needs it (e.g. `sudo apt install tmux`).\n");
3410
- process.exitCode = 1;
3411
- return;
3412
- }
3507
+ // NOTE: tmux availability is checked AFTER host resolution below — a
3508
+ // `--tmux` flag must not block acting on an existing session whose
3509
+ // recorded host does not need tmux at all.
3413
3510
  const explicitProfile = opts.claude !== undefined
3414
3511
  ? "claude"
3415
3512
  : opts.codex !== undefined
@@ -3450,17 +3547,32 @@ export async function main(argv) {
3450
3547
  return;
3451
3548
  }
3452
3549
  const target = slug ?? slugify(process.cwd());
3453
- const localResolution = resolveLocalSession(target);
3454
- if (localResolution.kind === "ambiguous") {
3455
- reportAmbiguousLocalTarget(target, "resume", localResolution.sessions.map((session) => session.name));
3550
+ // PRE-RESOLUTION tmux view — display + registry-lookup input ONLY,
3551
+ // never an act target: every act/attach/kill below derives from
3552
+ // resolveManagedHost's kind + name (F1). The rename makes any act on
3553
+ // it read as what it would be: acting on an unresolved object.
3554
+ const preResolutionTmux = resolveLocalSession(target);
3555
+ if (preResolutionTmux.kind === "ambiguous") {
3556
+ reportAmbiguousLocalTarget(target, "resume", preResolutionTmux.sessions.map((session) => session.name));
3456
3557
  process.exitCode = 1;
3457
3558
  return;
3458
3559
  }
3459
- const local = localResolution.kind === "found"
3460
- ? localResolution.session
3560
+ const preResolutionTmuxView = preResolutionTmux.kind === "found"
3561
+ ? preResolutionTmux.session
3461
3562
  : undefined;
3462
- const displaySlug = local?.slug ?? parseManagedSessionName(target)?.slug ?? target;
3463
- const registryEntry = registryEntryForResumeTarget(target, local);
3563
+ const displaySlug = preResolutionTmuxView?.slug ??
3564
+ parseManagedSessionName(target)?.slug ??
3565
+ target;
3566
+ // ONE registry read for the whole resume: the entry lookup and the
3567
+ // host resolver consume the SAME snapshot. Resume can kill — an
3568
+ // unreadable registry refuses up front (F2), never "no entry".
3569
+ const registryRead = loadRegistry();
3570
+ if (registryRead.state === "unknown") {
3571
+ process.stderr.write(`[h2a] cannot resume ${displaySlug}: the local registry is unreadable (${registryRead.reason}); refusing a destructive act on an unproven host state.\n`);
3572
+ process.exitCode = 1;
3573
+ return;
3574
+ }
3575
+ const registryEntry = registryEntryForResumeTarget(target, preResolutionTmuxView, registryRead.entries);
3464
3576
  const resolvedConvId = explicitProfile === "claude" && opts.last
3465
3577
  ? localConvStat(process.cwd())?.convId
3466
3578
  : explicitConvId;
@@ -3473,7 +3585,9 @@ export async function main(argv) {
3473
3585
  ? {
3474
3586
  id: displaySlug,
3475
3587
  tool: explicitProfile,
3476
- kind: "local-tmux",
3588
+ kind: (resolveSessionHostKind(opts) === "native"
3589
+ ? "local-native"
3590
+ : "local-tmux"),
3477
3591
  cwd: process.cwd(),
3478
3592
  label: displaySlug,
3479
3593
  ...(resolvedConvId ? { convId: resolvedConvId } : {}),
@@ -3483,12 +3597,71 @@ export async function main(argv) {
3483
3597
  source: "run",
3484
3598
  }
3485
3599
  : registryEntry;
3486
- const localIdle = local ? localSessionIdle(local.name) : false;
3487
- if (!entry && local && !localIdle && !opts.replace) {
3600
+ // WHICH host serves this resume — resolved BEFORE ANY act (F1): every
3601
+ // attach/kill/start below derives EXCLUSIVELY from this resolution's
3602
+ // kind + name over the ONE registry snapshot read above. An option
3603
+ // expresses an intent for a NEW session; it must NEVER win over the
3604
+ // recorded host of an EXISTING session. The symmetric resolver
3605
+ // consults BOTH persisted kinds (dual rows fail closed) and probes
3606
+ // only for unrecorded sessions (--tmux stays the permanent opt-in
3607
+ // creation switch — only the DEFAULT is native).
3608
+ // The resolver QUERY: recorded facts for a registry-backed entry; the
3609
+ // OBSERVED live identity (else the requested target) otherwise. A
3610
+ // synthesized explicit-profile entry's tmuxSession is an INTENT (the
3611
+ // canonical name a NEW session would take), not a recorded fact — it
3612
+ // must not hide an existing session living under a legacy name.
3613
+ const hostQuery = entry && !explicitProfile
3614
+ ? (entry.tmuxSession ?? entry.id)
3615
+ : (preResolutionTmuxView?.name ?? target);
3616
+ // The WHOLE read (not just `.entries`, PART A/A2): an unreadable
3617
+ // twin of `hostQuery` must poison this resolution BEFORE any act
3618
+ // below derives from it, never only surface at the pre-kill recheck.
3619
+ const hostResolution = resolveManagedHost(hostQuery, registryRead);
3620
+ if (hostResolution.state === "ambiguous") {
3621
+ process.stderr.write(`[h2a] cannot resume ${displaySlug}: this managed identity is recorded/live on BOTH local hosts (${hostResolution.candidates.join(", ")}); refusing to pick one. Clean up the conflicting registry rows first.\n`);
3622
+ process.exitCode = 1;
3623
+ return;
3624
+ }
3625
+ if (hostResolution.state === "unknown") {
3626
+ process.stderr.write(`[h2a] cannot resume ${displaySlug}: local host state is unknown (${hostResolution.reason}); refusing to guess a host.\n`);
3627
+ process.exitCode = 1;
3628
+ return;
3629
+ }
3630
+ const hostIsExisting = hostResolution.state === "recorded" ||
3631
+ hostResolution.state === "discovered";
3632
+ // Liveness ON THE RESOLVED HOST gates the acts below. 3-state: a
3633
+ // probe failure REFUSES (resume can kill) — never absence (F2).
3634
+ const actLiveness = hostIsExisting
3635
+ ? hostResolution.kind === "local-native"
3636
+ ? probeNativeSession(hostResolution.name)
3637
+ : probeTmuxSession(hostResolution.name)
3638
+ : "dead";
3639
+ if (actLiveness === "unknown") {
3640
+ process.stderr.write(`[h2a] cannot resume ${displaySlug}: the resolved local host state is unknown (probe failed); refusing a destructive act on an unproven host.\n`);
3641
+ process.exitCode = 1;
3642
+ return;
3643
+ }
3644
+ // The ONE act target for every effect below (F1): the resolver's
3645
+ // exact kind + name, present only while the session is LIVE there.
3646
+ const liveActTarget = hostIsExisting && actLiveness === "live"
3647
+ ? { kind: hostResolution.kind, name: hostResolution.name }
3648
+ : undefined;
3649
+ // Idle is a TMUX pane concept; a live native session is conservatively
3650
+ // NOT idle (never auto-replaced without an explicit --replace).
3651
+ const actIdle = liveActTarget !== undefined && liveActTarget.kind === "local-tmux"
3652
+ ? localSessionIdle(liveActTarget.name)
3653
+ : false;
3654
+ if (!entry &&
3655
+ liveActTarget &&
3656
+ liveActTarget.kind === "local-tmux" &&
3657
+ !actIdle &&
3658
+ !opts.replace) {
3488
3659
  process.stderr.write(`[h2a] local session ${displaySlug} already exists and does not look idle; no new CLI was started.\n`);
3489
3660
  if (opts.attach || explicitProfile) {
3490
3661
  process.stderr.write(`[h2a] switching to existing session ${displaySlug}\n`);
3491
- process.exitCode = attachLocalSession(local.name);
3662
+ // This gate is tmux-scoped (see condition); the attach targets
3663
+ // the RESOLVED exact name (F1).
3664
+ process.exitCode = attachLocalSession(liveActTarget.name);
3492
3665
  return;
3493
3666
  }
3494
3667
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
@@ -3497,7 +3670,7 @@ export async function main(argv) {
3497
3670
  }
3498
3671
  if (!entry) {
3499
3672
  process.stderr.write(`[h2a] cannot resume ${displaySlug}: registry has no single local-tmux entry with profile, cwd and convId.\n`);
3500
- if (local) {
3673
+ if (liveActTarget) {
3501
3674
  process.stderr.write(`[h2a] local session ${displaySlug} exists but cannot be relaunched without a recorded convId.\n`);
3502
3675
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
3503
3676
  }
@@ -3532,12 +3705,47 @@ export async function main(argv) {
3532
3705
  process.exitCode = 1;
3533
3706
  return;
3534
3707
  }
3535
- if (local && !localIdle && !opts.replace) {
3708
+ if (opts.tmux === true &&
3709
+ hostIsExisting &&
3710
+ hostResolution.kind === "local-native") {
3711
+ process.stderr.write(`[h2a] --tmux ignored for ${displaySlug}: an existing session keeps its recorded host (--tmux only applies to a brand-new session).\n`);
3712
+ }
3713
+ const resumeHost = hostIsExisting
3714
+ ? hostResolution.kind === "local-native"
3715
+ ? "native"
3716
+ : "local-tmux"
3717
+ : resolveSessionHostKind(opts);
3718
+ // Host availability is checked BEFORE any replace/kill step so a live
3719
+ // session can never be destroyed for a launch that cannot happen.
3720
+ if (resumeHost === "native") {
3721
+ const native = nativeHostAvailable();
3722
+ if (!native.ok) {
3723
+ process.stderr.write(`[h2a] the native PTY host is unavailable: ${native.reason}\n` +
3724
+ (hostIsExisting
3725
+ ? "[h2a] the session keeps its recorded host; nothing was started.\n"
3726
+ : "[h2a] pass --tmux to start the new session in tmux instead.\n"));
3727
+ process.exitCode = 1;
3728
+ return;
3729
+ }
3730
+ }
3731
+ else if (!tmuxAvailable()) {
3732
+ process.stderr.write("[h2a] tmux is not installed locally - resuming on the tmux host needs it (e.g. `sudo apt install tmux`).\n");
3733
+ process.exitCode = 1;
3734
+ return;
3735
+ }
3736
+ // A live NATIVE session without --replace deliberately falls PAST
3737
+ // these tmux gates to the start path: startNativeSession ADOPTS the
3738
+ // existing native session (Lot A semantics) — nothing is killed.
3739
+ if (liveActTarget &&
3740
+ liveActTarget.kind === "local-tmux" &&
3741
+ !actIdle &&
3742
+ !opts.replace) {
3536
3743
  const gatewayEnvStatus = explicitProfile === "claude" &&
3537
3744
  (profile === "claude" || profile === "claude-code") &&
3745
+ liveActTarget.kind === "local-tmux" &&
3538
3746
  process.env.ANTHROPIC_BASE_URL &&
3539
3747
  process.env.ANTHROPIC_AUTH_TOKEN
3540
- ? localSessionGatewayEnvStatus(local.name, {
3748
+ ? localSessionGatewayEnvStatus(liveActTarget.name, {
3541
3749
  baseUrl: process.env.ANTHROPIC_BASE_URL,
3542
3750
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
3543
3751
  })
@@ -3548,9 +3756,11 @@ export async function main(argv) {
3548
3756
  }
3549
3757
  process.stderr.write(`[h2a] local session ${displaySlug} already exists and does not look idle; no new ${profile} was started.\n`);
3550
3758
  if (opts.attach || explicitProfile) {
3551
- if (explicitProfile && currentTmuxSessionIs(local.name)) {
3552
- gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, local.name);
3553
- persistLaunchContext(local.name, buildLaunchContext({
3759
+ if (explicitProfile &&
3760
+ liveActTarget.kind === "local-tmux" &&
3761
+ currentTmuxSessionIs(liveActTarget.name)) {
3762
+ gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, liveActTarget.name);
3763
+ persistLaunchContext(liveActTarget.name, buildLaunchContext({
3554
3764
  profile,
3555
3765
  cwd: entry.cwd,
3556
3766
  label: displaySlug,
@@ -3561,20 +3771,23 @@ export async function main(argv) {
3561
3771
  return;
3562
3772
  }
3563
3773
  process.stderr.write(`[h2a] switching to existing session ${displaySlug}\n`);
3564
- process.exitCode = attachLocalSession(local.name);
3774
+ // This gate is tmux-scoped (see condition); the attach targets
3775
+ // the RESOLVED exact name (F1).
3776
+ process.exitCode = attachLocalSession(liveActTarget.name);
3565
3777
  return;
3566
3778
  }
3567
3779
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
3568
3780
  process.exitCode = 2;
3569
3781
  return;
3570
3782
  }
3571
- if (local) {
3572
- if (!localIdle && opts.replace) {
3573
- process.stderr.write(`[h2a] local session ${displaySlug} does not look idle; --replace will kill tmux session ${local.name} before resuming${entry.convId ? ` ${entry.convId}` : ""}.\n`);
3783
+ if (liveActTarget &&
3784
+ (liveActTarget.kind === "local-tmux" || opts.replace)) {
3785
+ if (!actIdle && opts.replace) {
3786
+ process.stderr.write(`[h2a] local session ${displaySlug} does not look idle; --replace will kill ${liveActTarget.kind} session ${liveActTarget.name} before resuming${entry.convId ? ` ${entry.convId}` : ""}.\n`);
3574
3787
  }
3575
3788
  if (!opts.replace) {
3576
3789
  process.stderr.write(`[h2a] local session ${displaySlug} already exists.\n`);
3577
- process.stderr.write(`[h2a] pane appears idle; replacing it will kill tmux session ${local.name} and resume${entry.convId ? ` conversation ${entry.convId}` : ""} in ${entry.cwd}.\n`);
3790
+ process.stderr.write(`[h2a] pane appears idle; replacing it will kill ${liveActTarget.kind} session ${liveActTarget.name} and resume${entry.convId ? ` conversation ${entry.convId}` : ""} in ${entry.cwd}.\n`);
3578
3791
  process.stderr.write(`[h2a] If another CLI is still writing this conversation, replacing can corrupt the .jsonl.\n`);
3579
3792
  if (!(await confirmReplace(displaySlug))) {
3580
3793
  process.stderr.write(`[h2a] takeover requires confirmation.\n`);
@@ -3585,13 +3798,27 @@ export async function main(argv) {
3585
3798
  return;
3586
3799
  }
3587
3800
  }
3588
- const rechecked = findLocalSession(displaySlug);
3589
- if (!rechecked) {
3801
+ // RECHECK immediately before the kill — the SAME resolver over a
3802
+ // FRESH read (never a tmux-only lookup): the identity must still
3803
+ // resolve to the SAME host + name and still be live there; any
3804
+ // drift (or an unknown) aborts without killing anything.
3805
+ const recheck = resolveManagedHost(hostQuery, loadRegistry());
3806
+ const recheckSameHost = (recheck.state === "recorded" || recheck.state === "discovered") &&
3807
+ recheck.kind === liveActTarget.kind &&
3808
+ recheck.name === liveActTarget.name;
3809
+ const recheckLiveness = recheckSameHost
3810
+ ? liveActTarget.kind === "local-native"
3811
+ ? probeNativeSession(liveActTarget.name)
3812
+ : probeTmuxSession(liveActTarget.name)
3813
+ : "unknown";
3814
+ if (!recheckSameHost || recheckLiveness !== "live") {
3590
3815
  process.stderr.write(`[h2a] local session ${displaySlug} changed state before replace; aborting.\n`);
3591
3816
  process.exitCode = 1;
3592
3817
  return;
3593
3818
  }
3594
- if (!opts.replace && !localSessionIdle(rechecked.name)) {
3819
+ if (!opts.replace &&
3820
+ liveActTarget.kind === "local-tmux" &&
3821
+ !localSessionIdle(liveActTarget.name)) {
3595
3822
  process.stderr.write(`[h2a] local session ${displaySlug} is no longer idle; aborting.\n`);
3596
3823
  process.exitCode = 1;
3597
3824
  return;
@@ -3611,12 +3838,17 @@ export async function main(argv) {
3611
3838
  return;
3612
3839
  }
3613
3840
  }
3614
- if (!killLocalSession(rechecked.name)) {
3841
+ // The kill derives from the RESOLVED kind + name only (F1): a
3842
+ // homonymous session on the OTHER host must never absorb it.
3843
+ const killed = liveActTarget.kind === "local-native"
3844
+ ? killNativeSessionTree(liveActTarget.name)
3845
+ : killLocalSession(liveActTarget.name);
3846
+ if (!killed) {
3615
3847
  process.stderr.write(`[h2a] local session ${displaySlug} could not be killed; no new ${profile} was started.\n`);
3616
3848
  process.exitCode = 1;
3617
3849
  return;
3618
3850
  }
3619
- process.stderr.write(`[h2a] replaced local session ${displaySlug} (${rechecked.name})\n`);
3851
+ process.stderr.write(`[h2a] replaced local session ${displaySlug} (${liveActTarget.name})\n`);
3620
3852
  }
3621
3853
  else {
3622
3854
  if (entry.convId) {
@@ -3642,11 +3874,18 @@ export async function main(argv) {
3642
3874
  process.stdin.isTTY === true &&
3643
3875
  process.stdout.isTTY === true,
3644
3876
  });
3645
- const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass, attachedTerminal: true });
3877
+ const { name } = resumeHost === "native"
3878
+ ? startNativeSession(profile, command, entry.cwd, args, resumeSlug, {
3879
+ sessionClass,
3880
+ ...(entry.convId ? { resumeId: entry.convId } : {}),
3881
+ })
3882
+ : startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass, attachedTerminal: true });
3646
3883
  enrollFromRun({
3647
3884
  profile,
3648
3885
  slug: resumeSlug,
3649
3886
  tmuxSession: name,
3887
+ // The ACTUAL host that just started the terminal — never a default.
3888
+ hostKind: resumeHost === "native" ? "local-native" : "local-tmux",
3650
3889
  cwd: entry.cwd,
3651
3890
  sessionClass,
3652
3891
  ...(entry.convId ? { convId: entry.convId } : {}),
@@ -3657,7 +3896,10 @@ export async function main(argv) {
3657
3896
  process.stderr.write(`[h2a] gateway active: ${gateway}\n`);
3658
3897
  }
3659
3898
  if (opts.attach) {
3660
- process.exitCode = attachLocalSession(name);
3899
+ process.exitCode =
3900
+ resumeHost === "native"
3901
+ ? attachNativeSession(name)
3902
+ : attachLocalSession(name);
3661
3903
  return;
3662
3904
  }
3663
3905
  process.stderr.write(`[h2a] attach: h2a attach ${resumeSlug}\n`);
@@ -3667,6 +3909,7 @@ export async function main(argv) {
3667
3909
  .description("Start a LOCAL session in tmux (claude/codex/…) in <path> (default: cwd), then attach this terminal by default. h2a applies its embedded scroll-safe tmux profile at launch; no ~/.tmux.conf is required. Manage it with `h2a ls`, `h2a attach <slug>`, and `h2a stop <slug>`. Detach with Ctrl-b d; the session keeps running.")
3668
3910
  .option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
3669
3911
  .option("--no-attached-terminal", "disable the persistent headless tmux client (legacy compatibility; cannot be used with --prompt-stdin)")
3912
+ .option("--tmux", "host the session in tmux instead of the native PTY host (native is the default; H2A_SESSION_HOST=tmux flips the default fleet-wide)")
3670
3913
  .option("-r, --resume <convId>", "resume a specific conversation in the CLI")
3671
3914
  .option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
3672
3915
  .option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
@@ -3725,10 +3968,22 @@ export async function main(argv) {
3725
3968
  process.exitCode = 2;
3726
3969
  return;
3727
3970
  }
3728
- if (!tmuxAvailable()) {
3729
- process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
3730
- process.exitCode = 1;
3731
- return;
3971
+ const sessionHost = resolveSessionHostKind(opts);
3972
+ if (sessionHost === "local-tmux") {
3973
+ if (!tmuxAvailable()) {
3974
+ process.stderr.write("[h2a] tmux is not installed locally — `h2a run --tmux` needs it (e.g. `sudo apt install tmux`).\n");
3975
+ process.exitCode = 1;
3976
+ return;
3977
+ }
3978
+ }
3979
+ else {
3980
+ const native = nativeHostAvailable();
3981
+ if (!native.ok) {
3982
+ process.stderr.write(`[h2a] the native PTY host is unavailable: ${native.reason}\n` +
3983
+ "[h2a] pass --tmux to host this session in tmux instead.\n");
3984
+ process.exitCode = 1;
3985
+ return;
3986
+ }
3732
3987
  }
3733
3988
  let count = 1;
3734
3989
  if (opts.count !== undefined) {
@@ -3786,7 +4041,37 @@ export async function main(argv) {
3786
4041
  const labels = count > 1
3787
4042
  ? fanoutLabels(opts.name ?? basename(cwd), count)
3788
4043
  : [opts.name];
3789
- const existingLocalSessions = existingLocalSessionSlugs(labels, cwd);
4044
+ const existingTmuxSlugs = tmuxAvailable()
4045
+ ? existingLocalSessionSlugs(labels, cwd)
4046
+ : [];
4047
+ // Native existence is THREE-state (the resolver's probe vocabulary):
4048
+ // a probe FAILURE is "unknown", never absence. Treating a thrown
4049
+ // probe as "no session" would let `run --tmux --resume` start a tmux
4050
+ // twin over a live-but-unprovable native writer (sol-F3): the same
4051
+ // fail-closed rule as resolveManagedHost's rule 4 applies — refuse
4052
+ // to launch on an unprovable host state.
4053
+ const nativeExistence = labels
4054
+ .map((label) => slugify(label ?? cwd))
4055
+ .map((slug) => ({
4056
+ slug,
4057
+ probe: probeNativeSession(localSessionName(slug)),
4058
+ }));
4059
+ const unknownNativeSlugs = nativeExistence
4060
+ .filter((probed) => probed.probe === "unknown")
4061
+ .map((probed) => probed.slug);
4062
+ if (unknownNativeSlugs.length > 0) {
4063
+ for (const slug of unknownNativeSlugs) {
4064
+ process.stderr.write(`[h2a] cannot start ${slug}: native host state is unknown (the probe failed) — a probe failure is never proof of absence; refusing to risk a second writer (fail closed).\n`);
4065
+ }
4066
+ process.exitCode = 1;
4067
+ return;
4068
+ }
4069
+ const existingNativeSlugs = nativeExistence
4070
+ .filter((probed) => probed.probe === "live")
4071
+ .map((probed) => probed.slug);
4072
+ const existingLocalSessions = [
4073
+ ...new Set([...existingTmuxSlugs, ...existingNativeSlugs]),
4074
+ ];
3790
4075
  if (existingLocalSessions.length > 0) {
3791
4076
  for (const slug of existingLocalSessions) {
3792
4077
  process.stderr.write(`[h2a] local session ${slug} already exists; no new ${profile} was started.\n`);
@@ -3923,7 +4208,22 @@ export async function main(argv) {
3923
4208
  mkdirSync(runDir, { recursive: true });
3924
4209
  outputLog = join(runDir, "output.log");
3925
4210
  resultJson = join(runDir, "result.json");
3926
- ({ name, slug, agentPane, promptFile } = startHeadlessSession(profile, command, cwd, args, resultJson, outputLog, label, getTmuxProfileConfig().profile, initialPrompt, structuredLaunch, sessionClass));
4211
+ if (sessionHost === "native") {
4212
+ ({ name, slug, promptFile } = startNativeHeadlessSession(profile, command, cwd, args, resultJson, outputLog, label, initialPrompt, structuredLaunch, sessionClass));
4213
+ }
4214
+ else {
4215
+ ({ name, slug, agentPane, promptFile } = startHeadlessSession(profile, command, cwd, args, resultJson, outputLog, label, getTmuxProfileConfig().profile, initialPrompt, structuredLaunch, sessionClass));
4216
+ }
4217
+ }
4218
+ else if (sessionHost === "native") {
4219
+ ({ name, slug } = startNativeSession(profile, command, cwd, args, label, {
4220
+ ...(opts.resume !== undefined ? { resumeId: opts.resume } : {}),
4221
+ ...(initialPrompt !== undefined
4222
+ ? { terminateOnAgentExit: true }
4223
+ : {}),
4224
+ ...(structuredLaunch ? { refuseExisting: true } : {}),
4225
+ sessionClass,
4226
+ }));
3927
4227
  }
3928
4228
  else {
3929
4229
  ({ name, slug, agentPane } = startLocalSession(profile, command, cwd, args, label, getTmuxProfileConfig().profile, {
@@ -3939,15 +4239,37 @@ export async function main(argv) {
3939
4239
  sessionClass,
3940
4240
  }));
3941
4241
  }
3942
- if (structuredLaunch && !agentPane) {
4242
+ if (structuredLaunch && sessionHost !== "native" && !agentPane) {
3943
4243
  cleanupHeadlessPromptFile(promptFile);
4244
+ // OWN-CLEANUP, deliberately NOT resolver-derived (F1 sorting):
4245
+ // `name` is the session THIS command just created above, on the
4246
+ // host it chose. Re-resolving could return missing/unknown and
4247
+ // refuse — leaking the partial session it exists to remove.
3944
4248
  killLocalSession(name);
3945
4249
  process.stderr.write(`[h2a] could not capture the exact agent pane for ${slug}; the partial session was stopped\n`);
3946
4250
  process.exitCode = 1;
3947
4251
  return;
3948
4252
  }
3949
4253
  let h2aSidecarStarted = false;
3950
- if (h2aSidecar) {
4254
+ if (h2aSidecar && sessionHost === "native") {
4255
+ h2aSidecarStarted = startNativeH2aSidecar(name, cwd, h2a.command, {
4256
+ ...(structuredLaunch ? { verified: true } : {}),
4257
+ });
4258
+ if (!h2aSidecarStarted && structuredLaunch) {
4259
+ cleanupHeadlessPromptFile(promptFile);
4260
+ // OWN-CLEANUP, deliberately NOT resolver-derived (F1 sorting):
4261
+ // this kills the session THIS command just created on the host
4262
+ // it chose; the resolver could refuse and leak the partial.
4263
+ killNativeSessionTree(name);
4264
+ process.stderr.write(`[h2a] required h2a sidecar failed for ${slug}; the partial session was stopped\n`);
4265
+ process.exitCode = 1;
4266
+ return;
4267
+ }
4268
+ if (h2aSidecarStarted) {
4269
+ process.stderr.write(`[h2a] h2a sidecar session started for ${slug} (${h2a.command})\n`);
4270
+ }
4271
+ }
4272
+ else if (h2aSidecar) {
3951
4273
  if (structuredLaunch) {
3952
4274
  try {
3953
4275
  h2aSidecarStarted = Boolean(await startH2aWindowVerified(name, cwd, h2a.command, agentPane));
@@ -3962,6 +4284,9 @@ export async function main(argv) {
3962
4284
  }
3963
4285
  if (!h2aSidecarStarted && structuredLaunch) {
3964
4286
  cleanupHeadlessPromptFile(promptFile);
4287
+ // OWN-CLEANUP, deliberately NOT resolver-derived (F1 sorting):
4288
+ // this kills the session THIS command just created on the host
4289
+ // it chose; the resolver could refuse and leak the partial.
3965
4290
  killLocalSession(name);
3966
4291
  process.stderr.write(`[h2a] required h2a sidecar failed for ${slug}; the partial session was stopped\n`);
3967
4292
  process.exitCode = 1;
@@ -3978,15 +4303,17 @@ export async function main(argv) {
3978
4303
  // OBSERVED — brief seen in a real composer, then real work — because
3979
4304
  // reporting "started" over a lost brief is what left a lane inert
3980
4305
  // for 33 minutes on 2026-07-28.
3981
- promptDelivery = deliverInitialPrompt(agentPane, initialPrompt, {
3982
- capturePane: capturePaneVisible,
3983
- clearComposer: clearPaneComposer,
3984
- pasteBlock: pasteLiteralBlock,
3985
- submit: submitPane,
3986
- cpuMs: paneTreeCpuMs,
3987
- sleep: sleepSync,
3988
- now: () => Date.now(),
3989
- });
4306
+ promptDelivery = sessionHost === "native"
4307
+ ? deliverInitialPrompt(name, initialPrompt, nativePromptDeliveryDeps(sleepSync))
4308
+ : deliverInitialPrompt(agentPane, initialPrompt, {
4309
+ capturePane: capturePaneVisible,
4310
+ clearComposer: clearPaneComposer,
4311
+ pasteBlock: pasteLiteralBlock,
4312
+ submit: submitPane,
4313
+ cpuMs: paneTreeCpuMs,
4314
+ sleep: sleepSync,
4315
+ now: () => Date.now(),
4316
+ });
3990
4317
  if (promptDelivery.state !== "working") {
3991
4318
  cleanupHeadlessPromptFile(promptFile);
3992
4319
  // Only claim the session is gone when the kill actually reported
@@ -3994,7 +4321,12 @@ export async function main(argv) {
3994
4321
  // same wider-than-its-evidence claim this change exists to remove,
3995
4322
  // and a surviving partial session is precisely the idle lane the
3996
4323
  // operator was just told could not happen.
3997
- const stopped = killLocalSession(name);
4324
+ // OWN-CLEANUP, deliberately NOT resolver-derived (F1 sorting):
4325
+ // `name`/`sessionHost` are the session and host THIS command
4326
+ // just created; re-resolving could refuse and leak the partial.
4327
+ const stopped = sessionHost === "native"
4328
+ ? killNativeSessionTree(name)
4329
+ : killLocalSession(name);
3998
4330
  const detail = promptDelivery.state === "submitted-idle"
3999
4331
  ? `the brief was submitted but the agent never started working ` +
4000
4332
  `(${Math.round(promptDelivery.cpuDeltaMs)}ms of CPU): it is not "started"`
@@ -4015,13 +4347,25 @@ export async function main(argv) {
4015
4347
  return;
4016
4348
  }
4017
4349
  }
4018
- const pid = agentPane
4019
- ? localSessionPanePid(agentPane)
4020
- : undefined;
4021
- const workerPid = agentPane !== undefined ? paneWorkerPid(agentPane) : undefined;
4350
+ const pid = sessionHost === "native"
4351
+ ? nativeSessionPid(name)
4352
+ : agentPane
4353
+ ? localSessionPanePid(agentPane)
4354
+ : undefined;
4355
+ const workerPid = sessionHost === "native"
4356
+ ? nativeWorkerPid(name)
4357
+ : agentPane !== undefined
4358
+ ? paneWorkerPid(agentPane)
4359
+ : undefined;
4022
4360
  if (structuredLaunch && pid === undefined) {
4023
4361
  cleanupHeadlessPromptFile(promptFile);
4024
- killLocalSession(name);
4362
+ // OWN-CLEANUP, deliberately NOT resolver-derived (F1 sorting):
4363
+ // `name`/`sessionHost` are the session and host THIS command
4364
+ // just created; re-resolving could refuse and leak the partial.
4365
+ if (sessionHost === "native")
4366
+ killNativeSessionTree(name);
4367
+ else
4368
+ killLocalSession(name);
4025
4369
  process.stderr.write(`[h2a] could not verify the agent pane pid for ${slug}; the partial session was stopped\n`);
4026
4370
  process.exitCode = 1;
4027
4371
  return;
@@ -4033,6 +4377,8 @@ export async function main(argv) {
4033
4377
  profile,
4034
4378
  slug,
4035
4379
  tmuxSession: name,
4380
+ // The ACTUAL host that just started the terminal — never a default.
4381
+ hostKind: sessionHost === "native" ? "local-native" : "local-tmux",
4036
4382
  ...(pid !== undefined ? { pid } : {}),
4037
4383
  ...(workerPid !== undefined ? { workerPid } : {}),
4038
4384
  cwd,
@@ -4068,6 +4414,7 @@ export async function main(argv) {
4068
4414
  session: {
4069
4415
  id: only.slug,
4070
4416
  tmuxSession: only.name,
4417
+ host: sessionHost === "native" ? "native" : "tmux",
4071
4418
  profile,
4072
4419
  workspace: cwd,
4073
4420
  mode: opts.headless ? "headless" : "interactive",
@@ -4115,7 +4462,10 @@ export async function main(argv) {
4115
4462
  process.stderr.write(`[h2a] attach with: h2a attach ${only.slug}\n`);
4116
4463
  return;
4117
4464
  }
4118
- process.exitCode = attachLocalSession(only.name);
4465
+ process.exitCode =
4466
+ sessionHost === "native"
4467
+ ? attachNativeSession(only.name)
4468
+ : attachLocalSession(only.name);
4119
4469
  return;
4120
4470
  });
4121
4471
  // ---------------------------------------------------------------------------
@@ -4125,6 +4475,7 @@ export async function main(argv) {
4125
4475
  .command("delegate <type> <task>")
4126
4476
  .description("Delegate a task to a LIVE agent (claude/codex/agy) in a detached tmux session, primed with <task> (passed as a single argv — never shell-concatenated) and with the h2a side-window so the parent/master dialogue works. Returns a job id. Supervise with `h2a jobs`; drain the queue with `h2a jobs conduct`. `--remote` runs the job CONCURRENTLY in a SCW Pod on the shared RWX volume (subPath per job). Beyond the concurrency cap (default 16) the job is QUEUED (pending) and started by the conductor as slots free.")
4127
4477
  .option("--remote [url]", "run the job in a SCW Pod (concurrent; isolated by a per-job workspace subPath on the shared RWX volume) instead of a local tmux session; optional control-plane URL (default: the configured remote)")
4478
+ .option("--tmux", "host the local job in tmux instead of the native PTY host (native is the default)")
4128
4479
  .option("--cwd <path>", "run the agent in this directory as-is (default: a dedicated git worktree under .h2a/jobs/<id>/wt when cwd is a repo, else cwd). Local only.")
4129
4480
  .option("--name <label>", "job id / tmux slug (letters/digits/_/-; default: <type>-<random>)")
4130
4481
  .option("--headless", "run-once-exit batch mode: claude -p / codex exec, stdout→output.log, write result.json, then END the session (no live agent; agy has no headless mode)")
@@ -4142,6 +4493,12 @@ export async function main(argv) {
4142
4493
  process.exitCode = 1;
4143
4494
  return;
4144
4495
  }
4496
+ // Which local host runs this job's terminal (native default, --tmux
4497
+ // per job, H2A_SESSION_HOST=tmux fleet-wide). Recorded on the job
4498
+ // entry so the conductor honors it at (re)launch time.
4499
+ const delegateLocalHostKind = resolveSessionHostKind(opts) === "native"
4500
+ ? "local-native"
4501
+ : "local-tmux";
4145
4502
  const jobType = type;
4146
4503
  try {
4147
4504
  assertDelegateEffort(jobType, opts.effort);
@@ -4220,7 +4577,7 @@ export async function main(argv) {
4220
4577
  enroll({
4221
4578
  id: jobId,
4222
4579
  tool: jobType,
4223
- kind: isRemote ? "remote" : "local-tmux",
4580
+ kind: isRemote ? "remote" : delegateLocalHostKind,
4224
4581
  cwd: isRemote ? `job-${jobId}` : process.cwd(),
4225
4582
  source: isRemote ? "remote" : "run",
4226
4583
  sessionClass: "background",
@@ -4257,7 +4614,7 @@ export async function main(argv) {
4257
4614
  const claimInput = {
4258
4615
  id: jobId,
4259
4616
  tool: jobType,
4260
- kind: (isRemote ? "remote" : "local-tmux"),
4617
+ kind: (isRemote ? "remote" : delegateLocalHostKind),
4261
4618
  cwd: isRemote ? `job-${jobId}` : process.cwd(),
4262
4619
  source: (isRemote ? "remote" : "run"),
4263
4620
  sessionClass: "background",
@@ -4306,7 +4663,7 @@ export async function main(argv) {
4306
4663
  {
4307
4664
  id: jobId,
4308
4665
  tool: jobType,
4309
- kind: isRemote ? "remote" : "local-tmux",
4666
+ kind: isRemote ? "remote" : delegateLocalHostKind,
4310
4667
  cwd: process.cwd(),
4311
4668
  enrolledAt: new Date().toISOString(),
4312
4669
  lastSeenAt: new Date().toISOString(),
@@ -4399,7 +4756,13 @@ export async function main(argv) {
4399
4756
  // → the envelope is rejected (fail closed). Used to drop forged cross-job
4400
4757
  // envelopes from the shared RWX inbox before they reach the decision logic.
4401
4758
  const expectedInstanceOf = (jobId, type) => {
4402
- const job = loadRegistry().find((e) => e.id === jobId && e.role === "job");
4759
+ const read = loadRegistry();
4760
+ // Unknown registry read → undefined → the envelope is REJECTED (the
4761
+ // same fail-closed arm as an unknown job): an unreadable registry never
4762
+ // authenticates anything.
4763
+ const job = read.state === "ok"
4764
+ ? read.entries.find((e) => e.id === jobId && e.role === "job")
4765
+ : undefined;
4403
4766
  if (!job)
4404
4767
  return undefined;
4405
4768
  if (type === "decision.reply")
@@ -5223,10 +5586,11 @@ export async function main(argv) {
5223
5586
  // Enroll + launch via the SAME path delegate uses (task rides argv, never a
5224
5587
  // shell string). Mark processed + record the cooldown timestamp regardless of
5225
5588
  // the spawn result (a failed spawn shouldn't tight-loop a watch).
5589
+ const conductorHostKind = resolveSessionHostKind({}) === "native" ? "local-native" : "local-tmux";
5226
5590
  const launchEntry = {
5227
5591
  id: slug,
5228
5592
  tool: host,
5229
- kind: "local-tmux",
5593
+ kind: conductorHostKind,
5230
5594
  cwd: process.cwd(),
5231
5595
  enrolledAt: new Date().toISOString(),
5232
5596
  lastSeenAt: new Date().toISOString(),
@@ -5243,7 +5607,7 @@ export async function main(argv) {
5243
5607
  enroll({
5244
5608
  id: slug,
5245
5609
  tool: host,
5246
- kind: "local-tmux",
5610
+ kind: conductorHostKind,
5247
5611
  cwd: process.cwd(),
5248
5612
  source: "run",
5249
5613
  sessionClass: "background",
@@ -5285,7 +5649,16 @@ export async function main(argv) {
5285
5649
  .option("--confirm", "actually launch (default: dry-run — decide and print what WOULD launch, launch nothing)")
5286
5650
  .option("--watch <minutes>", "loop in the foreground every <minutes> (whole number >= 1); without it, runs a single pass and exits")
5287
5651
  .option("--cooldown <min>", "minimum minutes between launches for the SAME workspace (default 30)")
5652
+ .option("--tmux", "host launched conductors in tmux instead of the native PTY host for this pass")
5288
5653
  .action(async (opts) => {
5654
+ // Per-pass host opt-out rides the fleet valve: every kind decision in
5655
+ // this pass (launch entries, startJob) reads H2A_SESSION_HOST. This
5656
+ // is CREATION intent only — it selects the host of conductors this
5657
+ // pass LAUNCHES; a job whose registry row already records a local
5658
+ // kind keeps that recorded host (startJob honors job.kind first), so
5659
+ // the valve can never re-route an existing session.
5660
+ if (opts.tmux === true)
5661
+ process.env["H2A_SESSION_HOST"] = "tmux";
5289
5662
  const confirm = opts.confirm === true;
5290
5663
  const cooldownMin = opts.cooldown !== undefined ? Number(opts.cooldown) : 30;
5291
5664
  if (!Number.isFinite(cooldownMin) || cooldownMin < 0) {
@@ -5534,13 +5907,110 @@ export async function main(argv) {
5534
5907
  .option("--yes", "confirm --all non-interactively (requires --apply)")
5535
5908
  .option("--include-agents", "with --all, include claude/codex/agy sessions that are excluded by default")
5536
5909
  .action(async (filter, opts) => {
5537
- if (!tmuxAvailable()) {
5538
- process.stderr.write("[h2a] tmux is not installed locally\n");
5910
+ if (filter && opts.all) {
5911
+ process.stderr.write("[h2a] relaunch accepts either one exact session name or --all, not both.\n");
5539
5912
  process.exitCode = 1;
5540
5913
  return;
5541
5914
  }
5542
- if (filter && opts.all) {
5543
- process.stderr.write("[h2a] relaunch accepts either one exact session name or --all, not both.\n");
5915
+ // Native-PTY sessions: an exact filter whose registry record says
5916
+ // kind:"local-native" is relaunched through the native host (the tmux
5917
+ // inventory below cannot see it). --all sweeps stay tmux-only for now.
5918
+ if (filter) {
5919
+ const relaunchRead = loadRegistry();
5920
+ if (relaunchRead.state === "unknown") {
5921
+ // Relaunch KILLS before it recreates: an unreadable registry
5922
+ // cannot prove which host (if any) owns this exact filter —
5923
+ // refuse instead of falling through to the tmux sweep (F2).
5924
+ process.stderr.write(`[h2a] cannot relaunch ${filter}: the local registry is unreadable (${relaunchRead.reason}); refusing a destructive act on an unproven host state.\n`);
5925
+ process.exitCode = 1;
5926
+ return;
5927
+ }
5928
+ const nativeEntry = relaunchRead.entries.find((e) => e.kind === "local-native" &&
5929
+ !e.endedAt &&
5930
+ (e.id === filter || e.label === filter || e.tmuxSession === filter));
5931
+ const nativeName = nativeEntry?.tmuxSession ??
5932
+ (nativeEntry ? localSessionName(nativeEntry.id) : undefined);
5933
+ const nativeLiveness = nativeEntry && nativeName ? nativeSessionLiveness(nativeName) : false;
5934
+ if (nativeLiveness === "unknown") {
5935
+ // A probe failure is not death: neither the native relaunch (kill)
5936
+ // nor the tmux sweep (which would treat this identity as absent)
5937
+ // may proceed on an unprovable host state.
5938
+ process.stderr.write(`[h2a] cannot relaunch ${filter}: native session ${nativeName} has an unknown host state (probe failed); refusing a destructive act on an unproven host.\n`);
5939
+ process.exitCode = 1;
5940
+ return;
5941
+ }
5942
+ if (nativeEntry && nativeName && nativeLiveness === true) {
5943
+ // F1: the kill target derives from the ONE symmetric resolver over
5944
+ // the same read — never directly from the row lookup above. A
5945
+ // dual-host identity (ambiguous) or an unprovable one refuses here
5946
+ // instead of letting the native leg absorb a contested name. The
5947
+ // WHOLE read (not just `.entries`, PART A/A2): an unreadable twin
5948
+ // of this identity must poison the resolution here too.
5949
+ const hostResolution = resolveManagedHost(nativeEntry.tmuxSession ?? nativeEntry.id, relaunchRead);
5950
+ if ((hostResolution.state !== "recorded" &&
5951
+ hostResolution.state !== "discovered") ||
5952
+ hostResolution.kind !== "local-native") {
5953
+ process.stderr.write(`[h2a] cannot relaunch ${filter}: this managed identity does not resolve to a single native host (resolver: ${hostResolution.state}); refusing a destructive act.\n`);
5954
+ process.exitCode = 1;
5955
+ return;
5956
+ }
5957
+ const actName = hostResolution.name;
5958
+ const slug = nativeEntry.label ?? nativeEntry.id;
5959
+ if (!opts.apply) {
5960
+ process.stderr.write(`[h2a] DRY-RUN — would relaunch NATIVE session ${slug} ` +
5961
+ `(${nativeEntry.tool}${nativeEntry.convId ? ` --resume ${nativeEntry.convId}` : ""} in ${nativeEntry.cwd}). ` +
5962
+ "Re-run with --apply.\n");
5963
+ return;
5964
+ }
5965
+ // Safety twin of the pane-CPU probe: a session burning CPU is
5966
+ // WORKING; refuse to kill it without --yes.
5967
+ const cpuBefore = nativeTreeCpuMs(actName);
5968
+ sleepSync(1_500);
5969
+ const cpuAfter = nativeTreeCpuMs(actName);
5970
+ const busy = cpuBefore !== undefined &&
5971
+ cpuAfter !== undefined &&
5972
+ cpuAfter - cpuBefore > 300;
5973
+ if (busy && opts.yes !== true) {
5974
+ process.stderr.write(`[h2a] native session ${slug} is actively working ` +
5975
+ `(+${Math.round((cpuAfter ?? 0) - (cpuBefore ?? 0))}ms CPU in 1.5s); ` +
5976
+ "pass --yes to relaunch it anyway.\n");
5977
+ process.exitCode = 1;
5978
+ return;
5979
+ }
5980
+ if (!killNativeSessionTree(actName)) {
5981
+ process.stderr.write(`[h2a] native session ${slug} could not be killed; nothing was relaunched.\n`);
5982
+ process.exitCode = 1;
5983
+ return;
5984
+ }
5985
+ const profile = nativeEntry.tool;
5986
+ const command = localCliCommand(profile);
5987
+ const useBare = shouldUseClaudeBare(profile);
5988
+ const args = nativeEntry.convId
5989
+ ? localResumeArgs(profile, nativeEntry.convId, { bare: useBare })
5990
+ : localStartArgs(profile, { bare: useBare });
5991
+ const relaunched = startNativeSession(profile, command, nativeEntry.cwd, args, slug, {
5992
+ ...(nativeEntry.convId ? { resumeId: nativeEntry.convId } : {}),
5993
+ ...(nativeEntry.sessionClass !== undefined
5994
+ ? { sessionClass: nativeEntry.sessionClass }
5995
+ : {}),
5996
+ });
5997
+ enrollFromRun({
5998
+ profile,
5999
+ slug,
6000
+ tmuxSession: relaunched.name,
6001
+ hostKind: "local-native",
6002
+ ...(relaunched.pid !== undefined ? { pid: relaunched.pid } : {}),
6003
+ cwd: nativeEntry.cwd,
6004
+ sessionClass: nativeEntry.sessionClass ?? "human",
6005
+ ...(nativeEntry.convId ? { convId: nativeEntry.convId } : {}),
6006
+ });
6007
+ process.stderr.write(`[h2a] native session ${slug} relaunched ` +
6008
+ `(${profile}${nativeEntry.convId ? ` --resume ${nativeEntry.convId}` : ""} in ${nativeEntry.cwd})\n`);
6009
+ return;
6010
+ }
6011
+ }
6012
+ if (!tmuxAvailable()) {
6013
+ process.stderr.write("[h2a] tmux is not installed locally\n");
5544
6014
  process.exitCode = 1;
5545
6015
  return;
5546
6016
  }
@@ -5573,6 +6043,17 @@ export async function main(argv) {
5573
6043
  process.exitCode = 1;
5574
6044
  return;
5575
6045
  }
6046
+ // Non-forced relaunch is non-destructive by design (it only retypes
6047
+ // into idle shells, see below), so an unreadable registry does not
6048
+ // exit 1 here — but `registryEntries` below would be `[]`, and every
6049
+ // session would then read as "no matching registry conversation",
6050
+ // which is a LIE: the truth is the registry couldn't be read at all.
6051
+ // Name the real cause once, up front, and skip the misleading
6052
+ // per-session reasons for this case.
6053
+ if (!forced && !registry.known) {
6054
+ process.stderr.write(`[h2a] registry is unreadable (${registry.reason ?? "no detail"}); cannot determine resumable conversations — relaunch refused for all ${sessions.length} managed session(s)\n`);
6055
+ return;
6056
+ }
5576
6057
  const registryEntries = registry.entries;
5577
6058
  const resolution = forced
5578
6059
  ? reconcileRunConvIds(registryEntries, (cwd, convId) => readConversationCustomTitle(homedir(), cwd, convId))
@@ -5743,7 +6224,18 @@ export async function main(argv) {
5743
6224
  process.stderr.write(`[h2a] skipped ${action.slug}: liveness identity was unreadable immediately before kill.\n`);
5744
6225
  continue;
5745
6226
  }
5746
- if (!killLocalSession(action.name, safety.identity)) {
6227
+ // F1: the kill target must derive from the ONE symmetric resolver —
6228
+ // a same-name identity recorded on the native host (dual) or an
6229
+ // unprovable one must never be absorbed by the tmux sweep.
6230
+ const hostResolution = resolveManagedHost(action.name, registryEntries);
6231
+ if ((hostResolution.state !== "recorded" &&
6232
+ hostResolution.state !== "discovered") ||
6233
+ hostResolution.kind !== "local-tmux" ||
6234
+ hostResolution.name !== action.name) {
6235
+ process.stderr.write(`[h2a] skipped ${action.slug}: ${action.name} does not resolve to a single tmux host (resolver: ${hostResolution.state}); refusing a destructive act.\n`);
6236
+ continue;
6237
+ }
6238
+ if (!killLocalSession(hostResolution.name, safety.identity)) {
5747
6239
  process.stderr.write(`[h2a] skipped ${action.slug}: tmux session ${action.name} changed or was no longer proven dead immediately before kill.\n`);
5748
6240
  continue;
5749
6241
  }
@@ -5759,6 +6251,9 @@ export async function main(argv) {
5759
6251
  profile: action.profile,
5760
6252
  slug: action.slug,
5761
6253
  tmuxSession: name,
6254
+ // startLocalSession above launched a tmux terminal: record the
6255
+ // ACTUAL host explicitly (the write boundary refuses defaults).
6256
+ hostKind: "local-tmux",
5762
6257
  cwd: entry.cwd,
5763
6258
  sessionClass,
5764
6259
  convId: action.convId,
@@ -5820,7 +6315,11 @@ export async function main(argv) {
5820
6315
  /** Interactive (claude/codex/agy) local-tmux sessions, optional slug filter. */
5821
6316
  const listInteractiveThrottleSessions = (filter) => {
5822
6317
  const enrolledAtBySlug = new Map();
5823
- for (const e of loadRegistry()) {
6318
+ // Unknown registry read → empty map (degrade): enrolledAt only refines
6319
+ // the throttle bookkeeping's startedAt; sessions still come from the
6320
+ // live tmux inventory, so nothing is asserted absent.
6321
+ const throttleRead = loadRegistry();
6322
+ for (const e of throttleRead.state === "ok" ? throttleRead.entries : []) {
5824
6323
  if (e.kind === "local-tmux")
5825
6324
  enrolledAtBySlug.set(e.id, e.enrolledAt);
5826
6325
  }
@@ -5894,7 +6393,11 @@ export async function main(argv) {
5894
6393
  });
5895
6394
  let nudged = 0;
5896
6395
  if (apply) {
5897
- const leaseIdByTmuxSession = new Map(loadRegistry()
6396
+ // Unknown registry read → empty map (degrade): the nudge then goes
6397
+ // through relaunchInSession instead of the lease-scoped variant; no
6398
+ // session is asserted absent and nothing is killed either way.
6399
+ const leaseRead = loadRegistry();
6400
+ const leaseIdByTmuxSession = new Map((leaseRead.state === "ok" ? leaseRead.entries : [])
5898
6401
  .filter((entry) => entry.tmuxSession !== undefined)
5899
6402
  .map((entry) => [entry.tmuxSession, entry.id]));
5900
6403
  for (const r of plan.toResume) {
@@ -6283,6 +6786,13 @@ export async function main(argv) {
6283
6786
  process.exitCode = 1;
6284
6787
  return;
6285
6788
  }
6789
+ if (localTarget.kind === "unknown") {
6790
+ // Unknown ≠ missing: falling through to the remote lookup would
6791
+ // attach a REMOTE homonym over an unprovable local session (F2).
6792
+ process.stderr.write(`[h2a] cannot attach ${first}: local session state is unknown (${localTarget.reason}); attach refused (fail closed).\n`);
6793
+ process.exitCode = 1;
6794
+ return;
6795
+ }
6286
6796
  // A session started by `h2a run` is enrolled in the registry as
6287
6797
  // kind:"local-tmux". Trust that durable record even when a transient
6288
6798
  // `tmux list-sessions` miss hides the live session — otherwise a purely
@@ -6298,8 +6808,37 @@ export async function main(argv) {
6298
6808
  process.exitCode = 1;
6299
6809
  return;
6300
6810
  }
6811
+ // WHICH host: the symmetric resolver over BOTH persisted kinds —
6812
+ // a recorded host wins REGARDLESS of the other host's liveness,
6813
+ // probes only DISCOVER a session with no registry row, dual rows
6814
+ // and probe failures fail closed. Liveness then gates the ACT
6815
+ // below; it never re-routes a recorded session.
6816
+ const resolution = resolveManagedHost(localName, loadRegistry());
6817
+ if (resolution.state === "ambiguous") {
6818
+ process.stderr.write(`[h2a] cannot attach ${first}: this managed identity is recorded/live on BOTH local hosts (${resolution.candidates.join(", ")}); refusing to pick one. Clean up the conflicting registry rows first.\n`);
6819
+ process.exitCode = 1;
6820
+ return;
6821
+ }
6822
+ if (resolution.state === "unknown") {
6823
+ process.stderr.write(`[h2a] cannot attach ${first}: local host state is unknown (${resolution.reason}); attach refused (fail closed).\n`);
6824
+ process.exitCode = 1;
6825
+ return;
6826
+ }
6827
+ if (resolution.state === "missing") {
6828
+ process.stderr.write(`[h2a] no local session "${first}" on either local host (see: h2a ls)\n`);
6829
+ process.exitCode = 1;
6830
+ return;
6831
+ }
6832
+ // Every act below targets the RESOLVED exact name (F1) — never
6833
+ // the pre-resolution lookup's name.
6834
+ const nativeTarget = resolution.kind === "local-native";
6301
6835
  if (opts.headlessTerminal) {
6302
- const terminal = ensureHeadlessTerminal(localName);
6836
+ if (nativeTarget) {
6837
+ process.stderr.write(`[h2a] ${first} is hosted by the native PTY host, which owns a persistent PTY already; --headless-terminal is tmux-only.\n`);
6838
+ process.exitCode = 1;
6839
+ return;
6840
+ }
6841
+ const terminal = ensureHeadlessTerminal(resolution.name);
6303
6842
  if (terminal.state === "unavailable") {
6304
6843
  process.stderr.write(`[h2a] could not attach a persistent terminal to ${first}: ${terminal.reason}\n`);
6305
6844
  process.exitCode = 1;
@@ -6308,7 +6847,27 @@ export async function main(argv) {
6308
6847
  process.stderr.write(`[h2a] persistent terminal ${terminal.state} for ${first} (${terminal.attachedClients} attached client(s))\n`);
6309
6848
  return;
6310
6849
  }
6311
- process.exitCode = attachLocalSession(localName);
6850
+ if (nativeTarget) {
6851
+ // Liveness gates the ACT, not the host choice: attaching to a
6852
+ // session that is not running is refused on its own host — it
6853
+ // must never degrade into a tmux attach of the same name. A
6854
+ // probe FAILURE is not death: it refuses with its own reason.
6855
+ const nativeState = probeNativeSession(resolution.name);
6856
+ if (nativeState === "dead") {
6857
+ process.stderr.write(`[h2a] native session ${first} is not running; attach refused ` +
6858
+ `(the session stays native — resume it with h2a resume ${first}).\n`);
6859
+ process.exitCode = 1;
6860
+ return;
6861
+ }
6862
+ if (nativeState === "unknown") {
6863
+ process.stderr.write(`[h2a] native session ${first}: host state is unknown (probe failed); attach refused (fail closed).\n`);
6864
+ process.exitCode = 1;
6865
+ return;
6866
+ }
6867
+ }
6868
+ process.exitCode = nativeTarget
6869
+ ? attachNativeSession(resolution.name)
6870
+ : attachLocalSession(resolution.name);
6312
6871
  return;
6313
6872
  }
6314
6873
  }
@@ -6490,13 +7049,93 @@ export async function main(argv) {
6490
7049
  process.exitCode = 1;
6491
7050
  return;
6492
7051
  }
7052
+ if (localTarget.kind === "unknown") {
7053
+ // Unknown ≠ missing: stop is DESTRUCTIVE — falling through here
7054
+ // would aim the kill at a REMOTE homonym of an unprovable local
7055
+ // session (F2, fail closed).
7056
+ process.stderr.write(`[h2a] cannot stop ${first}: local session state is unknown (${localTarget.reason}); refusing a destructive act on an unproven host state.\n`);
7057
+ process.exitCode = 1;
7058
+ return;
7059
+ }
7060
+ // A3 (PART A guard-before-act, sol-2): the per-identity
7061
+ // unreadable-row guard is evaluated HERE — before EITHER the
7062
+ // found/kill branch below OR the remote-fallthrough — never after
7063
+ // an act. It used to sit ~30 lines below, reachable only past the
7064
+ // found branch's `return`: for a valid row + an unreadable TWIN of
7065
+ // the SAME name, `localTarget.kind` is "found" and the kill ran
7066
+ // before this check was ever consulted (dead code in that mixed
7067
+ // case). resolveManagedHost's own per-identity check (A2, below)
7068
+ // ALSO catches this same identity independently — this is
7069
+ // deliberate defense-in-depth at the one place every branch shares,
7070
+ // not the only place it is caught.
7071
+ // Defense-in-depth ABOVE A2 (the resolver-level per-identity
7072
+ // `unreadable` check in resolveManagedHost). A2's position is
7073
+ // proven by the RESUME/RESTORE wiring tests; STOP additionally
7074
+ // carries THIS guard. Do NOT remove this guard as "redundant"
7075
+ // without first adding a stop test that reddens on A2's position —
7076
+ // otherwise stop's dependency on A2 would become silent.
7077
+ const unreadable = unreadableRegistryRowsForTarget(first);
7078
+ // B3 — this is itself a RE-READ (a TOCTOU window against the first
7079
+ // read inside resolveManagedLocalTarget above): "unknown" must
7080
+ // refuse exactly like the first read did, never re-flatten into
7081
+ // "no unreadable row" and fall through past this guard.
7082
+ if (unreadable === "unknown") {
7083
+ process.stderr.write(`[h2a] cannot stop ${first}: local host state became unknown while resolving this stop — the registry could not be re-read for this identity (fail closed); stop refuses to fall through to a remote session of the same name. Retry once the registry is readable.\n`);
7084
+ process.exitCode = 1;
7085
+ return;
7086
+ }
7087
+ if (unreadable.length > 0) {
7088
+ const unreadableIds = unreadable
7089
+ .map((row) => (typeof row.id === "string" ? row.id : "<no id>"))
7090
+ .join(", ");
7091
+ process.stderr.write(`[h2a] cannot stop ${first}: local host state is unknown — an unreadable registry row exists for this identity (row id: ${unreadableIds}) in ${resolveRegistryPath()} (kind absent/unreadable); the row is preserved but cannot be acted on, and stop refuses to fall through to a remote session of the same name (fail closed). Inspect the row before retrying.\n`);
7092
+ process.exitCode = 1;
7093
+ return;
7094
+ }
6493
7095
  if (localTarget.kind === "found") {
6494
- const ok = killLocalSession(localTarget.name);
6495
- process.stderr.write(`[h2a] local session ${localTarget.local?.slug ?? first} ${ok ? "killed" : "could not be killed"}\n`);
7096
+ // WHICH host: the symmetric resolver over BOTH persisted kinds —
7097
+ // a recorded host wins REGARDLESS of the other host's liveness
7098
+ // (a persisted tmux row must never let a homonymous live native
7099
+ // process swallow the kill: F2), probes only DISCOVER a session
7100
+ // with no registry row, and dual rows / probe failures FAIL
7101
+ // CLOSED — stop is destructive, so it never guesses a host.
7102
+ const resolution = resolveManagedHost(localTarget.name, loadRegistry());
7103
+ if (resolution.state === "ambiguous") {
7104
+ process.stderr.write(`[h2a] cannot stop ${first}: this managed identity is recorded/live on BOTH local hosts (${resolution.candidates.join(", ")}); refusing to kill either. Clean up the conflicting registry rows first.\n`);
7105
+ process.exitCode = 1;
7106
+ return;
7107
+ }
7108
+ if (resolution.state === "unknown") {
7109
+ process.stderr.write(`[h2a] cannot stop ${first}: local host state is unknown (${resolution.reason}); refusing a destructive act on an unproven host.\n`);
7110
+ process.exitCode = 1;
7111
+ return;
7112
+ }
7113
+ if (resolution.state === "missing") {
7114
+ process.stderr.write(`[h2a] no local session "${first}" on either local host (see: h2a ls)\n`);
7115
+ process.exitCode = 1;
7116
+ return;
7117
+ }
7118
+ // The kill derives from the RESOLVED kind + name only (F1) —
7119
+ // never the pre-resolution lookup's name.
7120
+ const nativeTarget = resolution.kind === "local-native";
7121
+ const ok = nativeTarget
7122
+ ? killNativeSessionTree(resolution.name)
7123
+ : killLocalSession(resolution.name);
7124
+ process.stderr.write(`[h2a] ${nativeTarget ? "native" : "local"} session ${localTarget.local?.slug ?? first} ${ok ? "killed" : "could not be killed"}\n`);
6496
7125
  if (!ok)
6497
7126
  process.exitCode = 1;
6498
7127
  return;
6499
7128
  }
7129
+ // CONJUNCTION RULE (F2): reaching stopRemoteSession below requires
7130
+ // local absence to be POSITIVELY known EVERYWHERE — the tmux
7131
+ // inventory read succeeded with no session AND the registry read
7132
+ // succeeded with no row AND the native probe answered dead (all
7133
+ // enforced by resolveManagedLocalTarget returning "missing", since
7134
+ // any unknown there returns "unknown" and refuses above) AND, last,
7135
+ // no UNREADABLE row exists for this identity (kind absent/invalid,
7136
+ // sol-2, checked above BEFORE this branch is even reached). One
7137
+ // unknown anywhere poisons the conjunction; falling through would
7138
+ // aim a DESTRUCTIVE stop at a REMOTE homonym of the same id.
6500
7139
  }
6501
7140
  const { url, sessionId } = resolveUrlAndSessionId(first, second);
6502
7141
  const result = await stopRemoteSession(url, sessionId, opts.reason);
@@ -6928,26 +7567,68 @@ export async function main(argv) {
6928
7567
  const account = await enrollViaFacade(provider, {
6929
7568
  ...(opts.configRef ? { configRef: opts.configRef } : {}),
6930
7569
  });
6931
- recordFacadeEnrollment(account);
6932
- process.stdout.write(`[h2a] llm-mesh: enrolled ${account.label} ` +
6933
- `(id: ${account.accountId}, provider: ${account.provider})\n`);
7570
+ process.stdout.write(`[h2a] llm-mesh: enrolled ${account.provider}; ` +
7571
+ "account inventory remains in Sentropic\n");
6934
7572
  }
6935
7573
  catch (error) {
6936
7574
  process.stderr.write(`[h2a] llm-mesh: ${error instanceof Error ? error.message : String(error)}\n`);
6937
7575
  process.exitCode = 1;
6938
7576
  }
6939
7577
  });
7578
+ const llmMeshRouteCommand = llmMeshCommand
7579
+ .command("route")
7580
+ .alias("routing")
7581
+ .description("Show or change the public llm-mesh routing policy");
7582
+ llmMeshRouteCommand
7583
+ .command("show")
7584
+ .description("Show the effective route order, fallback and policy controls")
7585
+ .action(() => {
7586
+ const config = readLlmMeshConfig();
7587
+ process.stdout.write(`${JSON.stringify(describeLlmMeshRoutingConfig(config?.routing), null, 2)}\n`);
7588
+ });
7589
+ llmMeshRouteCommand
7590
+ .command("prefer <transports...>")
7591
+ .description("Prefer transports in order (example: codex cloud-code)")
7592
+ .action((transports) => {
7593
+ const config = readLlmMeshConfig();
7594
+ const routing = preferredRoutingConfig(config?.routing, transports);
7595
+ updateLlmMeshRoutingConfig(routing);
7596
+ process.stdout.write(`[h2a] llm-mesh: preferred route order is ${transports.join(" > ")} ` +
7597
+ "for new affinities\n");
7598
+ });
7599
+ llmMeshRouteCommand
7600
+ .command("strategy <name>")
7601
+ .description("Use last-enrolled or round-robin for new affinities")
7602
+ .action((name) => {
7603
+ if (name !== "last-enrolled" && name !== "round-robin") {
7604
+ throw new Error("strategy must be last-enrolled or round-robin");
7605
+ }
7606
+ const config = readLlmMeshConfig();
7607
+ const routing = strategyRoutingConfig(config?.routing, name);
7608
+ updateLlmMeshRoutingConfig(routing);
7609
+ process.stdout.write(`[h2a] llm-mesh: ${name} routing enabled for new affinities\n`);
7610
+ });
7611
+ llmMeshRouteCommand
7612
+ .command("policy <json>")
7613
+ .description("Validate and install a complete serialized mesh policy/profile config")
7614
+ .action((json) => {
7615
+ const routing = parseLlmMeshRoutingConfig(json);
7616
+ updateLlmMeshRoutingConfig(routing);
7617
+ process.stdout.write("[h2a] llm-mesh: routing policy validated and installed\n");
7618
+ });
7619
+ llmMeshRouteCommand
7620
+ .command("reset")
7621
+ .description("Return new affinities to llm-mesh safe defaults")
7622
+ .action(() => {
7623
+ updateLlmMeshRoutingConfig(undefined);
7624
+ process.stdout.write("[h2a] llm-mesh: routing policy reset to mesh defaults\n");
7625
+ });
6940
7626
  llmMeshCommand
6941
7627
  .command("start")
6942
7628
  .description("Start the local LLM gateway")
6943
7629
  .option("-v, --verbose", "verbose output")
6944
7630
  .action(async (opts) => {
6945
- const config = readLlmMeshConfig();
6946
- if (!config || !config.meshAccounts?.length) {
6947
- process.stderr.write(`[h2a] llm-mesh: no Cloud Code account configured. Run \`h2a llm-mesh enroll cloud-code\` first.\n`);
6948
- process.exitCode = 1;
6949
- return;
6950
- }
7631
+ const config = readLlmMeshConfig() ?? {};
6951
7632
  const existing = readGatewayPid();
6952
7633
  if (existing) {
6953
7634
  setLlmMeshRuntimeConfig({ enabled: true });
@@ -6961,9 +7642,7 @@ export async function main(argv) {
6961
7642
  const port = result.port;
6962
7643
  process.stdout.write(`[h2a] llm-mesh: gateway started (pid ${result.pid}, port ${port})\n`);
6963
7644
  process.stdout.write(`[h2a] llm-mesh: restore config enabled\n`);
6964
- process.stdout.write(`\nTo use with Claude Code:\n`);
6965
- process.stdout.write(` export ANTHROPIC_BASE_URL=http://localhost:${port}\n`);
6966
- process.stdout.write(` export ANTHROPIC_AUTH_TOKEN=${result.gatewayToken}\n`);
7645
+ process.stdout.write("[h2a] llm-mesh: use `h2a run claude --gw`; the opaque bearer is not printed\n");
6967
7646
  });
6968
7647
  llmMeshCommand
6969
7648
  .command("enable")
@@ -6996,12 +7675,7 @@ export async function main(argv) {
6996
7675
  .description("Restart only the local LLM gateway; never restarts tmux/Claude sessions")
6997
7676
  .option("-v, --verbose", "verbose output")
6998
7677
  .action(async (opts) => {
6999
- const config = readLlmMeshConfig();
7000
- if (!config || !config.meshAccounts?.length) {
7001
- process.stderr.write(`[h2a] llm-mesh: no Cloud Code account configured. Run \`h2a llm-mesh enroll cloud-code\` first.\n`);
7002
- process.exitCode = 1;
7003
- return;
7004
- }
7678
+ const config = readLlmMeshConfig() ?? {};
7005
7679
  const stopped = stopGateway();
7006
7680
  if (stopped.stopped) {
7007
7681
  process.stdout.write(`[h2a] llm-mesh: stopped gateway (pid ${stopped.pid})\n`);
@@ -7015,10 +7689,7 @@ export async function main(argv) {
7015
7689
  process.stdout.write(`[h2a] llm-mesh: gateway restarted (pid ${result.pid}, port ${result.port})\n`);
7016
7690
  process.stdout.write(`[h2a] llm-mesh: restore config enabled\n`);
7017
7691
  process.stdout.write(`[h2a] llm-mesh: no tmux/Claude session was restarted; active sessions may keep their old gateway token until explicitly relaunched.\n`);
7018
- process.stdout.write(`\nTo use with new Claude Code processes:\n`);
7019
- process.stdout.write(` export ANTHROPIC_BASE_URL=http://localhost:${result.port}\n`);
7020
- process.stdout.write(` export ANTHROPIC_AUTH_TOKEN=${result.gatewayToken}\n`);
7021
- process.stdout.write(` export ANTHROPIC_API_KEY=${result.gatewayToken}\n`);
7692
+ process.stdout.write("[h2a] llm-mesh: use `h2a run claude --gw`; the opaque bearer is not printed\n");
7022
7693
  });
7023
7694
  llmMeshCommand
7024
7695
  .command("status")
@@ -7035,14 +7706,7 @@ export async function main(argv) {
7035
7706
  process.stdout.write(`[h2a] llm-mesh: stopped\n`);
7036
7707
  }
7037
7708
  process.stdout.write(` restore/local auto-reactivation: ${runtime.enabled ? "enabled" : "disabled"}\n`);
7038
- if (config) {
7039
- for (const acc of config.meshAccounts ?? []) {
7040
- process.stdout.write(` account: ${acc.label} (${acc.provider})\n`);
7041
- }
7042
- }
7043
- else {
7044
- process.stdout.write(` no config. Run \`h2a llm-mesh enroll cloud-code\` first.\n`);
7045
- }
7709
+ process.stdout.write(" account inventory: managed by Sentropic llm-mesh\n");
7046
7710
  });
7047
7711
  llmMeshCommand
7048
7712
  .command("logs")