@sentropic/h2a-runtime 0.92.0 → 0.93.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/conv-guard.d.ts +6 -1
  2. package/dist/conv-guard.d.ts.map +1 -1
  3. package/dist/conv-guard.js +33 -8
  4. package/dist/conv-guard.js.map +1 -1
  5. package/dist/enroll.d.ts.map +1 -1
  6. package/dist/enroll.js +12 -2
  7. package/dist/enroll.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +743 -97
  11. package/dist/index.js.map +1 -1
  12. package/dist/launch-layout.d.ts +74 -0
  13. package/dist/launch-layout.d.ts.map +1 -0
  14. package/dist/launch-layout.js +324 -0
  15. package/dist/launch-layout.js.map +1 -0
  16. package/dist/llm-mesh.d.ts +13 -7
  17. package/dist/llm-mesh.d.ts.map +1 -1
  18. package/dist/llm-mesh.js +6 -4
  19. package/dist/llm-mesh.js.map +1 -1
  20. package/dist/native-host.d.ts +146 -0
  21. package/dist/native-host.d.ts.map +1 -0
  22. package/dist/native-host.js +456 -0
  23. package/dist/native-host.js.map +1 -0
  24. package/dist/native-terminal/host.d.ts +317 -1
  25. package/dist/native-terminal/host.d.ts.map +1 -1
  26. package/dist/native-terminal/host.js +696 -3
  27. package/dist/native-terminal/host.js.map +1 -1
  28. package/dist/native-terminal/op.d.ts +5 -0
  29. package/dist/native-terminal/op.d.ts.map +1 -0
  30. package/dist/native-terminal/op.js +408 -0
  31. package/dist/native-terminal/op.js.map +1 -0
  32. package/dist/native-terminal/process.d.ts +2 -0
  33. package/dist/native-terminal/process.d.ts.map +1 -1
  34. package/dist/native-terminal/process.js +16 -2
  35. package/dist/native-terminal/process.js.map +1 -1
  36. package/dist/native-terminal/server.d.ts.map +1 -1
  37. package/dist/native-terminal/server.js +6 -2
  38. package/dist/native-terminal/server.js.map +1 -1
  39. package/dist/native-terminal/socket-path.d.ts +8 -0
  40. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  41. package/dist/native-terminal/socket-path.js +31 -1
  42. package/dist/native-terminal/socket-path.js.map +1 -1
  43. package/dist/native-terminal/supervisor.d.ts +13 -0
  44. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  45. package/dist/native-terminal/supervisor.js +73 -1
  46. package/dist/native-terminal/supervisor.js.map +1 -1
  47. package/dist/pty.d.ts +23 -0
  48. package/dist/pty.d.ts.map +1 -1
  49. package/dist/pty.js +39 -2
  50. package/dist/pty.js.map +1 -1
  51. package/dist/readiness.d.ts +7 -0
  52. package/dist/readiness.d.ts.map +1 -1
  53. package/dist/readiness.js +12 -9
  54. package/dist/readiness.js.map +1 -1
  55. package/dist/registry.d.ts +317 -2
  56. package/dist/registry.d.ts.map +1 -1
  57. package/dist/registry.js +622 -19
  58. package/dist/registry.js.map +1 -1
  59. package/dist/restore.d.ts +91 -32
  60. package/dist/restore.d.ts.map +1 -1
  61. package/dist/restore.js +226 -186
  62. package/dist/restore.js.map +1 -1
  63. package/dist/tmux.d.ts +2 -1
  64. package/dist/tmux.d.ts.map +1 -1
  65. package/dist/tmux.js +1 -1
  66. package/dist/tmux.js.map +1 -1
  67. package/package.json +2 -2
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";
@@ -793,9 +794,25 @@ export async function startJob(job) {
793
794
  }
794
795
  }
795
796
  // --- LOCAL (P1 path), now also queue-driven. ------------------------------
796
- 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()) {
797
805
  return { started: false, error: "tmux is not installed locally" };
798
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
+ }
799
816
  const originCwd = job.originCwd ?? process.cwd();
800
817
  let argv;
801
818
  try {
@@ -914,12 +931,26 @@ export async function startJob(job) {
914
931
  try {
915
932
  if (headless) {
916
933
  const dir = jobDir(originCwd, job.id);
917
- 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" });
918
950
  tmuxSession = launched.name;
919
- agentPane = launched.agentPane;
920
- workerPid = launched.agentPane
921
- ? localSessionPanePid(launched.agentPane)
922
- : undefined;
951
+ workerPid = nativeWorkerPid(launched.name);
952
+ const h2a = getH2aConfig();
953
+ startNativeH2aSidecar(tmuxSession, runCwd, h2a.command);
923
954
  }
924
955
  else {
925
956
  const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background", attachedTerminal: true });
@@ -955,7 +986,7 @@ export async function startJob(job) {
955
986
  enroll({
956
987
  id: job.id,
957
988
  tool: job.tool,
958
- kind: "local-tmux",
989
+ kind: jobHostKind,
959
990
  cwd: runCwd,
960
991
  source: "run",
961
992
  sessionClass: "background",
@@ -1157,15 +1188,24 @@ function formatIdle(ms) {
1157
1188
  * (control-plane) are phase 2 — see the TODOs at the call site.
1158
1189
  */
1159
1190
  export function resumeThrottledJob(job) {
1160
- if (job.kind !== "local-tmux" || job.headless !== true) {
1191
+ if (!isManagedLocalKind(job.kind) || job.headless !== true) {
1161
1192
  return {
1162
1193
  started: false,
1163
1194
  error: "throttle-resume is headless-local only (phase 1)",
1164
1195
  };
1165
1196
  }
1166
- if (!tmuxAvailable()) {
1197
+ if (job.kind === "local-tmux" && !tmuxAvailable()) {
1167
1198
  return { started: false, error: "tmux is not installed locally" };
1168
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
+ }
1169
1209
  const task = job.task ?? "";
1170
1210
  const originCwd = job.originCwd ?? process.cwd();
1171
1211
  // The job's recorded `cwd` IS the runCwd it ran in (the worktree, or the
@@ -1222,11 +1262,18 @@ export function resumeThrottledJob(job) {
1222
1262
  let workerPid;
1223
1263
  try {
1224
1264
  const dir = jobDir(originCwd, job.id);
1225
- const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
1226
- tmuxSession = launched.name;
1227
- workerPid = launched.agentPane
1228
- ? localSessionPanePid(launched.agentPane)
1229
- : 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
+ }
1230
1277
  }
1231
1278
  catch (err) {
1232
1279
  return { started: false, error: err.message };
@@ -1259,7 +1306,7 @@ export function resumeThrottledJob(job) {
1259
1306
  enroll({
1260
1307
  id: job.id,
1261
1308
  tool: job.tool,
1262
- kind: "local-tmux",
1309
+ kind: job.kind,
1263
1310
  cwd: runCwd,
1264
1311
  source: "run",
1265
1312
  sessionClass: "background",
@@ -1482,7 +1529,9 @@ export async function prepareLlmMeshForRestore(opts = {}, context = {
1482
1529
  process.stderr.write(`[h2a] llm-mesh: restore context active (${gateway})\n`);
1483
1530
  }
1484
1531
  }
1485
- 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) {
1486
1535
  const parsedTarget = parseManagedSessionName(target);
1487
1536
  const canonicalSlug = local?.slug ?? parsedTarget?.slug ?? target;
1488
1537
  const tmuxSession = local?.name ?? target;
@@ -1508,9 +1557,21 @@ function registryEntriesForLocalTmuxTarget(target, local, entries = loadRegistry
1508
1557
  (e.tmuxSession !== undefined && candidates.includes(e.tmuxSession)));
1509
1558
  });
1510
1559
  }
1511
- function registryEntryForResumeTarget(target, local) {
1512
- const evidence = legacySessionEvidence(homedir(), local === undefined ? listLocalSessions() : [local]);
1513
- 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) => {
1514
1575
  // Resume is a human-facing operation, not a promotion path. Share the
1515
1576
  // durable class test with restore so legacy human rows remain resumable,
1516
1577
  // while delegated jobs and explicit background launches stay excluded.
@@ -1530,7 +1591,20 @@ function registryEntryForResumeTarget(target, local) {
1530
1591
  * that names both prefix generations to fall through to a remote session.
1531
1592
  */
1532
1593
  function resolveManagedLocalTarget(target) {
1533
- 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);
1534
1608
  if (live.kind === "found") {
1535
1609
  return { kind: "found", name: live.session.name, local: live.session };
1536
1610
  }
@@ -1540,13 +1614,40 @@ function resolveManagedLocalTarget(target) {
1540
1614
  names: live.sessions.map((session) => session.name).sort(),
1541
1615
  };
1542
1616
  }
1543
- 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);
1544
1627
  if (registered.kind === "found") {
1545
1628
  return { kind: "found", name: registered.name };
1546
1629
  }
1547
1630
  if (registered.kind === "ambiguous") {
1548
1631
  return { kind: "ambiguous", names: [...registered.names].sort() };
1549
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
+ }
1550
1651
  return { kind: "missing" };
1551
1652
  }
1552
1653
  function reportAmbiguousLocalTarget(target, action, names) {
@@ -1970,6 +2071,10 @@ export async function main(argv) {
1970
2071
  }
1971
2072
  }
1972
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.
1973
2078
  await stopRemoteSession(marker.remote, session.id, "pull-complete");
1974
2079
  }
1975
2080
  if (!remoteArchive) {
@@ -3392,17 +3497,16 @@ export async function main(argv) {
3392
3497
  .option("--codex [convId]", "resume/enroll a Codex conversation from the current directory; omit convId to let Codex show its selector")
3393
3498
  .option("--last", "with --claude/--codex, resume the last conversation for the current directory instead of showing a selector")
3394
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")
3395
3501
  .option("--replace", "replace an existing idle local tmux session without interactive confirmation")
3396
3502
  .option("--llm-gateway", "launch the CLI through the local llm-mesh gateway")
3397
3503
  .option("--gw", "alias for --llm-gateway")
3398
3504
  .option("--no-llm-gateway", "launch without llm-mesh gateway env")
3399
3505
  .option("--no-gw", "alias for --no-llm-gateway")
3400
3506
  .action(async (slug, opts) => {
3401
- if (!tmuxAvailable()) {
3402
- process.stderr.write("[h2a] tmux is not installed locally — `h2a resume` needs it (e.g. `sudo apt install tmux`).\n");
3403
- process.exitCode = 1;
3404
- return;
3405
- }
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.
3406
3510
  const explicitProfile = opts.claude !== undefined
3407
3511
  ? "claude"
3408
3512
  : opts.codex !== undefined
@@ -3443,17 +3547,32 @@ export async function main(argv) {
3443
3547
  return;
3444
3548
  }
3445
3549
  const target = slug ?? slugify(process.cwd());
3446
- const localResolution = resolveLocalSession(target);
3447
- if (localResolution.kind === "ambiguous") {
3448
- 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));
3449
3557
  process.exitCode = 1;
3450
3558
  return;
3451
3559
  }
3452
- const local = localResolution.kind === "found"
3453
- ? localResolution.session
3560
+ const preResolutionTmuxView = preResolutionTmux.kind === "found"
3561
+ ? preResolutionTmux.session
3454
3562
  : undefined;
3455
- const displaySlug = local?.slug ?? parseManagedSessionName(target)?.slug ?? target;
3456
- 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);
3457
3576
  const resolvedConvId = explicitProfile === "claude" && opts.last
3458
3577
  ? localConvStat(process.cwd())?.convId
3459
3578
  : explicitConvId;
@@ -3466,7 +3585,9 @@ export async function main(argv) {
3466
3585
  ? {
3467
3586
  id: displaySlug,
3468
3587
  tool: explicitProfile,
3469
- kind: "local-tmux",
3588
+ kind: (resolveSessionHostKind(opts) === "native"
3589
+ ? "local-native"
3590
+ : "local-tmux"),
3470
3591
  cwd: process.cwd(),
3471
3592
  label: displaySlug,
3472
3593
  ...(resolvedConvId ? { convId: resolvedConvId } : {}),
@@ -3476,12 +3597,71 @@ export async function main(argv) {
3476
3597
  source: "run",
3477
3598
  }
3478
3599
  : registryEntry;
3479
- const localIdle = local ? localSessionIdle(local.name) : false;
3480
- 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) {
3481
3659
  process.stderr.write(`[h2a] local session ${displaySlug} already exists and does not look idle; no new CLI was started.\n`);
3482
3660
  if (opts.attach || explicitProfile) {
3483
3661
  process.stderr.write(`[h2a] switching to existing session ${displaySlug}\n`);
3484
- 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);
3485
3665
  return;
3486
3666
  }
3487
3667
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
@@ -3490,7 +3670,7 @@ export async function main(argv) {
3490
3670
  }
3491
3671
  if (!entry) {
3492
3672
  process.stderr.write(`[h2a] cannot resume ${displaySlug}: registry has no single local-tmux entry with profile, cwd and convId.\n`);
3493
- if (local) {
3673
+ if (liveActTarget) {
3494
3674
  process.stderr.write(`[h2a] local session ${displaySlug} exists but cannot be relaunched without a recorded convId.\n`);
3495
3675
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
3496
3676
  }
@@ -3525,12 +3705,47 @@ export async function main(argv) {
3525
3705
  process.exitCode = 1;
3526
3706
  return;
3527
3707
  }
3528
- 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) {
3529
3743
  const gatewayEnvStatus = explicitProfile === "claude" &&
3530
3744
  (profile === "claude" || profile === "claude-code") &&
3745
+ liveActTarget.kind === "local-tmux" &&
3531
3746
  process.env.ANTHROPIC_BASE_URL &&
3532
3747
  process.env.ANTHROPIC_AUTH_TOKEN
3533
- ? localSessionGatewayEnvStatus(local.name, {
3748
+ ? localSessionGatewayEnvStatus(liveActTarget.name, {
3534
3749
  baseUrl: process.env.ANTHROPIC_BASE_URL,
3535
3750
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
3536
3751
  })
@@ -3541,9 +3756,11 @@ export async function main(argv) {
3541
3756
  }
3542
3757
  process.stderr.write(`[h2a] local session ${displaySlug} already exists and does not look idle; no new ${profile} was started.\n`);
3543
3758
  if (opts.attach || explicitProfile) {
3544
- if (explicitProfile && currentTmuxSessionIs(local.name)) {
3545
- gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, local.name);
3546
- 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({
3547
3764
  profile,
3548
3765
  cwd: entry.cwd,
3549
3766
  label: displaySlug,
@@ -3554,20 +3771,23 @@ export async function main(argv) {
3554
3771
  return;
3555
3772
  }
3556
3773
  process.stderr.write(`[h2a] switching to existing session ${displaySlug}\n`);
3557
- 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);
3558
3777
  return;
3559
3778
  }
3560
3779
  process.stderr.write(`[h2a] attach: h2a attach ${displaySlug}\n`);
3561
3780
  process.exitCode = 2;
3562
3781
  return;
3563
3782
  }
3564
- if (local) {
3565
- if (!localIdle && opts.replace) {
3566
- 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`);
3567
3787
  }
3568
3788
  if (!opts.replace) {
3569
3789
  process.stderr.write(`[h2a] local session ${displaySlug} already exists.\n`);
3570
- 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`);
3571
3791
  process.stderr.write(`[h2a] If another CLI is still writing this conversation, replacing can corrupt the .jsonl.\n`);
3572
3792
  if (!(await confirmReplace(displaySlug))) {
3573
3793
  process.stderr.write(`[h2a] takeover requires confirmation.\n`);
@@ -3578,13 +3798,27 @@ export async function main(argv) {
3578
3798
  return;
3579
3799
  }
3580
3800
  }
3581
- const rechecked = findLocalSession(displaySlug);
3582
- 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") {
3583
3815
  process.stderr.write(`[h2a] local session ${displaySlug} changed state before replace; aborting.\n`);
3584
3816
  process.exitCode = 1;
3585
3817
  return;
3586
3818
  }
3587
- if (!opts.replace && !localSessionIdle(rechecked.name)) {
3819
+ if (!opts.replace &&
3820
+ liveActTarget.kind === "local-tmux" &&
3821
+ !localSessionIdle(liveActTarget.name)) {
3588
3822
  process.stderr.write(`[h2a] local session ${displaySlug} is no longer idle; aborting.\n`);
3589
3823
  process.exitCode = 1;
3590
3824
  return;
@@ -3604,12 +3838,17 @@ export async function main(argv) {
3604
3838
  return;
3605
3839
  }
3606
3840
  }
3607
- 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) {
3608
3847
  process.stderr.write(`[h2a] local session ${displaySlug} could not be killed; no new ${profile} was started.\n`);
3609
3848
  process.exitCode = 1;
3610
3849
  return;
3611
3850
  }
3612
- process.stderr.write(`[h2a] replaced local session ${displaySlug} (${rechecked.name})\n`);
3851
+ process.stderr.write(`[h2a] replaced local session ${displaySlug} (${liveActTarget.name})\n`);
3613
3852
  }
3614
3853
  else {
3615
3854
  if (entry.convId) {
@@ -3635,11 +3874,18 @@ export async function main(argv) {
3635
3874
  process.stdin.isTTY === true &&
3636
3875
  process.stdout.isTTY === true,
3637
3876
  });
3638
- 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 });
3639
3883
  enrollFromRun({
3640
3884
  profile,
3641
3885
  slug: resumeSlug,
3642
3886
  tmuxSession: name,
3887
+ // The ACTUAL host that just started the terminal — never a default.
3888
+ hostKind: resumeHost === "native" ? "local-native" : "local-tmux",
3643
3889
  cwd: entry.cwd,
3644
3890
  sessionClass,
3645
3891
  ...(entry.convId ? { convId: entry.convId } : {}),
@@ -3650,7 +3896,10 @@ export async function main(argv) {
3650
3896
  process.stderr.write(`[h2a] gateway active: ${gateway}\n`);
3651
3897
  }
3652
3898
  if (opts.attach) {
3653
- process.exitCode = attachLocalSession(name);
3899
+ process.exitCode =
3900
+ resumeHost === "native"
3901
+ ? attachNativeSession(name)
3902
+ : attachLocalSession(name);
3654
3903
  return;
3655
3904
  }
3656
3905
  process.stderr.write(`[h2a] attach: h2a attach ${resumeSlug}\n`);
@@ -3660,6 +3909,7 @@ export async function main(argv) {
3660
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.")
3661
3910
  .option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
3662
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)")
3663
3913
  .option("-r, --resume <convId>", "resume a specific conversation in the CLI")
3664
3914
  .option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
3665
3915
  .option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
@@ -3718,10 +3968,22 @@ export async function main(argv) {
3718
3968
  process.exitCode = 2;
3719
3969
  return;
3720
3970
  }
3721
- if (!tmuxAvailable()) {
3722
- process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
3723
- process.exitCode = 1;
3724
- 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
+ }
3725
3987
  }
3726
3988
  let count = 1;
3727
3989
  if (opts.count !== undefined) {
@@ -3779,7 +4041,37 @@ export async function main(argv) {
3779
4041
  const labels = count > 1
3780
4042
  ? fanoutLabels(opts.name ?? basename(cwd), count)
3781
4043
  : [opts.name];
3782
- 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
+ ];
3783
4075
  if (existingLocalSessions.length > 0) {
3784
4076
  for (const slug of existingLocalSessions) {
3785
4077
  process.stderr.write(`[h2a] local session ${slug} already exists; no new ${profile} was started.\n`);
@@ -3916,7 +4208,22 @@ export async function main(argv) {
3916
4208
  mkdirSync(runDir, { recursive: true });
3917
4209
  outputLog = join(runDir, "output.log");
3918
4210
  resultJson = join(runDir, "result.json");
3919
- ({ 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
+ }));
3920
4227
  }
3921
4228
  else {
3922
4229
  ({ name, slug, agentPane } = startLocalSession(profile, command, cwd, args, label, getTmuxProfileConfig().profile, {
@@ -3932,15 +4239,37 @@ export async function main(argv) {
3932
4239
  sessionClass,
3933
4240
  }));
3934
4241
  }
3935
- if (structuredLaunch && !agentPane) {
4242
+ if (structuredLaunch && sessionHost !== "native" && !agentPane) {
3936
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.
3937
4248
  killLocalSession(name);
3938
4249
  process.stderr.write(`[h2a] could not capture the exact agent pane for ${slug}; the partial session was stopped\n`);
3939
4250
  process.exitCode = 1;
3940
4251
  return;
3941
4252
  }
3942
4253
  let h2aSidecarStarted = false;
3943
- 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) {
3944
4273
  if (structuredLaunch) {
3945
4274
  try {
3946
4275
  h2aSidecarStarted = Boolean(await startH2aWindowVerified(name, cwd, h2a.command, agentPane));
@@ -3955,6 +4284,9 @@ export async function main(argv) {
3955
4284
  }
3956
4285
  if (!h2aSidecarStarted && structuredLaunch) {
3957
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.
3958
4290
  killLocalSession(name);
3959
4291
  process.stderr.write(`[h2a] required h2a sidecar failed for ${slug}; the partial session was stopped\n`);
3960
4292
  process.exitCode = 1;
@@ -3971,15 +4303,17 @@ export async function main(argv) {
3971
4303
  // OBSERVED — brief seen in a real composer, then real work — because
3972
4304
  // reporting "started" over a lost brief is what left a lane inert
3973
4305
  // for 33 minutes on 2026-07-28.
3974
- promptDelivery = deliverInitialPrompt(agentPane, initialPrompt, {
3975
- capturePane: capturePaneVisible,
3976
- clearComposer: clearPaneComposer,
3977
- pasteBlock: pasteLiteralBlock,
3978
- submit: submitPane,
3979
- cpuMs: paneTreeCpuMs,
3980
- sleep: sleepSync,
3981
- now: () => Date.now(),
3982
- });
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
+ });
3983
4317
  if (promptDelivery.state !== "working") {
3984
4318
  cleanupHeadlessPromptFile(promptFile);
3985
4319
  // Only claim the session is gone when the kill actually reported
@@ -3987,7 +4321,12 @@ export async function main(argv) {
3987
4321
  // same wider-than-its-evidence claim this change exists to remove,
3988
4322
  // and a surviving partial session is precisely the idle lane the
3989
4323
  // operator was just told could not happen.
3990
- 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);
3991
4330
  const detail = promptDelivery.state === "submitted-idle"
3992
4331
  ? `the brief was submitted but the agent never started working ` +
3993
4332
  `(${Math.round(promptDelivery.cpuDeltaMs)}ms of CPU): it is not "started"`
@@ -4008,13 +4347,25 @@ export async function main(argv) {
4008
4347
  return;
4009
4348
  }
4010
4349
  }
4011
- const pid = agentPane
4012
- ? localSessionPanePid(agentPane)
4013
- : undefined;
4014
- 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;
4015
4360
  if (structuredLaunch && pid === undefined) {
4016
4361
  cleanupHeadlessPromptFile(promptFile);
4017
- 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);
4018
4369
  process.stderr.write(`[h2a] could not verify the agent pane pid for ${slug}; the partial session was stopped\n`);
4019
4370
  process.exitCode = 1;
4020
4371
  return;
@@ -4026,6 +4377,8 @@ export async function main(argv) {
4026
4377
  profile,
4027
4378
  slug,
4028
4379
  tmuxSession: name,
4380
+ // The ACTUAL host that just started the terminal — never a default.
4381
+ hostKind: sessionHost === "native" ? "local-native" : "local-tmux",
4029
4382
  ...(pid !== undefined ? { pid } : {}),
4030
4383
  ...(workerPid !== undefined ? { workerPid } : {}),
4031
4384
  cwd,
@@ -4061,6 +4414,7 @@ export async function main(argv) {
4061
4414
  session: {
4062
4415
  id: only.slug,
4063
4416
  tmuxSession: only.name,
4417
+ host: sessionHost === "native" ? "native" : "tmux",
4064
4418
  profile,
4065
4419
  workspace: cwd,
4066
4420
  mode: opts.headless ? "headless" : "interactive",
@@ -4108,7 +4462,10 @@ export async function main(argv) {
4108
4462
  process.stderr.write(`[h2a] attach with: h2a attach ${only.slug}\n`);
4109
4463
  return;
4110
4464
  }
4111
- process.exitCode = attachLocalSession(only.name);
4465
+ process.exitCode =
4466
+ sessionHost === "native"
4467
+ ? attachNativeSession(only.name)
4468
+ : attachLocalSession(only.name);
4112
4469
  return;
4113
4470
  });
4114
4471
  // ---------------------------------------------------------------------------
@@ -4118,6 +4475,7 @@ export async function main(argv) {
4118
4475
  .command("delegate <type> <task>")
4119
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.")
4120
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)")
4121
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.")
4122
4480
  .option("--name <label>", "job id / tmux slug (letters/digits/_/-; default: <type>-<random>)")
4123
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)")
@@ -4135,6 +4493,12 @@ export async function main(argv) {
4135
4493
  process.exitCode = 1;
4136
4494
  return;
4137
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";
4138
4502
  const jobType = type;
4139
4503
  try {
4140
4504
  assertDelegateEffort(jobType, opts.effort);
@@ -4213,7 +4577,7 @@ export async function main(argv) {
4213
4577
  enroll({
4214
4578
  id: jobId,
4215
4579
  tool: jobType,
4216
- kind: isRemote ? "remote" : "local-tmux",
4580
+ kind: isRemote ? "remote" : delegateLocalHostKind,
4217
4581
  cwd: isRemote ? `job-${jobId}` : process.cwd(),
4218
4582
  source: isRemote ? "remote" : "run",
4219
4583
  sessionClass: "background",
@@ -4250,7 +4614,7 @@ export async function main(argv) {
4250
4614
  const claimInput = {
4251
4615
  id: jobId,
4252
4616
  tool: jobType,
4253
- kind: (isRemote ? "remote" : "local-tmux"),
4617
+ kind: (isRemote ? "remote" : delegateLocalHostKind),
4254
4618
  cwd: isRemote ? `job-${jobId}` : process.cwd(),
4255
4619
  source: (isRemote ? "remote" : "run"),
4256
4620
  sessionClass: "background",
@@ -4299,7 +4663,7 @@ export async function main(argv) {
4299
4663
  {
4300
4664
  id: jobId,
4301
4665
  tool: jobType,
4302
- kind: isRemote ? "remote" : "local-tmux",
4666
+ kind: isRemote ? "remote" : delegateLocalHostKind,
4303
4667
  cwd: process.cwd(),
4304
4668
  enrolledAt: new Date().toISOString(),
4305
4669
  lastSeenAt: new Date().toISOString(),
@@ -4392,7 +4756,13 @@ export async function main(argv) {
4392
4756
  // → the envelope is rejected (fail closed). Used to drop forged cross-job
4393
4757
  // envelopes from the shared RWX inbox before they reach the decision logic.
4394
4758
  const expectedInstanceOf = (jobId, type) => {
4395
- 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;
4396
4766
  if (!job)
4397
4767
  return undefined;
4398
4768
  if (type === "decision.reply")
@@ -5216,10 +5586,11 @@ export async function main(argv) {
5216
5586
  // Enroll + launch via the SAME path delegate uses (task rides argv, never a
5217
5587
  // shell string). Mark processed + record the cooldown timestamp regardless of
5218
5588
  // the spawn result (a failed spawn shouldn't tight-loop a watch).
5589
+ const conductorHostKind = resolveSessionHostKind({}) === "native" ? "local-native" : "local-tmux";
5219
5590
  const launchEntry = {
5220
5591
  id: slug,
5221
5592
  tool: host,
5222
- kind: "local-tmux",
5593
+ kind: conductorHostKind,
5223
5594
  cwd: process.cwd(),
5224
5595
  enrolledAt: new Date().toISOString(),
5225
5596
  lastSeenAt: new Date().toISOString(),
@@ -5236,7 +5607,7 @@ export async function main(argv) {
5236
5607
  enroll({
5237
5608
  id: slug,
5238
5609
  tool: host,
5239
- kind: "local-tmux",
5610
+ kind: conductorHostKind,
5240
5611
  cwd: process.cwd(),
5241
5612
  source: "run",
5242
5613
  sessionClass: "background",
@@ -5278,7 +5649,16 @@ export async function main(argv) {
5278
5649
  .option("--confirm", "actually launch (default: dry-run — decide and print what WOULD launch, launch nothing)")
5279
5650
  .option("--watch <minutes>", "loop in the foreground every <minutes> (whole number >= 1); without it, runs a single pass and exits")
5280
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")
5281
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";
5282
5662
  const confirm = opts.confirm === true;
5283
5663
  const cooldownMin = opts.cooldown !== undefined ? Number(opts.cooldown) : 30;
5284
5664
  if (!Number.isFinite(cooldownMin) || cooldownMin < 0) {
@@ -5527,13 +5907,110 @@ export async function main(argv) {
5527
5907
  .option("--yes", "confirm --all non-interactively (requires --apply)")
5528
5908
  .option("--include-agents", "with --all, include claude/codex/agy sessions that are excluded by default")
5529
5909
  .action(async (filter, opts) => {
5530
- if (!tmuxAvailable()) {
5531
- 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");
5532
5912
  process.exitCode = 1;
5533
5913
  return;
5534
5914
  }
5535
- if (filter && opts.all) {
5536
- 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");
5537
6014
  process.exitCode = 1;
5538
6015
  return;
5539
6016
  }
@@ -5566,6 +6043,17 @@ export async function main(argv) {
5566
6043
  process.exitCode = 1;
5567
6044
  return;
5568
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
+ }
5569
6057
  const registryEntries = registry.entries;
5570
6058
  const resolution = forced
5571
6059
  ? reconcileRunConvIds(registryEntries, (cwd, convId) => readConversationCustomTitle(homedir(), cwd, convId))
@@ -5736,7 +6224,18 @@ export async function main(argv) {
5736
6224
  process.stderr.write(`[h2a] skipped ${action.slug}: liveness identity was unreadable immediately before kill.\n`);
5737
6225
  continue;
5738
6226
  }
5739
- 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)) {
5740
6239
  process.stderr.write(`[h2a] skipped ${action.slug}: tmux session ${action.name} changed or was no longer proven dead immediately before kill.\n`);
5741
6240
  continue;
5742
6241
  }
@@ -5752,6 +6251,9 @@ export async function main(argv) {
5752
6251
  profile: action.profile,
5753
6252
  slug: action.slug,
5754
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",
5755
6257
  cwd: entry.cwd,
5756
6258
  sessionClass,
5757
6259
  convId: action.convId,
@@ -5813,7 +6315,11 @@ export async function main(argv) {
5813
6315
  /** Interactive (claude/codex/agy) local-tmux sessions, optional slug filter. */
5814
6316
  const listInteractiveThrottleSessions = (filter) => {
5815
6317
  const enrolledAtBySlug = new Map();
5816
- 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 : []) {
5817
6323
  if (e.kind === "local-tmux")
5818
6324
  enrolledAtBySlug.set(e.id, e.enrolledAt);
5819
6325
  }
@@ -5887,7 +6393,11 @@ export async function main(argv) {
5887
6393
  });
5888
6394
  let nudged = 0;
5889
6395
  if (apply) {
5890
- 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 : [])
5891
6401
  .filter((entry) => entry.tmuxSession !== undefined)
5892
6402
  .map((entry) => [entry.tmuxSession, entry.id]));
5893
6403
  for (const r of plan.toResume) {
@@ -6276,6 +6786,13 @@ export async function main(argv) {
6276
6786
  process.exitCode = 1;
6277
6787
  return;
6278
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
+ }
6279
6796
  // A session started by `h2a run` is enrolled in the registry as
6280
6797
  // kind:"local-tmux". Trust that durable record even when a transient
6281
6798
  // `tmux list-sessions` miss hides the live session — otherwise a purely
@@ -6291,8 +6808,37 @@ export async function main(argv) {
6291
6808
  process.exitCode = 1;
6292
6809
  return;
6293
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";
6294
6835
  if (opts.headlessTerminal) {
6295
- 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);
6296
6842
  if (terminal.state === "unavailable") {
6297
6843
  process.stderr.write(`[h2a] could not attach a persistent terminal to ${first}: ${terminal.reason}\n`);
6298
6844
  process.exitCode = 1;
@@ -6301,7 +6847,27 @@ export async function main(argv) {
6301
6847
  process.stderr.write(`[h2a] persistent terminal ${terminal.state} for ${first} (${terminal.attachedClients} attached client(s))\n`);
6302
6848
  return;
6303
6849
  }
6304
- 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);
6305
6871
  return;
6306
6872
  }
6307
6873
  }
@@ -6483,13 +7049,93 @@ export async function main(argv) {
6483
7049
  process.exitCode = 1;
6484
7050
  return;
6485
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
+ }
6486
7095
  if (localTarget.kind === "found") {
6487
- const ok = killLocalSession(localTarget.name);
6488
- 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`);
6489
7125
  if (!ok)
6490
7126
  process.exitCode = 1;
6491
7127
  return;
6492
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.
6493
7139
  }
6494
7140
  const { url, sessionId } = resolveUrlAndSessionId(first, second);
6495
7141
  const result = await stopRemoteSession(url, sessionId, opts.reason);