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