@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.
Files changed (49) 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 +492 -50
  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/relaunch.d.ts +20 -1
  34. package/dist/relaunch.d.ts.map +1 -1
  35. package/dist/relaunch.js +31 -10
  36. package/dist/relaunch.js.map +1 -1
  37. package/dist/restore.d.ts +20 -4
  38. package/dist/restore.d.ts.map +1 -1
  39. package/dist/restore.js +26 -4
  40. package/dist/restore.js.map +1 -1
  41. package/dist/session-lease.d.ts +266 -0
  42. package/dist/session-lease.d.ts.map +1 -0
  43. package/dist/session-lease.js +342 -0
  44. package/dist/session-lease.js.map +1 -0
  45. package/dist/tmux.d.ts +77 -2
  46. package/dist/tmux.d.ts.map +1 -1
  47. package/dist/tmux.js +269 -23
  48. package/dist/tmux.js.map +1 -1
  49. 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 registryEntryForResumeTarget(target, local) {
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
- 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")
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
- 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;
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 — bring idle local sessions back in situ, each resuming its OWN conv
5391
+ // relaunch — recover idle sessions, or explicitly force-recreate one/all
5116
5392
  // ---------------------------------------------------------------------------
5117
5393
  program
5118
5394
  .command("relaunch [filter]")
5119
- .description("Relaunch the CLI in local tmux sessions whose CLI dropped to a shell, in situ (windows kept), each resuming ITS OWN conversation from the registry. Running sessions are left alone. Dry-run by default; --apply to do it. [filter] = only sessions whose slug contains it.")
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
- .action((filter, opts) => {
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
- // Exact tmux session -> its own convId when available. Historical rows
5128
- // fall back to their slug only after planRelaunch has excluded a dual
5129
- // h2a-/remote- prefix collision.
5130
- const convByTmuxSession = new Map();
5131
- const convBySlug = new Map();
5132
- for (const e of loadRegistry()) {
5133
- if (e.kind !== "local-tmux" || !e.convId)
5134
- continue;
5135
- convBySlug.set(e.id, e.convId);
5136
- if (e.tmuxSession)
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
- const sessions = listLocalSessions().filter((s) => !filter || s.slug.includes(filter));
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
- ...(convByTmuxSession.has(s.name)
5146
- ? { convId: convByTmuxSession.get(s.name) }
5147
- : convBySlug.has(s.slug)
5148
- ? { convId: convBySlug.get(s.slug) }
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 a of plan.actions) {
5170
- if (relaunchInSession(a.name, a.cmd)) {
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] relaunched ${a.slug}: ${a.cmd}\n`);
5601
+ process.stderr.write(`[h2a] force-restarted ${action.slug}: ${action.cmd}\n`);
5173
5602
  }
5174
- else {
5175
- process.stderr.write(`[h2a] FAILED to relaunch ${a.slug}\n`);
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] relaunched ${ok}/${plan.actions.length}${plan.skipped.length ? `, ${plan.skipped.length} skipped` : ""}\n`);
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
  }