@sentropic/h2a-runtime 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/attach.d.ts +21 -0
  2. package/dist/attach.d.ts.map +1 -1
  3. package/dist/attach.js +69 -1
  4. package/dist/attach.js.map +1 -1
  5. package/dist/file-lock.d.ts +34 -0
  6. package/dist/file-lock.d.ts.map +1 -0
  7. package/dist/file-lock.js +96 -0
  8. package/dist/file-lock.js.map +1 -0
  9. package/dist/index.d.ts +30 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +289 -18
  12. package/dist/index.js.map +1 -1
  13. package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -1
  14. package/dist/llm-gateway-runtime/accounts.js +3 -0
  15. package/dist/llm-gateway-runtime/accounts.js.map +1 -1
  16. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -1
  17. package/dist/llm-gateway-runtime/proxy-anthropic.js +24 -0
  18. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -1
  19. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -1
  20. package/dist/llm-gateway-runtime/proxy-openai.js +53 -2
  21. package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -1
  22. package/dist/proc-cpu.d.ts +55 -0
  23. package/dist/proc-cpu.d.ts.map +1 -0
  24. package/dist/proc-cpu.js +133 -0
  25. package/dist/proc-cpu.js.map +1 -0
  26. package/dist/prompt-delivery.d.ts +159 -0
  27. package/dist/prompt-delivery.d.ts.map +1 -0
  28. package/dist/prompt-delivery.js +469 -0
  29. package/dist/prompt-delivery.js.map +1 -0
  30. package/dist/registry.d.ts.map +1 -1
  31. package/dist/registry.js +7 -69
  32. package/dist/registry.js.map +1 -1
  33. package/dist/restore.d.ts +20 -4
  34. package/dist/restore.d.ts.map +1 -1
  35. package/dist/restore.js +26 -4
  36. package/dist/restore.js.map +1 -1
  37. package/dist/session-lease.d.ts +266 -0
  38. package/dist/session-lease.d.ts.map +1 -0
  39. package/dist/session-lease.js +342 -0
  40. package/dist/session-lease.js.map +1 -0
  41. package/dist/tmux.d.ts +77 -2
  42. package/dist/tmux.d.ts.map +1 -1
  43. package/dist/tmux.js +269 -23
  44. package/dist/tmux.js.map +1 -1
  45. 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, 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, readLastLayout, restore as restoreLayout, } from "./restore.js";
25
26
  import { getLayoutConfig } from "./config.js";
26
27
  import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, 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
@@ -1327,12 +1432,18 @@ function registryEntryForResumeTarget(target, local) {
1327
1432
  ? [target]
1328
1433
  : managedSessionCandidates(canonicalSlug);
1329
1434
  const matches = loadRegistry().filter((e) => {
1330
- // Resume is a human-facing operation, not a promotion path. Keep the
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")
1435
+ // Resume is a human-facing operation, not a promotion path: a delegated job
1436
+ // or an explicit background launch must never become resumable here.
1437
+ //
1438
+ // The class test is SHARED with restore rather than written inline, because
1439
+ // written inline it was an opt-in on an explicit class and every record
1440
+ // enrolled before the class existed failed closed — measured as "cannot
1441
+ // resume <slug>: registry has no human session" on entries that are plainly
1442
+ // human sessions. Two gates judging the same question in two ways is how one
1443
+ // of them ends up wrong on its own.
1444
+ if (e.role !== undefined || e.kind !== "local-tmux")
1445
+ return false;
1446
+ if (!isHumanFacingSession(e))
1336
1447
  return false;
1337
1448
  // Full managed names are exact targets; never reinterpret one as an id
1338
1449
  // or label that happens to share a prefix-shaped string.
@@ -3408,7 +3519,7 @@ export async function main(argv) {
3408
3519
  }
3409
3520
  }
3410
3521
  gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, localSessionName(resumeSlug));
3411
- const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human" });
3522
+ const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human", attachedTerminal: true });
3412
3523
  enrollFromRun({
3413
3524
  profile,
3414
3525
  slug: resumeSlug,
@@ -3432,6 +3543,7 @@ export async function main(argv) {
3432
3543
  .command("run <profile> [path]")
3433
3544
  .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
3545
  .option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
3546
+ .option("--no-attached-terminal", "disable the persistent headless tmux client (legacy compatibility; cannot be used with --prompt-stdin)")
3435
3547
  .option("-r, --resume <convId>", "resume a specific conversation in the CLI")
3436
3548
  .option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
3437
3549
  .option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
@@ -3485,6 +3597,11 @@ export async function main(argv) {
3485
3597
  process.exitCode = 2;
3486
3598
  return;
3487
3599
  }
3600
+ if (opts.promptStdin && opts.attachedTerminal === false) {
3601
+ process.stderr.write("[h2a] --prompt-stdin requires the attached terminal; remove --no-attached-terminal.\n");
3602
+ process.exitCode = 2;
3603
+ return;
3604
+ }
3488
3605
  if (!tmuxAvailable()) {
3489
3606
  process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
3490
3607
  process.exitCode = 1;
@@ -3673,6 +3790,7 @@ export async function main(argv) {
3673
3790
  let resultJson;
3674
3791
  let agentPane;
3675
3792
  let promptFile;
3793
+ let promptDelivery;
3676
3794
  if (opts.headless) {
3677
3795
  const runDir = join(cwd, ".h2a", "runs", label);
3678
3796
  mkdirSync(runDir, { recursive: true });
@@ -3690,6 +3808,7 @@ export async function main(argv) {
3690
3808
  ? { terminateOnAgentExit: true }
3691
3809
  : {}),
3692
3810
  ...(structuredLaunch ? { refuseExisting: true } : {}),
3811
+ attachedTerminal: opts.attachedTerminal !== false,
3693
3812
  sessionClass,
3694
3813
  }));
3695
3814
  }
@@ -3725,18 +3844,54 @@ export async function main(argv) {
3725
3844
  process.stderr.write(`[h2a] h2a window started in ${slug} (${h2a.command})\n`);
3726
3845
  }
3727
3846
  }
3728
- if (!opts.headless &&
3729
- initialPrompt !== undefined &&
3730
- !sendKeysLiteral(agentPane, initialPrompt)) {
3731
- cleanupHeadlessPromptFile(promptFile);
3732
- killLocalSession(name);
3733
- process.stderr.write(`[h2a] failed to deliver the initial prompt to ${slug}; the partial session was stopped\n`);
3734
- process.exitCode = 1;
3735
- return;
3847
+ if (!opts.headless && initialPrompt !== undefined) {
3848
+ // NEVER paste-and-hope: `tmux new-session -d` returns before the
3849
+ // agent TUI draws anything, and a prompt sent into that window is
3850
+ // echoed by the raw terminal and then discarded. Delivery is
3851
+ // OBSERVED — brief seen in a real composer, then real work — because
3852
+ // reporting "started" over a lost brief is what left a lane inert
3853
+ // for 33 minutes on 2026-07-28.
3854
+ promptDelivery = deliverInitialPrompt(agentPane, initialPrompt, {
3855
+ capturePane: capturePaneVisible,
3856
+ clearComposer: clearPaneComposer,
3857
+ pasteBlock: pasteLiteralBlock,
3858
+ submit: submitPane,
3859
+ cpuMs: paneTreeCpuMs,
3860
+ sleep: sleepSync,
3861
+ now: () => Date.now(),
3862
+ });
3863
+ if (promptDelivery.state !== "working") {
3864
+ cleanupHeadlessPromptFile(promptFile);
3865
+ // Only claim the session is gone when the kill actually reported
3866
+ // success. "nothing is left running" over an unchecked call is the
3867
+ // same wider-than-its-evidence claim this change exists to remove,
3868
+ // and a surviving partial session is precisely the idle lane the
3869
+ // operator was just told could not happen.
3870
+ const stopped = killLocalSession(name);
3871
+ const detail = promptDelivery.state === "submitted-idle"
3872
+ ? `the brief was submitted but the agent never started working ` +
3873
+ `(${Math.round(promptDelivery.cpuDeltaMs)}ms of CPU): it is not "started"`
3874
+ : promptDelivery.reason;
3875
+ process.stderr.write(`[h2a] the initial prompt did not reach ${slug}: ${detail}\n` +
3876
+ (promptDelivery.state === "host-modal"
3877
+ ? `[h2a] fix: ${promptDelivery.hint}\n`
3878
+ : "") +
3879
+ (stopped
3880
+ ? `[h2a] the partial session was stopped, so nothing is left running idle\n`
3881
+ : `[h2a] WARNING: stopping the partial session FAILED — ${name} may still be alive; ` +
3882
+ `list it with h2a ls and stop it with h2a stop ${slug} --reason failed-launch\n`) +
3883
+ (promptDelivery.state !== "submitted-idle" &&
3884
+ promptDelivery.capture
3885
+ ? `[h2a] last screen:\n${promptDelivery.capture}\n`
3886
+ : ""));
3887
+ process.exitCode = 1;
3888
+ return;
3889
+ }
3736
3890
  }
3737
3891
  const pid = agentPane
3738
3892
  ? localSessionPanePid(agentPane)
3739
3893
  : undefined;
3894
+ const workerPid = agentPane !== undefined ? paneWorkerPid(agentPane) : undefined;
3740
3895
  if (structuredLaunch && pid === undefined) {
3741
3896
  cleanupHeadlessPromptFile(promptFile);
3742
3897
  killLocalSession(name);
@@ -3752,6 +3907,7 @@ export async function main(argv) {
3752
3907
  slug,
3753
3908
  tmuxSession: name,
3754
3909
  ...(pid !== undefined ? { pid } : {}),
3910
+ ...(workerPid !== undefined ? { workerPid } : {}),
3755
3911
  cwd,
3756
3912
  sessionClass,
3757
3913
  ...(opts.resume !== undefined ? { convId: opts.resume } : {}),
@@ -3765,6 +3921,7 @@ export async function main(argv) {
3765
3921
  ...(pid !== undefined ? { pid } : {}),
3766
3922
  ...(outputLog !== undefined ? { outputLog } : {}),
3767
3923
  ...(resultJson !== undefined ? { resultJson } : {}),
3924
+ ...(promptDelivery !== undefined ? { promptDelivery } : {}),
3768
3925
  });
3769
3926
  }
3770
3927
  if (count > 1) {
@@ -3792,10 +3949,28 @@ export async function main(argv) {
3792
3949
  h2aSidecar: only.h2aSidecar,
3793
3950
  ...(only.pane !== undefined ? { pane: only.pane } : {}),
3794
3951
  ...(only.pid !== undefined ? { pid: only.pid } : {}),
3952
+ // `pid` is the launch WRAPPER. Measured 2026-07-29: it showed 0s
3953
+ // of CPU after 37s while its codex child had burned 13s, so a
3954
+ // liveness check on `pid` alone reports a working agent as dead.
3955
+ // Name the process that actually works, and say it is distinct.
3956
+ ...(only.workerPid !== undefined
3957
+ ? { workerPid: only.workerPid }
3958
+ : {}),
3795
3959
  },
3796
3960
  attach: opts.headless
3797
3961
  ? null
3798
3962
  : { command: "h2a", args: ["attach", only.slug] },
3963
+ // What was OBSERVED, not what was attempted: the brief was seen in
3964
+ // a real composer, submitted, and the agent's CPU then moved.
3965
+ ...(only.promptDelivery?.state === "working"
3966
+ ? {
3967
+ prompt: {
3968
+ delivered: true,
3969
+ waitedMs: only.promptDelivery.waitedMs,
3970
+ cpuDeltaMs: Math.round(only.promptDelivery.cpuDeltaMs),
3971
+ },
3972
+ }
3973
+ : {}),
3799
3974
  ...(only.outputLog !== undefined
3800
3975
  ? {
3801
3976
  logs: { path: only.outputLog },
@@ -4358,6 +4533,74 @@ export async function main(argv) {
4358
4533
  if (claudeWarn)
4359
4534
  process.stderr.write(`${claudeWarn}\n`);
4360
4535
  });
4536
+ jobsCommand
4537
+ .command("leases")
4538
+ .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.")
4539
+ .option("--probe", "before reporting, run one supervising pass: beat the leases of sessions whose pane is burning CPU, record the others")
4540
+ .action((opts) => {
4541
+ const now = new Date().toISOString();
4542
+ if (opts.probe === true)
4543
+ superviseSessionLeases(listJobs(), now);
4544
+ const leases = sessionLeaseStore().readAll();
4545
+ if (leases.length === 0) {
4546
+ process.stdout.write("no session leases\n");
4547
+ return;
4548
+ }
4549
+ const nowMs = Date.parse(now);
4550
+ const rows = leases
4551
+ .slice()
4552
+ .sort((a, b) => Date.parse(a.heartbeatAt) - Date.parse(b.heartbeatAt))
4553
+ .map((lease) => {
4554
+ const idle = nowMs - Date.parse(lease.heartbeatAt);
4555
+ const state = isSessionLeaseAbandoned(lease, now) ? "RECLAIMABLE" : "held";
4556
+ return (`${lease.sessionId.padEnd(24)} ${state.padEnd(12)} ` +
4557
+ `idle ${formatIdle(idle).padEnd(7)} ttl ${formatIdle(lease.ttlMs).padEnd(6)} ` +
4558
+ `holder ${lease.holder}`);
4559
+ });
4560
+ process.stdout.write(`${rows.join("\n")}\n`);
4561
+ const reclaimable = reclaimProposals(leases, now).length;
4562
+ if (reclaimable > 0) {
4563
+ process.stderr.write(`[h2a] ${reclaimable} slot(s) reclaimable — free one with: h2a jobs reclaim <id> ` +
4564
+ "(the session's tmux window is left running and can still be attached)\n");
4565
+ }
4566
+ });
4567
+ jobsCommand
4568
+ .command("reclaim <id>")
4569
+ .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.")
4570
+ .action((id) => {
4571
+ const now = new Date().toISOString();
4572
+ const store = sessionLeaseStore();
4573
+ const lease = store.forSession(id);
4574
+ if (!lease) {
4575
+ process.stderr.write(`[h2a] no session lease for "${id}" — nothing to reclaim (see: h2a jobs leases)\n`);
4576
+ process.exitCode = 1;
4577
+ return;
4578
+ }
4579
+ // The one guard that matters: a session still proving it works keeps its
4580
+ // slot, whatever the operator believes. Reclaiming is not arbitration.
4581
+ if (!isSessionLeaseAbandoned(lease, now)) {
4582
+ const idle = Date.parse(now) - Date.parse(lease.heartbeatAt);
4583
+ process.stderr.write(`[h2a] refusing to reclaim ${id}: its lease still beats (last proof of work ` +
4584
+ `${formatIdle(idle)} ago, TTL ${formatIdle(lease.ttlMs)}). It is working.\n`);
4585
+ process.exitCode = 1;
4586
+ return;
4587
+ }
4588
+ const job = listJobs().find((e) => e.id === id);
4589
+ const advanced = job ? advanceJob(id, "failed") : undefined;
4590
+ try {
4591
+ store.release({ sessionId: id, token: lease.token });
4592
+ }
4593
+ catch (err) {
4594
+ process.stderr.write(`[h2a] lease release failed: ${err.message}\n`);
4595
+ }
4596
+ process.stdout.write(`reclaimed ${id} — slot freed after ` +
4597
+ `${formatIdle(Date.parse(now) - Date.parse(lease.heartbeatAt))} without work\n`);
4598
+ process.stderr.write(`[h2a] the session was NOT killed: ${job?.tmuxSession ?? "its tmux session"} is still running ` +
4599
+ `(h2a jobs attach ${id}). ` +
4600
+ (advanced === undefined
4601
+ ? "No registry entry to advance — only the lease was released.\n"
4602
+ : "The job is recorded terminal so the conductor can admit queued work.\n"));
4603
+ });
4361
4604
  jobsCommand
4362
4605
  .command("status <id>")
4363
4606
  .description("Show a job's detail (+ output.log / result.json paths for headless jobs).")
@@ -4638,6 +4881,23 @@ export async function main(argv) {
4638
4881
  if (effectiveCap < cap) {
4639
4882
  process.stderr.write(`[h2a] conduct: AIMD breaker — admitting up to ${effectiveCap}/${cap} (rate-limit pressure)\n`);
4640
4883
  }
4884
+ // (a'') SESSION LEASES — beat what is working, and REPORT what has stopped.
4885
+ // A finished-but-alive session is invisible to (a): it is `isJobLive`, so
4886
+ // reconcile and the stale sweep both skip it, and it keeps its slot for as
4887
+ // long as it stays alive. The lease pass is the only thing here that reads
4888
+ // inactivity rather than death. It proposes; it never frees a slot itself.
4889
+ try {
4890
+ const supervision = superviseSessionLeases(refreshed);
4891
+ for (const proposal of supervision.proposals) {
4892
+ process.stderr.write(`[h2a] conduct: ${proposal.sessionId} has held its slot without working for ` +
4893
+ `${formatIdle(proposal.idleMs)} (holder ${proposal.holder}, TTL ${formatIdle(proposal.ttlMs)}). ` +
4894
+ `NOT killed — inspect it with: h2a jobs attach ${proposal.sessionId} ` +
4895
+ `free the slot with: h2a jobs reclaim ${proposal.sessionId}\n`);
4896
+ }
4897
+ }
4898
+ catch {
4899
+ // the lease store is advisory: a failure here must not stop the conductor
4900
+ }
4641
4901
  // (b) start pending jobs under the EFFECTIVE cap (oldest-first FIFO).
4642
4902
  let started = 0;
4643
4903
  for (const id of planNextStarts(refreshed, effectiveCap)) {
@@ -5629,6 +5889,7 @@ export async function main(argv) {
5629
5889
  .option("--exec", "force exec-into-Pod-tmux attach (this is the DEFAULT when a tunnel is configured: UTF-8, native scrollback + copy)")
5630
5890
  .option("--ws", "force the legacy WS/SSE attach (control-plane proxy) instead of exec")
5631
5891
  .option("--local", "force local tmux lookup")
5892
+ .option("--headless-terminal", "attach a persistent 160x48 PTY client without taking over this terminal (local sessions only)")
5632
5893
  .action(async (first, second, opts) => {
5633
5894
  // Local tmux session? (unless an explicit URL/sessionId pair is given).
5634
5895
  if (second === undefined && !looksLikeUrl(first)) {
@@ -5653,6 +5914,16 @@ export async function main(argv) {
5653
5914
  process.exitCode = 1;
5654
5915
  return;
5655
5916
  }
5917
+ if (opts.headlessTerminal) {
5918
+ const terminal = ensureHeadlessTerminal(localName);
5919
+ if (terminal.state === "unavailable") {
5920
+ process.stderr.write(`[h2a] could not attach a persistent terminal to ${first}: ${terminal.reason}\n`);
5921
+ process.exitCode = 1;
5922
+ return;
5923
+ }
5924
+ process.stderr.write(`[h2a] persistent terminal ${terminal.state} for ${first} (${terminal.attachedClients} attached client(s))\n`);
5925
+ return;
5926
+ }
5656
5927
  process.exitCode = attachLocalSession(localName);
5657
5928
  return;
5658
5929
  }