@sentropic/h2a-runtime 0.88.0 → 0.90.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/attach.d.ts +21 -0
- package/dist/attach.d.ts.map +1 -1
- package/dist/attach.js +69 -1
- package/dist/attach.js.map +1 -1
- package/dist/file-lock.d.ts +34 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +96 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -50
- package/dist/index.js.map +1 -1
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/accounts.js +3 -0
- package/dist/llm-gateway-runtime/accounts.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js +24 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.js +53 -2
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -1
- package/dist/proc-cpu.d.ts +55 -0
- package/dist/proc-cpu.d.ts.map +1 -0
- package/dist/proc-cpu.js +133 -0
- package/dist/proc-cpu.js.map +1 -0
- package/dist/prompt-delivery.d.ts +159 -0
- package/dist/prompt-delivery.d.ts.map +1 -0
- package/dist/prompt-delivery.js +469 -0
- package/dist/prompt-delivery.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +7 -69
- package/dist/registry.js.map +1 -1
- package/dist/relaunch.d.ts +20 -1
- package/dist/relaunch.d.ts.map +1 -1
- package/dist/relaunch.js +31 -10
- package/dist/relaunch.js.map +1 -1
- package/dist/restore.d.ts +20 -4
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +26 -4
- package/dist/restore.js.map +1 -1
- package/dist/session-lease.d.ts +266 -0
- package/dist/session-lease.d.ts.map +1 -0
- package/dist/session-lease.js +342 -0
- package/dist/session-lease.js.map +1 -0
- package/dist/tmux.d.ts +77 -2
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +269 -23
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,15 +15,17 @@ import { transmittedSecrets, secretsSummary } from "./secrets.js";
|
|
|
15
15
|
import { localConvStat, remoteConvStat, alignment } from "./convsync.js";
|
|
16
16
|
import { gitAlignment, localAncestry, localGitStat, remoteGitStat, } from "./gitdiff.js";
|
|
17
17
|
import { syncConversation } from "./sync.js";
|
|
18
|
-
import { attachLocalSession, attachPodTmux, capturePane, conductorRunning, currentTmuxSessionName, currentTmuxSessionIs, ensureManagedTmuxProfile, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, localSessionIdle, localSessionPanePid, localSessionName, installH2aStatusSurface, managedSessionCandidates, parseManagedSessionName, openH2aStatusWindow, persistLaunchContext, readLaunchContext, relaunchInSession, resolveAgentPaneForInstance, resolveLocalSession, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, uninstallH2aStatusSurface, } from "./tmux.js";
|
|
18
|
+
import { attachLocalSession, attachPodTmux, capturePane, capturePaneVisible, clearPaneComposer, conductorRunning, currentTmuxSessionName, currentTmuxSessionIs, ensureManagedTmuxProfile, ensureHeadlessTerminal, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, listLocalSessionsWithDiagnostics, localSessionIdle, localSessionPanePid, localSessionName, paneTreeCpuMs, paneWorkerPid, pasteLiteralBlock, submitPane, installH2aStatusSurface, managedSessionCandidates, parseManagedSessionName, openH2aStatusWindow, persistLaunchContext, readLaunchContext, relaunchInSession, resolveAgentPane, resolveAgentPaneForInstance, resolveLocalSession, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, uninstallH2aStatusSurface, } from "./tmux.js";
|
|
19
|
+
import { deliverInitialPrompt, sleepSync, } from "./prompt-delivery.js";
|
|
19
20
|
import { buildLaunchContext } from "./launch-context.js";
|
|
20
21
|
import { migrateTmuxNames } from "./tmux-name-migration.js";
|
|
21
22
|
import { projectStatusForH2a } from "./status-projection.js";
|
|
22
23
|
import { buildAgentLaunchArgs, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
|
|
23
24
|
import { planRelaunch } from "./relaunch.js";
|
|
24
|
-
import { readLastLayout, restore as restoreLayout, } from "./restore.js";
|
|
25
|
+
import { isHumanFacingSession, readConversationCustomTitle, readLastLayout, reconcileRunConvIds, restore as restoreLayout, } from "./restore.js";
|
|
25
26
|
import { getLayoutConfig } from "./config.js";
|
|
26
|
-
import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, resolveLocalTmuxSessionForName, tryClaimSlot, withRegistryLock, resolveRegistryPath, } from "./registry.js";
|
|
27
|
+
import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, loadRegistryWithDiagnostics, resolveLocalTmuxSessionForName, tryClaimSlot, withRegistryLock, resolveRegistryPath, } from "./registry.js";
|
|
28
|
+
import { SessionLeaseStore, decideSessionBeat, isSessionLeaseAbandoned, planLeaseSupervision, reclaimProposals, resolveSessionLeasePath, } from "./session-lease.js";
|
|
27
29
|
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";
|
|
28
30
|
import { detectThrottle, THROTTLE_TAIL_LINES } from "./throttle-signatures.js";
|
|
29
31
|
import { interactiveResumeNudge, planInteractiveResume, } from "./interactive-throttle.js";
|
|
@@ -905,18 +907,21 @@ export async function startJob(job) {
|
|
|
905
907
|
const restoreGatewayEnvironment = replaceAnthropicGatewayEnvironment(process.env, meshEnv ?? undefined);
|
|
906
908
|
let tmuxSession;
|
|
907
909
|
let workerPid;
|
|
910
|
+
let agentPane;
|
|
908
911
|
try {
|
|
909
912
|
if (headless) {
|
|
910
913
|
const dir = jobDir(originCwd, job.id);
|
|
911
914
|
const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
|
|
912
915
|
tmuxSession = launched.name;
|
|
916
|
+
agentPane = launched.agentPane;
|
|
913
917
|
workerPid = launched.agentPane
|
|
914
918
|
? localSessionPanePid(launched.agentPane)
|
|
915
919
|
: undefined;
|
|
916
920
|
}
|
|
917
921
|
else {
|
|
918
|
-
const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background" });
|
|
922
|
+
const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background", attachedTerminal: true });
|
|
919
923
|
tmuxSession = launched.name;
|
|
924
|
+
agentPane = launched.agentPane;
|
|
920
925
|
workerPid = launched.agentPane
|
|
921
926
|
? localSessionPanePid(launched.agentPane)
|
|
922
927
|
: undefined;
|
|
@@ -971,6 +976,21 @@ export async function startJob(job) {
|
|
|
971
976
|
? { delegatorTmuxSession: job.delegatorTmuxSession }
|
|
972
977
|
: {}),
|
|
973
978
|
});
|
|
979
|
+
// Take the SLOT lease for this session (machine-scoped). From here on the slot
|
|
980
|
+
// is held only as long as something keeps beating it — a session that finishes
|
|
981
|
+
// without dying stops beating and its slot is reported reclaimable after one
|
|
982
|
+
// TTL, instead of the 994 minutes measured on the worst hoarder. Best-effort:
|
|
983
|
+
// a lease hiccup must never break a launch that already happened.
|
|
984
|
+
try {
|
|
985
|
+
acquireSessionLease(job.id, {
|
|
986
|
+
holder: job.delegatorInstance ?? "h2a:startJob",
|
|
987
|
+
workspace: runCwd,
|
|
988
|
+
...(agentPane !== undefined ? { agentPane } : {}),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// the store is advisory — the job is running either way
|
|
993
|
+
}
|
|
974
994
|
mirrorNew();
|
|
975
995
|
return {
|
|
976
996
|
started: true,
|
|
@@ -978,6 +998,91 @@ export async function startJob(job) {
|
|
|
978
998
|
detail: `${runCwd}${isolated ? " [worktree]" : ""}`,
|
|
979
999
|
};
|
|
980
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Open the machine-scoped session-lease store. The clock is injected HERE, at
|
|
1003
|
+
* the call site, and never read inside the store: abandonment stays a reader's
|
|
1004
|
+
* computation against an instant the caller names.
|
|
1005
|
+
*/
|
|
1006
|
+
function sessionLeaseStore() {
|
|
1007
|
+
return new SessionLeaseStore(resolveSessionLeasePath(), {
|
|
1008
|
+
now: () => new Date().toISOString(),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Take the slot lease for a freshly launched session, seeding it with the pane's
|
|
1013
|
+
* current process-tree CPU so the FIRST supervising pass already has a baseline
|
|
1014
|
+
* to compute a rate against.
|
|
1015
|
+
*/
|
|
1016
|
+
export function acquireSessionLease(sessionId, opts) {
|
|
1017
|
+
const cpuMs = opts.agentPane ? paneTreeCpuMs(opts.agentPane) : undefined;
|
|
1018
|
+
return sessionLeaseStore().acquire({
|
|
1019
|
+
sessionId,
|
|
1020
|
+
holder: opts.holder,
|
|
1021
|
+
...(opts.workspace !== undefined ? { workspace: opts.workspace } : {}),
|
|
1022
|
+
...(cpuMs !== undefined ? { cpuMs } : {}),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* One supervising pass: for every recorded lease, decide from the REGISTRY what
|
|
1027
|
+
* to do, then — for sessions still running — probe the pane's process tree and
|
|
1028
|
+
* beat only what is actually working. Everything else merely records its CPU
|
|
1029
|
+
* reading, so the abandonment clock keeps running.
|
|
1030
|
+
*
|
|
1031
|
+
* This pass NEVER kills, never sends a key, and never frees a slot on its own.
|
|
1032
|
+
* Its only output beyond the store is a list of PROPOSALS.
|
|
1033
|
+
*/
|
|
1034
|
+
export function superviseSessionLeases(sessions, now = new Date().toISOString()) {
|
|
1035
|
+
const store = sessionLeaseStore();
|
|
1036
|
+
const result = {
|
|
1037
|
+
beaten: [],
|
|
1038
|
+
observed: [],
|
|
1039
|
+
released: [],
|
|
1040
|
+
unreadable: [],
|
|
1041
|
+
proposals: [],
|
|
1042
|
+
};
|
|
1043
|
+
const leases = store.readAll();
|
|
1044
|
+
const byId = new Map(leases.map((lease) => [lease.sessionId, lease]));
|
|
1045
|
+
for (const step of planLeaseSupervision(leases, sessions)) {
|
|
1046
|
+
const lease = byId.get(step.sessionId);
|
|
1047
|
+
if (lease === undefined)
|
|
1048
|
+
continue;
|
|
1049
|
+
try {
|
|
1050
|
+
if (step.action === "release") {
|
|
1051
|
+
store.release({ sessionId: lease.sessionId, token: lease.token });
|
|
1052
|
+
result.released.push(lease.sessionId);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (step.action === "leave")
|
|
1056
|
+
continue;
|
|
1057
|
+
const pane = step.tmuxSession ? resolveAgentPane(step.tmuxSession) : undefined;
|
|
1058
|
+
const cpuMsNow = pane ? paneTreeCpuMs(pane) : undefined;
|
|
1059
|
+
const decision = decideSessionBeat({ lease, cpuMsNow, now });
|
|
1060
|
+
if (decision.action === "beat" && cpuMsNow !== undefined) {
|
|
1061
|
+
store.heartbeat({ sessionId: lease.sessionId, token: lease.token, cpuMs: cpuMsNow });
|
|
1062
|
+
result.beaten.push(lease.sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
else if (decision.action === "observe" && cpuMsNow !== undefined) {
|
|
1065
|
+
store.observe({ sessionId: lease.sessionId, token: lease.token, cpuMs: cpuMsNow });
|
|
1066
|
+
result.observed.push(lease.sessionId);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
result.unreadable.push(lease.sessionId);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
// a rejected write (raced re-acquisition, torn store) must not stop the pass
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
result.proposals = reclaimProposals(store.readAll(), now);
|
|
1077
|
+
return result;
|
|
1078
|
+
}
|
|
1079
|
+
/** Human-readable duration for lease reporting ("41m", "2h13m"). */
|
|
1080
|
+
function formatIdle(ms) {
|
|
1081
|
+
const minutes = Math.floor(ms / 60_000);
|
|
1082
|
+
if (minutes < 60)
|
|
1083
|
+
return `${minutes}m`;
|
|
1084
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
1085
|
+
}
|
|
981
1086
|
/**
|
|
982
1087
|
* Reliability slice 1 — RESUME a throttled HEADLESS LOCAL job. Relaunch the SAME
|
|
983
1088
|
* job in the SAME `runCwd` it already ran in (its recorded `cwd` — NOT a fresh
|
|
@@ -1317,7 +1422,7 @@ async function prepareLlmMeshForRestore(opts = {}) {
|
|
|
1317
1422
|
process.stderr.write(`[h2a] llm-mesh: restore context active (${gateway})\n`);
|
|
1318
1423
|
}
|
|
1319
1424
|
}
|
|
1320
|
-
function
|
|
1425
|
+
function registryEntriesForLocalTmuxTarget(target, local, entries = loadRegistry()) {
|
|
1321
1426
|
const parsedTarget = parseManagedSessionName(target);
|
|
1322
1427
|
const canonicalSlug = local?.slug ?? parsedTarget?.slug ?? target;
|
|
1323
1428
|
const tmuxSession = local?.name ?? target;
|
|
@@ -1326,13 +1431,8 @@ function registryEntryForResumeTarget(target, local) {
|
|
|
1326
1431
|
: parsedTarget
|
|
1327
1432
|
? [target]
|
|
1328
1433
|
: managedSessionCandidates(canonicalSlug);
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
// durable class intact by accepting only explicitly human rows; legacy and
|
|
1332
|
-
// background entries fail closed instead of becoming restorable here.
|
|
1333
|
-
if (e.role !== undefined ||
|
|
1334
|
-
e.kind !== "local-tmux" ||
|
|
1335
|
-
e.sessionClass !== "human")
|
|
1434
|
+
return entries.filter((e) => {
|
|
1435
|
+
if (e.kind !== "local-tmux")
|
|
1336
1436
|
return false;
|
|
1337
1437
|
// Full managed names are exact targets; never reinterpret one as an id
|
|
1338
1438
|
// or label that happens to share a prefix-shaped string.
|
|
@@ -1347,6 +1447,16 @@ function registryEntryForResumeTarget(target, local) {
|
|
|
1347
1447
|
e.tmuxSession === tmuxSession ||
|
|
1348
1448
|
(e.tmuxSession !== undefined && candidates.includes(e.tmuxSession)));
|
|
1349
1449
|
});
|
|
1450
|
+
}
|
|
1451
|
+
function registryEntryForResumeTarget(target, local) {
|
|
1452
|
+
const matches = registryEntriesForLocalTmuxTarget(target, local).filter((e) => {
|
|
1453
|
+
// Resume is a human-facing operation, not a promotion path. Share the
|
|
1454
|
+
// durable class test with restore so legacy human rows remain resumable,
|
|
1455
|
+
// while delegated jobs and explicit background launches stay excluded.
|
|
1456
|
+
if (e.role !== undefined)
|
|
1457
|
+
return false;
|
|
1458
|
+
return isHumanFacingSession(e);
|
|
1459
|
+
});
|
|
1350
1460
|
const ids = new Set(matches.map((e) => e.id));
|
|
1351
1461
|
if (ids.size !== 1)
|
|
1352
1462
|
return undefined;
|
|
@@ -1401,6 +1511,23 @@ async function confirmReplace(slug) {
|
|
|
1401
1511
|
rl.close();
|
|
1402
1512
|
}
|
|
1403
1513
|
}
|
|
1514
|
+
async function confirmRelaunchAll() {
|
|
1515
|
+
if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
|
|
1516
|
+
return false;
|
|
1517
|
+
}
|
|
1518
|
+
const { createInterface: createPromisesInterface } = await import("node:readline/promises");
|
|
1519
|
+
const rl = createPromisesInterface({
|
|
1520
|
+
input: process.stdin,
|
|
1521
|
+
output: process.stderr,
|
|
1522
|
+
});
|
|
1523
|
+
try {
|
|
1524
|
+
const answer = await rl.question('Type "RELAUNCH" to force-restart every listed session: ');
|
|
1525
|
+
return answer.trim() === "RELAUNCH";
|
|
1526
|
+
}
|
|
1527
|
+
finally {
|
|
1528
|
+
rl.close();
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1404
1531
|
function resolveUrlAndSessionId(first, second) {
|
|
1405
1532
|
if (second !== undefined) {
|
|
1406
1533
|
return { url: getConfiguredRemote(first), sessionId: second };
|
|
@@ -3408,7 +3535,7 @@ export async function main(argv) {
|
|
|
3408
3535
|
}
|
|
3409
3536
|
}
|
|
3410
3537
|
gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, localSessionName(resumeSlug));
|
|
3411
|
-
const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human" });
|
|
3538
|
+
const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human", attachedTerminal: true });
|
|
3412
3539
|
enrollFromRun({
|
|
3413
3540
|
profile,
|
|
3414
3541
|
slug: resumeSlug,
|
|
@@ -3432,6 +3559,7 @@ export async function main(argv) {
|
|
|
3432
3559
|
.command("run <profile> [path]")
|
|
3433
3560
|
.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.")
|
|
3434
3561
|
.option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
|
|
3562
|
+
.option("--no-attached-terminal", "disable the persistent headless tmux client (legacy compatibility; cannot be used with --prompt-stdin)")
|
|
3435
3563
|
.option("-r, --resume <convId>", "resume a specific conversation in the CLI")
|
|
3436
3564
|
.option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
|
|
3437
3565
|
.option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
|
|
@@ -3485,6 +3613,11 @@ export async function main(argv) {
|
|
|
3485
3613
|
process.exitCode = 2;
|
|
3486
3614
|
return;
|
|
3487
3615
|
}
|
|
3616
|
+
if (opts.promptStdin && opts.attachedTerminal === false) {
|
|
3617
|
+
process.stderr.write("[h2a] --prompt-stdin requires the attached terminal; remove --no-attached-terminal.\n");
|
|
3618
|
+
process.exitCode = 2;
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3488
3621
|
if (!tmuxAvailable()) {
|
|
3489
3622
|
process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
|
|
3490
3623
|
process.exitCode = 1;
|
|
@@ -3673,6 +3806,7 @@ export async function main(argv) {
|
|
|
3673
3806
|
let resultJson;
|
|
3674
3807
|
let agentPane;
|
|
3675
3808
|
let promptFile;
|
|
3809
|
+
let promptDelivery;
|
|
3676
3810
|
if (opts.headless) {
|
|
3677
3811
|
const runDir = join(cwd, ".h2a", "runs", label);
|
|
3678
3812
|
mkdirSync(runDir, { recursive: true });
|
|
@@ -3690,6 +3824,7 @@ export async function main(argv) {
|
|
|
3690
3824
|
? { terminateOnAgentExit: true }
|
|
3691
3825
|
: {}),
|
|
3692
3826
|
...(structuredLaunch ? { refuseExisting: true } : {}),
|
|
3827
|
+
attachedTerminal: opts.attachedTerminal !== false,
|
|
3693
3828
|
sessionClass,
|
|
3694
3829
|
}));
|
|
3695
3830
|
}
|
|
@@ -3725,18 +3860,54 @@ export async function main(argv) {
|
|
|
3725
3860
|
process.stderr.write(`[h2a] h2a window started in ${slug} (${h2a.command})\n`);
|
|
3726
3861
|
}
|
|
3727
3862
|
}
|
|
3728
|
-
if (!opts.headless &&
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3863
|
+
if (!opts.headless && initialPrompt !== undefined) {
|
|
3864
|
+
// NEVER paste-and-hope: `tmux new-session -d` returns before the
|
|
3865
|
+
// agent TUI draws anything, and a prompt sent into that window is
|
|
3866
|
+
// echoed by the raw terminal and then discarded. Delivery is
|
|
3867
|
+
// OBSERVED — brief seen in a real composer, then real work — because
|
|
3868
|
+
// reporting "started" over a lost brief is what left a lane inert
|
|
3869
|
+
// for 33 minutes on 2026-07-28.
|
|
3870
|
+
promptDelivery = deliverInitialPrompt(agentPane, initialPrompt, {
|
|
3871
|
+
capturePane: capturePaneVisible,
|
|
3872
|
+
clearComposer: clearPaneComposer,
|
|
3873
|
+
pasteBlock: pasteLiteralBlock,
|
|
3874
|
+
submit: submitPane,
|
|
3875
|
+
cpuMs: paneTreeCpuMs,
|
|
3876
|
+
sleep: sleepSync,
|
|
3877
|
+
now: () => Date.now(),
|
|
3878
|
+
});
|
|
3879
|
+
if (promptDelivery.state !== "working") {
|
|
3880
|
+
cleanupHeadlessPromptFile(promptFile);
|
|
3881
|
+
// Only claim the session is gone when the kill actually reported
|
|
3882
|
+
// success. "nothing is left running" over an unchecked call is the
|
|
3883
|
+
// same wider-than-its-evidence claim this change exists to remove,
|
|
3884
|
+
// and a surviving partial session is precisely the idle lane the
|
|
3885
|
+
// operator was just told could not happen.
|
|
3886
|
+
const stopped = killLocalSession(name);
|
|
3887
|
+
const detail = promptDelivery.state === "submitted-idle"
|
|
3888
|
+
? `the brief was submitted but the agent never started working ` +
|
|
3889
|
+
`(${Math.round(promptDelivery.cpuDeltaMs)}ms of CPU): it is not "started"`
|
|
3890
|
+
: promptDelivery.reason;
|
|
3891
|
+
process.stderr.write(`[h2a] the initial prompt did not reach ${slug}: ${detail}\n` +
|
|
3892
|
+
(promptDelivery.state === "host-modal"
|
|
3893
|
+
? `[h2a] fix: ${promptDelivery.hint}\n`
|
|
3894
|
+
: "") +
|
|
3895
|
+
(stopped
|
|
3896
|
+
? `[h2a] the partial session was stopped, so nothing is left running idle\n`
|
|
3897
|
+
: `[h2a] WARNING: stopping the partial session FAILED — ${name} may still be alive; ` +
|
|
3898
|
+
`list it with h2a ls and stop it with h2a stop ${slug} --reason failed-launch\n`) +
|
|
3899
|
+
(promptDelivery.state !== "submitted-idle" &&
|
|
3900
|
+
promptDelivery.capture
|
|
3901
|
+
? `[h2a] last screen:\n${promptDelivery.capture}\n`
|
|
3902
|
+
: ""));
|
|
3903
|
+
process.exitCode = 1;
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3736
3906
|
}
|
|
3737
3907
|
const pid = agentPane
|
|
3738
3908
|
? localSessionPanePid(agentPane)
|
|
3739
3909
|
: undefined;
|
|
3910
|
+
const workerPid = agentPane !== undefined ? paneWorkerPid(agentPane) : undefined;
|
|
3740
3911
|
if (structuredLaunch && pid === undefined) {
|
|
3741
3912
|
cleanupHeadlessPromptFile(promptFile);
|
|
3742
3913
|
killLocalSession(name);
|
|
@@ -3752,6 +3923,7 @@ export async function main(argv) {
|
|
|
3752
3923
|
slug,
|
|
3753
3924
|
tmuxSession: name,
|
|
3754
3925
|
...(pid !== undefined ? { pid } : {}),
|
|
3926
|
+
...(workerPid !== undefined ? { workerPid } : {}),
|
|
3755
3927
|
cwd,
|
|
3756
3928
|
sessionClass,
|
|
3757
3929
|
...(opts.resume !== undefined ? { convId: opts.resume } : {}),
|
|
@@ -3765,6 +3937,7 @@ export async function main(argv) {
|
|
|
3765
3937
|
...(pid !== undefined ? { pid } : {}),
|
|
3766
3938
|
...(outputLog !== undefined ? { outputLog } : {}),
|
|
3767
3939
|
...(resultJson !== undefined ? { resultJson } : {}),
|
|
3940
|
+
...(promptDelivery !== undefined ? { promptDelivery } : {}),
|
|
3768
3941
|
});
|
|
3769
3942
|
}
|
|
3770
3943
|
if (count > 1) {
|
|
@@ -3792,10 +3965,28 @@ export async function main(argv) {
|
|
|
3792
3965
|
h2aSidecar: only.h2aSidecar,
|
|
3793
3966
|
...(only.pane !== undefined ? { pane: only.pane } : {}),
|
|
3794
3967
|
...(only.pid !== undefined ? { pid: only.pid } : {}),
|
|
3968
|
+
// `pid` is the launch WRAPPER. Measured 2026-07-29: it showed 0s
|
|
3969
|
+
// of CPU after 37s while its codex child had burned 13s, so a
|
|
3970
|
+
// liveness check on `pid` alone reports a working agent as dead.
|
|
3971
|
+
// Name the process that actually works, and say it is distinct.
|
|
3972
|
+
...(only.workerPid !== undefined
|
|
3973
|
+
? { workerPid: only.workerPid }
|
|
3974
|
+
: {}),
|
|
3795
3975
|
},
|
|
3796
3976
|
attach: opts.headless
|
|
3797
3977
|
? null
|
|
3798
3978
|
: { command: "h2a", args: ["attach", only.slug] },
|
|
3979
|
+
// What was OBSERVED, not what was attempted: the brief was seen in
|
|
3980
|
+
// a real composer, submitted, and the agent's CPU then moved.
|
|
3981
|
+
...(only.promptDelivery?.state === "working"
|
|
3982
|
+
? {
|
|
3983
|
+
prompt: {
|
|
3984
|
+
delivered: true,
|
|
3985
|
+
waitedMs: only.promptDelivery.waitedMs,
|
|
3986
|
+
cpuDeltaMs: Math.round(only.promptDelivery.cpuDeltaMs),
|
|
3987
|
+
},
|
|
3988
|
+
}
|
|
3989
|
+
: {}),
|
|
3799
3990
|
...(only.outputLog !== undefined
|
|
3800
3991
|
? {
|
|
3801
3992
|
logs: { path: only.outputLog },
|
|
@@ -4358,6 +4549,74 @@ export async function main(argv) {
|
|
|
4358
4549
|
if (claudeWarn)
|
|
4359
4550
|
process.stderr.write(`${claudeWarn}\n`);
|
|
4360
4551
|
});
|
|
4552
|
+
jobsCommand
|
|
4553
|
+
.command("leases")
|
|
4554
|
+
.description("Session slot leases (machine-scoped): who holds a concurrency slot, when it last proved it was working, and which slots are reclaimable. READ-ONLY — it kills nothing and frees nothing.")
|
|
4555
|
+
.option("--probe", "before reporting, run one supervising pass: beat the leases of sessions whose pane is burning CPU, record the others")
|
|
4556
|
+
.action((opts) => {
|
|
4557
|
+
const now = new Date().toISOString();
|
|
4558
|
+
if (opts.probe === true)
|
|
4559
|
+
superviseSessionLeases(listJobs(), now);
|
|
4560
|
+
const leases = sessionLeaseStore().readAll();
|
|
4561
|
+
if (leases.length === 0) {
|
|
4562
|
+
process.stdout.write("no session leases\n");
|
|
4563
|
+
return;
|
|
4564
|
+
}
|
|
4565
|
+
const nowMs = Date.parse(now);
|
|
4566
|
+
const rows = leases
|
|
4567
|
+
.slice()
|
|
4568
|
+
.sort((a, b) => Date.parse(a.heartbeatAt) - Date.parse(b.heartbeatAt))
|
|
4569
|
+
.map((lease) => {
|
|
4570
|
+
const idle = nowMs - Date.parse(lease.heartbeatAt);
|
|
4571
|
+
const state = isSessionLeaseAbandoned(lease, now) ? "RECLAIMABLE" : "held";
|
|
4572
|
+
return (`${lease.sessionId.padEnd(24)} ${state.padEnd(12)} ` +
|
|
4573
|
+
`idle ${formatIdle(idle).padEnd(7)} ttl ${formatIdle(lease.ttlMs).padEnd(6)} ` +
|
|
4574
|
+
`holder ${lease.holder}`);
|
|
4575
|
+
});
|
|
4576
|
+
process.stdout.write(`${rows.join("\n")}\n`);
|
|
4577
|
+
const reclaimable = reclaimProposals(leases, now).length;
|
|
4578
|
+
if (reclaimable > 0) {
|
|
4579
|
+
process.stderr.write(`[h2a] ${reclaimable} slot(s) reclaimable — free one with: h2a jobs reclaim <id> ` +
|
|
4580
|
+
"(the session's tmux window is left running and can still be attached)\n");
|
|
4581
|
+
}
|
|
4582
|
+
});
|
|
4583
|
+
jobsCommand
|
|
4584
|
+
.command("reclaim <id>")
|
|
4585
|
+
.description("Free the concurrency SLOT of a session that stopped working (its lease is abandoned): the job is marked terminal and its lease released. It does NOT kill anything — the tmux session keeps running and stays attachable. Refuses while the lease is still being beaten.")
|
|
4586
|
+
.action((id) => {
|
|
4587
|
+
const now = new Date().toISOString();
|
|
4588
|
+
const store = sessionLeaseStore();
|
|
4589
|
+
const lease = store.forSession(id);
|
|
4590
|
+
if (!lease) {
|
|
4591
|
+
process.stderr.write(`[h2a] no session lease for "${id}" — nothing to reclaim (see: h2a jobs leases)\n`);
|
|
4592
|
+
process.exitCode = 1;
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
// The one guard that matters: a session still proving it works keeps its
|
|
4596
|
+
// slot, whatever the operator believes. Reclaiming is not arbitration.
|
|
4597
|
+
if (!isSessionLeaseAbandoned(lease, now)) {
|
|
4598
|
+
const idle = Date.parse(now) - Date.parse(lease.heartbeatAt);
|
|
4599
|
+
process.stderr.write(`[h2a] refusing to reclaim ${id}: its lease still beats (last proof of work ` +
|
|
4600
|
+
`${formatIdle(idle)} ago, TTL ${formatIdle(lease.ttlMs)}). It is working.\n`);
|
|
4601
|
+
process.exitCode = 1;
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
4604
|
+
const job = listJobs().find((e) => e.id === id);
|
|
4605
|
+
const advanced = job ? advanceJob(id, "failed") : undefined;
|
|
4606
|
+
try {
|
|
4607
|
+
store.release({ sessionId: id, token: lease.token });
|
|
4608
|
+
}
|
|
4609
|
+
catch (err) {
|
|
4610
|
+
process.stderr.write(`[h2a] lease release failed: ${err.message}\n`);
|
|
4611
|
+
}
|
|
4612
|
+
process.stdout.write(`reclaimed ${id} — slot freed after ` +
|
|
4613
|
+
`${formatIdle(Date.parse(now) - Date.parse(lease.heartbeatAt))} without work\n`);
|
|
4614
|
+
process.stderr.write(`[h2a] the session was NOT killed: ${job?.tmuxSession ?? "its tmux session"} is still running ` +
|
|
4615
|
+
`(h2a jobs attach ${id}). ` +
|
|
4616
|
+
(advanced === undefined
|
|
4617
|
+
? "No registry entry to advance — only the lease was released.\n"
|
|
4618
|
+
: "The job is recorded terminal so the conductor can admit queued work.\n"));
|
|
4619
|
+
});
|
|
4361
4620
|
jobsCommand
|
|
4362
4621
|
.command("status <id>")
|
|
4363
4622
|
.description("Show a job's detail (+ output.log / result.json paths for headless jobs).")
|
|
@@ -4638,6 +4897,23 @@ export async function main(argv) {
|
|
|
4638
4897
|
if (effectiveCap < cap) {
|
|
4639
4898
|
process.stderr.write(`[h2a] conduct: AIMD breaker — admitting up to ${effectiveCap}/${cap} (rate-limit pressure)\n`);
|
|
4640
4899
|
}
|
|
4900
|
+
// (a'') SESSION LEASES — beat what is working, and REPORT what has stopped.
|
|
4901
|
+
// A finished-but-alive session is invisible to (a): it is `isJobLive`, so
|
|
4902
|
+
// reconcile and the stale sweep both skip it, and it keeps its slot for as
|
|
4903
|
+
// long as it stays alive. The lease pass is the only thing here that reads
|
|
4904
|
+
// inactivity rather than death. It proposes; it never frees a slot itself.
|
|
4905
|
+
try {
|
|
4906
|
+
const supervision = superviseSessionLeases(refreshed);
|
|
4907
|
+
for (const proposal of supervision.proposals) {
|
|
4908
|
+
process.stderr.write(`[h2a] conduct: ${proposal.sessionId} has held its slot without working for ` +
|
|
4909
|
+
`${formatIdle(proposal.idleMs)} (holder ${proposal.holder}, TTL ${formatIdle(proposal.ttlMs)}). ` +
|
|
4910
|
+
`NOT killed — inspect it with: h2a jobs attach ${proposal.sessionId} ` +
|
|
4911
|
+
`free the slot with: h2a jobs reclaim ${proposal.sessionId}\n`);
|
|
4912
|
+
}
|
|
4913
|
+
}
|
|
4914
|
+
catch {
|
|
4915
|
+
// the lease store is advisory: a failure here must not stop the conductor
|
|
4916
|
+
}
|
|
4641
4917
|
// (b) start pending jobs under the EFFECTIVE cap (oldest-first FIFO).
|
|
4642
4918
|
let started = 0;
|
|
4643
4919
|
for (const id of planNextStarts(refreshed, effectiveCap)) {
|
|
@@ -5112,42 +5388,105 @@ export async function main(argv) {
|
|
|
5112
5388
|
process.stderr.write("[h2a] wake-request watch stopped\n");
|
|
5113
5389
|
});
|
|
5114
5390
|
// ---------------------------------------------------------------------------
|
|
5115
|
-
// relaunch —
|
|
5391
|
+
// relaunch — recover idle sessions, or explicitly force-recreate one/all
|
|
5116
5392
|
// ---------------------------------------------------------------------------
|
|
5117
5393
|
program
|
|
5118
5394
|
.command("relaunch [filter]")
|
|
5119
|
-
.description("
|
|
5395
|
+
.description("Dry-run local session recovery by default. With an exact [filter], --apply force-recreates that one session from its own registry conversation. --all force-recreates managed sessions only after a warning and typed confirmation (or --yes); interactive agent CLIs stay excluded unless --include-agents is explicit.")
|
|
5120
5396
|
.option("--apply", "actually relaunch (default: dry-run, just print the plan)")
|
|
5121
|
-
.
|
|
5397
|
+
.option("--all", "force-restart every managed local tmux session")
|
|
5398
|
+
.option("--yes", "confirm --all non-interactively (requires --apply)")
|
|
5399
|
+
.option("--include-agents", "with --all, include claude/codex/agy sessions that are excluded by default")
|
|
5400
|
+
.action(async (filter, opts) => {
|
|
5122
5401
|
if (!tmuxAvailable()) {
|
|
5123
5402
|
process.stderr.write("[h2a] tmux is not installed locally\n");
|
|
5124
5403
|
process.exitCode = 1;
|
|
5125
5404
|
return;
|
|
5126
5405
|
}
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
convByTmuxSession.set(e.tmuxSession, e.convId);
|
|
5406
|
+
if (filter && opts.all) {
|
|
5407
|
+
process.stderr.write("[h2a] relaunch accepts either one exact session name or --all, not both.\n");
|
|
5408
|
+
process.exitCode = 1;
|
|
5409
|
+
return;
|
|
5410
|
+
}
|
|
5411
|
+
const inventory = listLocalSessionsWithDiagnostics();
|
|
5412
|
+
if (!inventory.known) {
|
|
5413
|
+
process.stderr.write(`[h2a] cannot safely relaunch: managed tmux inventory is unknown (${inventory.reason ?? "no detail"}).\n`);
|
|
5414
|
+
process.exitCode = 1;
|
|
5415
|
+
return;
|
|
5138
5416
|
}
|
|
5139
|
-
|
|
5417
|
+
let sessions = inventory.sessions;
|
|
5418
|
+
if (filter) {
|
|
5419
|
+
const exactName = parseManagedSessionName(filter) ? filter : undefined;
|
|
5420
|
+
const matches = exactName
|
|
5421
|
+
? sessions.filter((session) => session.name === exactName)
|
|
5422
|
+
: sessions.filter((session) => session.slug === filter);
|
|
5423
|
+
if (matches.length !== 1) {
|
|
5424
|
+
const detail = matches.length
|
|
5425
|
+
? `ambiguous (${matches.map((session) => session.name).join(", ")})`
|
|
5426
|
+
: "not found";
|
|
5427
|
+
process.stderr.write(`[h2a] cannot force-relaunch ${filter}: exact managed session is ${detail}.\n`);
|
|
5428
|
+
process.exitCode = 1;
|
|
5429
|
+
return;
|
|
5430
|
+
}
|
|
5431
|
+
sessions = matches;
|
|
5432
|
+
}
|
|
5433
|
+
const forced = Boolean(filter || opts.all);
|
|
5434
|
+
const registry = loadRegistryWithDiagnostics();
|
|
5435
|
+
if (forced && !registry.known) {
|
|
5436
|
+
process.stderr.write(`[h2a] cannot safely force-relaunch: registry is unknown (${registry.reason ?? "no detail"}).\n`);
|
|
5437
|
+
process.exitCode = 1;
|
|
5438
|
+
return;
|
|
5439
|
+
}
|
|
5440
|
+
const registryEntries = registry.entries;
|
|
5441
|
+
const resolution = forced
|
|
5442
|
+
? reconcileRunConvIds(registryEntries, (cwd, convId) => readConversationCustomTitle(homedir(), cwd, convId))
|
|
5443
|
+
: undefined;
|
|
5444
|
+
const entryByTmuxSession = new Map();
|
|
5140
5445
|
const plan = planRelaunch(sessions.map((s) => ({
|
|
5446
|
+
...(() => {
|
|
5447
|
+
const matches = registryEntriesForLocalTmuxTarget(s.name, s, registryEntries);
|
|
5448
|
+
const ids = new Set(matches.map((entry) => entry.id));
|
|
5449
|
+
const entry = matches.length === 1 && ids.size === 1
|
|
5450
|
+
? matches
|
|
5451
|
+
.slice()
|
|
5452
|
+
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))[0]
|
|
5453
|
+
: undefined;
|
|
5454
|
+
if (entry)
|
|
5455
|
+
entryByTmuxSession.set(s.name, entry);
|
|
5456
|
+
const resolvedConvId = entry
|
|
5457
|
+
? resolution?.resolvedSid.get(entry.id) ?? entry.convId
|
|
5458
|
+
: undefined;
|
|
5459
|
+
const labelOnly = entry !== undefined &&
|
|
5460
|
+
(!resolvedConvId ||
|
|
5461
|
+
resolvedConvId === entry.id ||
|
|
5462
|
+
resolvedConvId === entry.label);
|
|
5463
|
+
return {
|
|
5464
|
+
profile: entry?.tool ?? s.profile,
|
|
5465
|
+
...(matches.length > 1 || ids.size > 1
|
|
5466
|
+
? { unresumableReason: "multiple registry rows match this tmux session — relaunch refused" }
|
|
5467
|
+
: !entry
|
|
5468
|
+
? { unresumableReason: "no matching registry conversation — relaunch refused" }
|
|
5469
|
+
: entry.sessionClass === undefined
|
|
5470
|
+
? { unresumableReason: "registry session class is missing — relaunch refused" }
|
|
5471
|
+
: labelOnly || resolution?.unresolvedRunIds.has(entry.id)
|
|
5472
|
+
? { unresumableReason: "no resumable conversation in the registry — relaunch refused" }
|
|
5473
|
+
: resolvedConvId !== undefined
|
|
5474
|
+
? { convId: resolvedConvId }
|
|
5475
|
+
: { unresumableReason: "no resumable conversation in the registry — relaunch refused" }),
|
|
5476
|
+
};
|
|
5477
|
+
})(),
|
|
5141
5478
|
slug: s.slug,
|
|
5142
5479
|
name: s.name,
|
|
5143
|
-
profile: s.profile,
|
|
5144
5480
|
idle: localSessionIdle(s.name),
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
})
|
|
5481
|
+
})), {
|
|
5482
|
+
...(forced ? { force: true } : {}),
|
|
5483
|
+
...(opts.all && !opts.includeAgents
|
|
5484
|
+
? { excludeInteractiveAgents: true }
|
|
5485
|
+
: {}),
|
|
5486
|
+
});
|
|
5487
|
+
if (opts.all) {
|
|
5488
|
+
process.stderr.write(`[h2a] WARNING: force-restart plan covers ${sessions.length} managed session(s): ${sessions.map((session) => session.name).join(", ") || "none"}\n`);
|
|
5489
|
+
}
|
|
5151
5490
|
if (plan.actions.length === 0) {
|
|
5152
5491
|
process.stderr.write(`[h2a] nothing to relaunch${filter ? ` matching "${filter}"` : ""} (${plan.skipped.length} skipped)\n`);
|
|
5153
5492
|
for (const s of plan.skipped) {
|
|
@@ -5156,7 +5495,7 @@ export async function main(argv) {
|
|
|
5156
5495
|
return;
|
|
5157
5496
|
}
|
|
5158
5497
|
if (!opts.apply) {
|
|
5159
|
-
process.stderr.write(`[h2a] would relaunch ${plan.actions.length} session(s) — dry-run, pass --apply:\n`);
|
|
5498
|
+
process.stderr.write(`[h2a] would${forced ? " force-restart" : " relaunch"} ${plan.actions.length} session(s) — dry-run, pass --apply:\n`);
|
|
5160
5499
|
for (const a of plan.actions) {
|
|
5161
5500
|
process.stderr.write(` ${a.slug}: ${a.cmd}\n`);
|
|
5162
5501
|
}
|
|
@@ -5165,17 +5504,109 @@ export async function main(argv) {
|
|
|
5165
5504
|
}
|
|
5166
5505
|
return;
|
|
5167
5506
|
}
|
|
5507
|
+
// Backward-compatible crash recovery: bare `relaunch --apply` only
|
|
5508
|
+
// retypes into idle shells. Forced kill/recreate is available solely for
|
|
5509
|
+
// one exact target or explicit `--all`.
|
|
5510
|
+
if (!forced) {
|
|
5511
|
+
let ok = 0;
|
|
5512
|
+
for (const action of plan.actions) {
|
|
5513
|
+
if (relaunchInSession(action.name, action.cmd)) {
|
|
5514
|
+
ok += 1;
|
|
5515
|
+
process.stderr.write(`[h2a] relaunched ${action.slug}: ${action.cmd}\n`);
|
|
5516
|
+
}
|
|
5517
|
+
else {
|
|
5518
|
+
process.stderr.write(`[h2a] FAILED to relaunch ${action.slug}\n`);
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
process.stderr.write(`[h2a] relaunched ${ok}/${plan.actions.length}${plan.skipped.length ? `, ${plan.skipped.length} skipped` : ""}\n`);
|
|
5522
|
+
if (ok !== plan.actions.length)
|
|
5523
|
+
process.exitCode = 1;
|
|
5524
|
+
return;
|
|
5525
|
+
}
|
|
5526
|
+
if (opts.all && !opts.yes && !(await confirmRelaunchAll())) {
|
|
5527
|
+
process.stderr.write("[h2a] bulk force-restart requires typing RELAUNCH, or pass --yes for non-interactive use. No session was changed.\n");
|
|
5528
|
+
process.exitCode = 1;
|
|
5529
|
+
return;
|
|
5530
|
+
}
|
|
5531
|
+
for (const skipped of plan.skipped) {
|
|
5532
|
+
process.stderr.write(`[h2a] skipped ${skipped.slug}: ${skipped.reason}\n`);
|
|
5533
|
+
}
|
|
5534
|
+
if (filter) {
|
|
5535
|
+
process.stderr.write(`[h2a] confirmed force-restart of ${plan.actions[0].slug}; its tmux session will be replaced.\n`);
|
|
5536
|
+
}
|
|
5537
|
+
const ready = [];
|
|
5538
|
+
let preflightFailed = false;
|
|
5539
|
+
for (const action of plan.actions) {
|
|
5540
|
+
const entry = entryByTmuxSession.get(action.name);
|
|
5541
|
+
const sessionClass = entry?.sessionClass;
|
|
5542
|
+
if (!entry || sessionClass === undefined) {
|
|
5543
|
+
preflightFailed = true;
|
|
5544
|
+
process.stderr.write(`[h2a] cannot force-restart ${action.slug}: its registry launch context changed; no session was killed.\n`);
|
|
5545
|
+
continue;
|
|
5546
|
+
}
|
|
5547
|
+
const allowed = await guardConvWriters({
|
|
5548
|
+
convId: action.convId,
|
|
5549
|
+
cwd: entry.cwd,
|
|
5550
|
+
excludeId: entry.id,
|
|
5551
|
+
fetchRemoteSessions: async () => {
|
|
5552
|
+
const url = getConfiguredRemoteOptional();
|
|
5553
|
+
return url ? await listRemoteSessions(url) : [];
|
|
5554
|
+
},
|
|
5555
|
+
});
|
|
5556
|
+
if (!allowed) {
|
|
5557
|
+
preflightFailed = true;
|
|
5558
|
+
continue;
|
|
5559
|
+
}
|
|
5560
|
+
ready.push({ action, entry, sessionClass });
|
|
5561
|
+
}
|
|
5562
|
+
const rechecked = listLocalSessionsWithDiagnostics();
|
|
5563
|
+
if (!rechecked.known) {
|
|
5564
|
+
process.stderr.write(`[h2a] cannot safely force-restart: managed tmux inventory changed (${rechecked.reason ?? "no detail"}). No session was killed.\n`);
|
|
5565
|
+
process.exitCode = 1;
|
|
5566
|
+
return;
|
|
5567
|
+
}
|
|
5568
|
+
for (const { action } of ready) {
|
|
5569
|
+
if (!rechecked.sessions.some((session) => session.name === action.name)) {
|
|
5570
|
+
preflightFailed = true;
|
|
5571
|
+
process.stderr.write(`[h2a] cannot force-restart ${action.slug}: its tmux session changed before replacement. No session was killed.\n`);
|
|
5572
|
+
}
|
|
5573
|
+
}
|
|
5574
|
+
if (preflightFailed) {
|
|
5575
|
+
process.stderr.write("[h2a] force-restart aborted before any session was killed.\n");
|
|
5576
|
+
process.exitCode = 1;
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5168
5579
|
let ok = 0;
|
|
5169
|
-
for (const
|
|
5170
|
-
if (
|
|
5580
|
+
for (const { action, entry, sessionClass } of ready) {
|
|
5581
|
+
if (!killLocalSession(action.name)) {
|
|
5582
|
+
process.stderr.write(`[h2a] FAILED to force-restart ${action.slug}: tmux session ${action.name} could not be killed.\n`);
|
|
5583
|
+
continue;
|
|
5584
|
+
}
|
|
5585
|
+
try {
|
|
5586
|
+
const gatewayMode = gatewayModeForProfile(action.profile, entry.gatewayMode ?? "auto");
|
|
5587
|
+
await injectLlmMeshGatewayEnv(gatewayMode, true, localSessionName(action.slug));
|
|
5588
|
+
const { name } = startLocalSession(action.profile, action.command, entry.cwd, action.args, action.slug, undefined, { sessionClass });
|
|
5589
|
+
enrollFromRun({
|
|
5590
|
+
profile: action.profile,
|
|
5591
|
+
slug: action.slug,
|
|
5592
|
+
tmuxSession: name,
|
|
5593
|
+
cwd: entry.cwd,
|
|
5594
|
+
sessionClass,
|
|
5595
|
+
convId: action.convId,
|
|
5596
|
+
...(entry.gatewayMode !== undefined
|
|
5597
|
+
? { gatewayMode: entry.gatewayMode }
|
|
5598
|
+
: {}),
|
|
5599
|
+
});
|
|
5171
5600
|
ok += 1;
|
|
5172
|
-
process.stderr.write(`[h2a]
|
|
5601
|
+
process.stderr.write(`[h2a] force-restarted ${action.slug}: ${action.cmd}\n`);
|
|
5173
5602
|
}
|
|
5174
|
-
|
|
5175
|
-
process.stderr.write(`[h2a] FAILED to
|
|
5603
|
+
catch (error) {
|
|
5604
|
+
process.stderr.write(`[h2a] FAILED to force-restart ${action.slug} after killing ${action.name}: ${error.message}\n`);
|
|
5176
5605
|
}
|
|
5177
5606
|
}
|
|
5178
|
-
process.stderr.write(`[h2a]
|
|
5607
|
+
process.stderr.write(`[h2a] force-restarted ${ok}/${plan.actions.length}${plan.skipped.length ? `, ${plan.skipped.length} skipped` : ""}\n`);
|
|
5608
|
+
if (ok !== plan.actions.length)
|
|
5609
|
+
process.exitCode = 1;
|
|
5179
5610
|
});
|
|
5180
5611
|
// ---------------------------------------------------------------------------
|
|
5181
5612
|
// Reliability slice 2 (throttle phase 2) — auto-resume RATE-LIMITED INTERACTIVE
|
|
@@ -5629,6 +6060,7 @@ export async function main(argv) {
|
|
|
5629
6060
|
.option("--exec", "force exec-into-Pod-tmux attach (this is the DEFAULT when a tunnel is configured: UTF-8, native scrollback + copy)")
|
|
5630
6061
|
.option("--ws", "force the legacy WS/SSE attach (control-plane proxy) instead of exec")
|
|
5631
6062
|
.option("--local", "force local tmux lookup")
|
|
6063
|
+
.option("--headless-terminal", "attach a persistent 160x48 PTY client without taking over this terminal (local sessions only)")
|
|
5632
6064
|
.action(async (first, second, opts) => {
|
|
5633
6065
|
// Local tmux session? (unless an explicit URL/sessionId pair is given).
|
|
5634
6066
|
if (second === undefined && !looksLikeUrl(first)) {
|
|
@@ -5653,6 +6085,16 @@ export async function main(argv) {
|
|
|
5653
6085
|
process.exitCode = 1;
|
|
5654
6086
|
return;
|
|
5655
6087
|
}
|
|
6088
|
+
if (opts.headlessTerminal) {
|
|
6089
|
+
const terminal = ensureHeadlessTerminal(localName);
|
|
6090
|
+
if (terminal.state === "unavailable") {
|
|
6091
|
+
process.stderr.write(`[h2a] could not attach a persistent terminal to ${first}: ${terminal.reason}\n`);
|
|
6092
|
+
process.exitCode = 1;
|
|
6093
|
+
return;
|
|
6094
|
+
}
|
|
6095
|
+
process.stderr.write(`[h2a] persistent terminal ${terminal.state} for ${first} (${terminal.attachedClients} attached client(s))\n`);
|
|
6096
|
+
return;
|
|
6097
|
+
}
|
|
5656
6098
|
process.exitCode = attachLocalSession(localName);
|
|
5657
6099
|
return;
|
|
5658
6100
|
}
|