@sentropic/h2a-runtime 0.92.0 → 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.
- package/dist/conv-guard.d.ts +6 -1
- package/dist/conv-guard.d.ts.map +1 -1
- package/dist/conv-guard.js +33 -8
- package/dist/conv-guard.js.map +1 -1
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +12 -2
- package/dist/enroll.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +743 -97
- package/dist/index.js.map +1 -1
- package/dist/launch-layout.d.ts +74 -0
- package/dist/launch-layout.d.ts.map +1 -0
- package/dist/launch-layout.js +324 -0
- package/dist/launch-layout.js.map +1 -0
- package/dist/native-host.d.ts +146 -0
- package/dist/native-host.d.ts.map +1 -0
- package/dist/native-host.js +456 -0
- package/dist/native-host.js.map +1 -0
- package/dist/native-terminal/host.d.ts +317 -1
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +696 -3
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts +5 -0
- package/dist/native-terminal/op.d.ts.map +1 -0
- package/dist/native-terminal/op.js +408 -0
- package/dist/native-terminal/op.js.map +1 -0
- package/dist/native-terminal/process.d.ts +2 -0
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +16 -2
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +6 -2
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +8 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +31 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +13 -0
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +45 -0
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/pty.d.ts +23 -0
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +35 -0
- package/dist/pty.js.map +1 -1
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +12 -9
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +317 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +622 -19
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +91 -32
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +226 -186
- package/dist/restore.js.map +1 -1
- package/dist/tmux.d.ts +2 -1
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +1 -1
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -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";
|
|
@@ -793,9 +794,25 @@ export async function startJob(job) {
|
|
|
793
794
|
}
|
|
794
795
|
}
|
|
795
796
|
// --- LOCAL (P1 path), now also queue-driven. ------------------------------
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
1513
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
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
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
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
|
|
3453
|
-
?
|
|
3560
|
+
const preResolutionTmuxView = preResolutionTmux.kind === "found"
|
|
3561
|
+
? preResolutionTmux.session
|
|
3454
3562
|
: undefined;
|
|
3455
|
-
const displaySlug =
|
|
3456
|
-
|
|
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: "
|
|
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
|
-
|
|
3480
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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(
|
|
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 &&
|
|
3545
|
-
|
|
3546
|
-
|
|
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
|
-
|
|
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 (
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
|
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
|
-
|
|
3582
|
-
|
|
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 &&
|
|
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
|
-
|
|
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} (${
|
|
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 } =
|
|
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 =
|
|
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
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
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
|
-
|
|
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 =
|
|
4012
|
-
?
|
|
4013
|
-
:
|
|
4014
|
-
|
|
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
|
-
|
|
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 =
|
|
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" :
|
|
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" :
|
|
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" :
|
|
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
|
|
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:
|
|
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:
|
|
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 (
|
|
5531
|
-
process.stderr.write("[h2a]
|
|
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
|
-
|
|
5536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6488
|
-
|
|
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);
|