@sentropic/h2a-runtime 0.85.16 → 0.85.17

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 (58) hide show
  1. package/dist/agent-launch-args.d.ts +26 -0
  2. package/dist/agent-launch-args.d.ts.map +1 -0
  3. package/dist/agent-launch-args.js +65 -0
  4. package/dist/agent-launch-args.js.map +1 -0
  5. package/dist/delegate.d.ts +17 -8
  6. package/dist/delegate.d.ts.map +1 -1
  7. package/dist/delegate.js +70 -33
  8. package/dist/delegate.js.map +1 -1
  9. package/dist/enroll.d.ts +7 -3
  10. package/dist/enroll.d.ts.map +1 -1
  11. package/dist/enroll.js +65 -13
  12. package/dist/enroll.js.map +1 -1
  13. package/dist/h2a-bridge.d.ts +1 -1
  14. package/dist/h2a-bridge.js +2 -2
  15. package/dist/h2a-bridge.js.map +1 -1
  16. package/dist/index.d.ts +7 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +364 -84
  19. package/dist/index.js.map +1 -1
  20. package/dist/launch-context.d.ts +2 -0
  21. package/dist/launch-context.d.ts.map +1 -1
  22. package/dist/launch-context.js +10 -5
  23. package/dist/launch-context.js.map +1 -1
  24. package/dist/llm-gateway-runtime/accounts.d.ts +17 -3
  25. package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -1
  26. package/dist/llm-gateway-runtime/accounts.js +44 -7
  27. package/dist/llm-gateway-runtime/accounts.js.map +1 -1
  28. package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
  29. package/dist/llm-gateway-runtime/index.js +6 -0
  30. package/dist/llm-gateway-runtime/index.js.map +1 -1
  31. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -1
  32. package/dist/llm-gateway-runtime/model-catalog.js +12 -2
  33. package/dist/llm-gateway-runtime/model-catalog.js.map +1 -1
  34. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -1
  35. package/dist/llm-gateway-runtime/proxy-anthropic.js +13 -1
  36. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -1
  37. package/dist/llm-gateway-runtime/proxy-openai.d.ts +5 -2
  38. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -1
  39. package/dist/llm-gateway-runtime/proxy-openai.js +41 -18
  40. package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -1
  41. package/dist/llm-gateway-runtime/sticky.d.ts +11 -2
  42. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
  43. package/dist/llm-gateway-runtime/sticky.js +325 -31
  44. package/dist/llm-gateway-runtime/sticky.js.map +1 -1
  45. package/dist/readiness.js +2 -2
  46. package/dist/readiness.js.map +1 -1
  47. package/dist/registry.d.ts +6 -1
  48. package/dist/registry.d.ts.map +1 -1
  49. package/dist/registry.js +10 -1
  50. package/dist/registry.js.map +1 -1
  51. package/dist/restore.d.ts.map +1 -1
  52. package/dist/restore.js +2 -0
  53. package/dist/restore.js.map +1 -1
  54. package/dist/tmux.d.ts +71 -13
  55. package/dist/tmux.d.ts.map +1 -1
  56. package/dist/tmux.js +301 -34
  57. package/dist/tmux.js.map +1 -1
  58. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { basename, join, resolve } from "node:path";
6
+ import { basename, isAbsolute, join, resolve } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { Command } from "commander";
9
9
  import { attach, createRemoteSession, getRemoteSession, listRemoteSessions, refreshRemoteSession, renameRemoteSession, sessionTerminalHealth, stopRemoteSession, } from "./attach.js";
@@ -14,12 +14,13 @@ import { transmittedSecrets, secretsSummary } from "./secrets.js";
14
14
  import { localConvStat, remoteConvStat, alignment } from "./convsync.js";
15
15
  import { gitAlignment, localAncestry, localGitStat, remoteGitStat, } from "./gitdiff.js";
16
16
  import { syncConversation } from "./sync.js";
17
- import { attachLocalSession, attachPodTmux, capturePane, conductorRunning, currentTmuxSessionIs, ensureManagedTmuxProfile, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, localSessionIdle, localSessionName, readLaunchContext, relaunchInSession, resolveAgentPaneForInstance, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, startH2aWindow, startHeadlessSession, startLocalSession, tmuxAvailable, } from "./tmux.js";
17
+ import { attachLocalSession, attachPodTmux, capturePane, conductorRunning, currentTmuxSessionIs, ensureManagedTmuxProfile, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, localSessionIdle, localSessionPanePid, localSessionName, readLaunchContext, relaunchInSession, resolveAgentPaneForInstance, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, } from "./tmux.js";
18
+ import { buildAgentLaunchArgs, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
18
19
  import { planRelaunch } from "./relaunch.js";
19
20
  import { readLastLayout, restore as restoreLayout, } from "./restore.js";
20
21
  import { getLayoutConfig } from "./config.js";
21
22
  import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, localTmuxSessionForName, tryClaimSlot, withRegistryLock, resolveRegistryPath, } from "./registry.js";
22
- import { aimdEffectiveCap, 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";
23
+ 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";
23
24
  import { detectThrottle, THROTTLE_TAIL_LINES } from "./throttle-signatures.js";
24
25
  import { interactiveResumeNudge, planInteractiveResume, } from "./interactive-throttle.js";
25
26
  import { promptProfileMenu, shouldShowProfileMenu } from "./profile-menu.js";
@@ -56,11 +57,15 @@ import { acquireLease, createLineage, handoffLease, isIncarnationSuspended, list
56
57
  import { checkReadiness } from "./readiness.js";
57
58
  import { createInterface } from "node:readline";
58
59
  import { appendSessionLogEntry, clearBinding, clearExhaustion, enrollAccount, listAccounts, listAccountsWithTokens, listAccountsWithStatus, listBindings, lookupBinding, markExhausted, readClaudeCredential, readCodexCredential, removeAccount, selectAccount, selectAccountWithFallback, stickyBind, loadCandidates, sessionLogPath, exportSessionLogToS3, QUOTA_WINDOW_5H_MS, QUOTA_WINDOW_WEEK_MS, } from "./account-pool.js";
60
+ const H2A_RUN_API_VERSION = "h2a.run/v1";
61
+ const H2A_RUNTIME_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
59
62
  import { CLI_PROFILES } from "./protocol-local.js";
60
63
  import { enrollCodexAccount, enrollClaudeAccount, readLlmMeshConfig, startGateway, stopGateway, writeLlmMeshConfig, readGatewayPid, llmMeshLogPath, jwtExpiry, readLlmMeshSessionEnv, acquireLlmMeshSessionEnv, } from "./llm-mesh.js";
61
64
  const KNOWN_PROFILE_HELP = `${CLI_PROFILES.join(", ")} (aliases: claude-code, antigravity, gemini-cli, mistralcli)`;
62
- export const packageName = "@sentropic/remote-cli";
65
+ export const packageName = "@sentropic/h2a-runtime";
66
+ export const H2A_RUNTIME_CLI_API_VERSION = 1;
63
67
  export { run } from "./run.js";
68
+ export { AGENT_LAUNCH_EFFORTS, AGENT_LAUNCH_PROFILES, AGENT_LAUNCH_PROMPT_MAX_BYTES, assertAgentLaunchModel, assertAgentLaunchPrompt, buildAgentLaunchArgs, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
64
69
  export { attach, createRemoteSession, getRemoteSession, listRemoteSessions, refreshRemoteSession, renameRemoteSession, stopRemoteSession, } from "./attach.js";
65
70
  export { inspectProfileAuth } from "./auth-diagnostics.js";
66
71
  export { AuthRefreshError, ensureProfileAuthFresh } from "./auth-refresh.js";
@@ -87,8 +92,8 @@ function resumeStartupArgs(profileName, resume) {
87
92
  * fleet (one auth). Creation is bounded-concurrent (cap = the fleet size,
88
93
  * itself <= DEFAULT_FANOUT_MAX). NEVER auto-attaches — a fleet has no single
89
94
  * terminal to take over; prints a summary table and the per-session attach
90
- * hints, mirroring the LOCAL `remote run --count` contract. Reconcile/cleanup
91
- * of dead members reuses the existing `remote ls`/`jobs` reconciliation against
95
+ * hints, mirroring the LOCAL `h2a run --count` contract. Reconcile/cleanup
96
+ * of dead members reuses the existing `h2a ls`/`jobs` reconciliation against
92
97
  * `listRemoteSessions` (a dead Pod simply drops off the live list). Returns the
93
98
  * created (id,name) pairs; throws only on a setup error before fan-out.
94
99
  */
@@ -721,7 +726,7 @@ export async function startJob(job) {
721
726
  // Bundle local CLI credentials (e.g. ~/.claude/.credentials.json for
722
727
  // "claude", ~/.codex/auth.json for "codex") so the pod has them at start.
723
728
  // Best-effort: a missing bundle is not fatal — the session starts without
724
- // credentials (user can `remote refresh` the job later if needed).
729
+ // credentials (user can `h2a refresh` the job later if needed).
725
730
  let jobCredentials;
726
731
  if (isCliProfile(remoteArgs.profile)) {
727
732
  try {
@@ -838,7 +843,7 @@ export async function startJob(job) {
838
843
  // allExhausted: proceed with existing env (fail gracefully at the CLI level)
839
844
  }
840
845
  // Propagate the child's remaining spawn-depth budget through the env so a job
841
- // that itself runs `remote delegate` inherits a DECREMENTED budget (depth=0 →
846
+ // that itself runs `h2a delegate` inherits a DECREMENTED budget (depth=0 →
842
847
  // refuse). tmux inherits the spawning process's env, so set it around spawn.
843
848
  // ALSO stamp REMOTE_JOB_ID (H1): the spawned agent's claude SessionStart/End
844
849
  // hooks read it to resolve THIS job (they only get claude's conversation uuid,
@@ -949,7 +954,7 @@ export function resumeThrottledJob(job) {
949
954
  const runCwd = job.cwd;
950
955
  let argv;
951
956
  try {
952
- argv = buildThrottleResumeArgs(job.tool, task);
957
+ argv = buildThrottleResumeArgs(job.tool, task, job.model, job.effort);
953
958
  }
954
959
  catch (err) {
955
960
  return { started: false, error: err.message };
@@ -1064,7 +1069,7 @@ function looksLikeUrl(value) {
1064
1069
  }
1065
1070
  /**
1066
1071
  * Resolve the tool list to bundle: `--with a,b` overrides the configured
1067
- * default (`remote config tools …`). Unknown tools are dropped with a warning.
1072
+ * default (`h2a config tools …`). Unknown tools are dropped with a warning.
1068
1073
  */
1069
1074
  function resolveTools(withOpt) {
1070
1075
  const raw = withOpt
@@ -1091,7 +1096,7 @@ function projectName(s) {
1091
1096
  }
1092
1097
  return s.id;
1093
1098
  }
1094
- /** Local CLI binary for a profile (used by `remote run`). */
1099
+ /** Local CLI binary for a profile (used by `h2a run`). */
1095
1100
  const LOCAL_CLI = {
1096
1101
  claude: "claude",
1097
1102
  "claude-code": "claude",
@@ -1159,7 +1164,7 @@ function shouldUseClaudeBare(profile) {
1159
1164
  return ((profile === "claude" || profile === "claude-code") &&
1160
1165
  Boolean(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN));
1161
1166
  }
1162
- async function injectLlmMeshGatewayEnv(mode = "auto") {
1167
+ async function injectLlmMeshGatewayEnv(mode = "auto", allowDirectFallback = true) {
1163
1168
  if (mode === "direct") {
1164
1169
  delete process.env.ANTHROPIC_BASE_URL;
1165
1170
  delete process.env.ANTHROPIC_AUTH_TOKEN;
@@ -1186,12 +1191,14 @@ async function injectLlmMeshGatewayEnv(mode = "auto") {
1186
1191
  process.stderr.write(`[h2a] llm-mesh: gateway was stopped; started on ${meshEnv.ANTHROPIC_BASE_URL}\n`);
1187
1192
  }
1188
1193
  catch (err) {
1189
- process.stderr.write(`[h2a] llm-mesh: gateway env unavailable (${String(err)}); Claude may ask for login.\n`);
1194
+ if (allowDirectFallback) {
1195
+ process.stderr.write(`[h2a] llm-mesh: gateway env unavailable (${String(err)}); Claude may ask for login.\n`);
1196
+ }
1190
1197
  }
1191
1198
  }
1192
1199
  }
1193
1200
  if (!meshEnv) {
1194
- if (mode === "gateway") {
1201
+ if (mode === "gateway" && allowDirectFallback) {
1195
1202
  process.stderr.write("[h2a] llm-mesh: --gw requested but no gateway env is available; continuing direct. Run `h2a llm-mesh start` or enroll an account first.\n");
1196
1203
  }
1197
1204
  return undefined;
@@ -1206,6 +1213,13 @@ async function injectLlmMeshGatewayEnv(mode = "auto") {
1206
1213
  }
1207
1214
  return meshEnv.ANTHROPIC_BASE_URL;
1208
1215
  }
1216
+ export async function prepareStructuredGateway(mode, inject = (selectedMode) => injectLlmMeshGatewayEnv(selectedMode, selectedMode !== "gateway")) {
1217
+ const gateway = await inject(mode);
1218
+ if (mode === "gateway" && !gateway) {
1219
+ throw new Error("llm-mesh gateway is required but unavailable; no agent was started");
1220
+ }
1221
+ return gateway;
1222
+ }
1209
1223
  async function prepareLlmMeshForRestore(opts = {}) {
1210
1224
  if (!getLlmMeshRuntimeConfig().enabled)
1211
1225
  return;
@@ -1268,7 +1282,7 @@ function resolveUrlAndSessionId(first, second) {
1268
1282
  return { url: getConfiguredRemote(first), sessionId: second };
1269
1283
  }
1270
1284
  if (looksLikeUrl(first)) {
1271
- throw new Error(`Missing session id. Usage: remote <command> [url] <sessionId> (received URL "${first}" without session id).`);
1285
+ throw new Error(`Missing session id. Usage: h2a <command> [url] <sessionId> (received URL "${first}" without session id).`);
1272
1286
  }
1273
1287
  return { url: getConfiguredRemote(), sessionId: first };
1274
1288
  }
@@ -1297,7 +1311,7 @@ export async function main(argv) {
1297
1311
  }
1298
1312
  return main([
1299
1313
  argv[0] ?? "node",
1300
- argv[1] ?? "remote",
1314
+ argv[1] ?? "h2a",
1301
1315
  "run",
1302
1316
  profile,
1303
1317
  process.cwd(),
@@ -1305,7 +1319,7 @@ export async function main(argv) {
1305
1319
  }
1306
1320
  const program = new Command();
1307
1321
  program
1308
- .name("remote")
1322
+ .name("h2a")
1309
1323
  .description("Wrap a local agent CLI (codex/claude/agy/gemini/mistral) and expose its session for h2a attach.")
1310
1324
  .version("0.0.0");
1311
1325
  for (const [profileName, alias] of [
@@ -1319,11 +1333,11 @@ export async function main(argv) {
1319
1333
  ]) {
1320
1334
  const cmd = program
1321
1335
  .command(profileName)
1322
- .description(`Run ${profileName} via remote-cli`)
1336
+ .description(`Run ${profileName} via h2a`)
1323
1337
  .argument("[commandArgs...]", "Arguments passed to the wrapped CLI")
1324
1338
  .option("-r, --resume [convId]", "resume the wrapped CLI's last conversation (or a specific one by id) using its native --continue/--resume flag")
1325
1339
  .option("-p, --port <port>", "expose the in-process control-plane on this port", (value) => Number(value))
1326
- .option("--remote <url>", "override the control-plane URL (defaults to the configured remote; `remote` is remote-first)")
1340
+ .option("--remote <url>", "override the control-plane URL (h2a defaults to the configured remote)")
1327
1341
  .option("--local", "run the CLI in-process via a local PTY instead of a remote session")
1328
1342
  .option("--sync", "seed the remote /workspace with the current directory (honors .gitignore)")
1329
1343
  .option("--no-workspace", "ignore the .remote/ mapping and use a throwaway workspace")
@@ -1374,7 +1388,7 @@ export async function main(argv) {
1374
1388
  }
1375
1389
  program
1376
1390
  .command("install <url>")
1377
- .description("Set default remote URL and apply remote's managed local tmux profile")
1391
+ .description("Set default remote URL and apply h2a's managed local tmux profile")
1378
1392
  .action((url) => {
1379
1393
  setAndReportDefaultRemote(url);
1380
1394
  });
@@ -1655,7 +1669,7 @@ export async function main(argv) {
1655
1669
  });
1656
1670
  const authCommand = program
1657
1671
  .command("auth")
1658
- .description("Inspect and manage the local CLI credentials remote sends to sessions");
1672
+ .description("Inspect and manage the local CLI credentials h2a sends to sessions");
1659
1673
  const printAuthStatus = async (profile, opts) => {
1660
1674
  const result = await inspectProfileAuth(profile, {
1661
1675
  ...(opts.authRefresh !== undefined
@@ -1818,7 +1832,7 @@ export async function main(argv) {
1818
1832
  });
1819
1833
  configCommand
1820
1834
  .command("tmux-profile <name>")
1821
- .description("Set the marker name for remote's embedded tmux profile applied to local sessions")
1835
+ .description("Set the marker name for h2a's embedded tmux profile applied to local sessions")
1822
1836
  .action((name) => {
1823
1837
  setTmuxProfileConfig({ profile: name });
1824
1838
  const profile = getTmuxProfileConfig().profile;
@@ -2461,7 +2475,7 @@ export async function main(argv) {
2461
2475
  await ensureConnected(remoteUrl);
2462
2476
  // Single-writer guard: the conversation must not stay open locally (or
2463
2477
  // in another pod) while we deport it — stop the local session first
2464
- // (`remote stop <slug>`), or --force to take over anyway. A BARE -r
2478
+ // (`h2a stop <slug>`), or --force to take over anyway. A BARE -r
2465
2479
  // resumes "the most recent conversation", resolved inside
2466
2480
  // migrateForward AFTER this point — so resolve the same newest local
2467
2481
  // conversation HERE (localConvStat on the cwd; claude-family only,
@@ -3165,18 +3179,61 @@ export async function main(argv) {
3165
3179
  });
3166
3180
  program
3167
3181
  .command("run <profile> [path]")
3168
- .description("Start a LOCAL session in tmux (claude/codex/…) in <path> (default: cwd), then attach this terminal by default. Remote applies its embedded scroll-safe tmux profile at launch; no ~/.tmux.conf is required. Manage it like a remote one: `h2a ls`, `h2a attach <slug>`, `h2a stop <slug>`. Detach with Ctrl-b d; the session keeps running.")
3182
+ .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.")
3169
3183
  .option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
3170
3184
  .option("-r, --resume <convId>", "resume a specific conversation in the CLI")
3171
3185
  .option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
3172
3186
  .option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
3173
3187
  .option("--count <n>", "fan out N parallel agents (named <base>#1…#N) — run more than the per-project layout cap of claude/codex sessions")
3174
3188
  .option("--h2a", 'also start the h2a MCP server in a side tmux window "h2a" (launcher contract: agent reachable/wakeable via ~/h2a-workspace/.h2a); config key `h2a: {enabled, command}` makes it the default')
3189
+ .option("--no-h2a", "do not start the h2a MCP side window, overriding config")
3190
+ .option("--prompt-stdin", "read the initial Claude/Codex prompt from stdin (keeps it out of process argv)")
3191
+ .option("--model <model>", "Claude/Codex model override")
3192
+ .option("--effort <level>", "reasoning effort: low|medium|high|xhigh (Claude and Codex)")
3193
+ .option("--headless", "run once and exit (Claude -p / Codex exec), recording output under .h2a/runs/<name>")
3194
+ .option("--background", "classify this detached session as a background worker excluded from human restore")
3195
+ .option("--json", "emit one machine-readable h2a.run.result object")
3175
3196
  .option("--llm-gateway", "launch the CLI through the local llm-mesh gateway")
3176
3197
  .option("--gw", "alias for --llm-gateway")
3177
3198
  .option("--no-llm-gateway", "launch without llm-mesh gateway env")
3178
3199
  .option("--no-gw", "alias for --no-llm-gateway")
3179
3200
  .action(async (profile, path, opts) => {
3201
+ const structuredLaunch = opts.promptStdin === true ||
3202
+ opts.model !== undefined ||
3203
+ opts.effort !== undefined ||
3204
+ opts.headless === true ||
3205
+ opts.background === true ||
3206
+ opts.json === true;
3207
+ if (structuredLaunch && !isAgentLaunchProfile(profile)) {
3208
+ process.stderr.write(`[h2a] structured run supports only claude|codex (got "${profile}")\n`);
3209
+ process.exitCode = 2;
3210
+ return;
3211
+ }
3212
+ if (opts.effort !== undefined && !isAgentLaunchEffort(opts.effort)) {
3213
+ process.stderr.write(`[h2a] --effort must be low|medium|high|xhigh\n`);
3214
+ process.exitCode = 2;
3215
+ return;
3216
+ }
3217
+ if (opts.json && !opts.background) {
3218
+ process.stderr.write("[h2a] --json launch requires --background (MCP launches are detached)\n");
3219
+ process.exitCode = 2;
3220
+ return;
3221
+ }
3222
+ if (opts.background && opts.attach !== false) {
3223
+ process.stderr.write("[h2a] --background requires --no-attach\n");
3224
+ process.exitCode = 2;
3225
+ return;
3226
+ }
3227
+ if (opts.headless && opts.h2a === true) {
3228
+ process.stderr.write("[h2a] --headless cannot combine with --h2a (the sidecar would keep a run-once session alive)\n");
3229
+ process.exitCode = 2;
3230
+ return;
3231
+ }
3232
+ if (opts.promptStdin && process.stdin.isTTY) {
3233
+ process.stderr.write("[h2a] --prompt-stdin requires piped stdin\n");
3234
+ process.exitCode = 2;
3235
+ return;
3236
+ }
3180
3237
  if (!tmuxAvailable()) {
3181
3238
  process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
3182
3239
  process.exitCode = 1;
@@ -3198,6 +3255,39 @@ export async function main(argv) {
3198
3255
  return;
3199
3256
  }
3200
3257
  }
3258
+ if (structuredLaunch && count !== 1) {
3259
+ process.stderr.write("[h2a] structured prompt/model launch supports exactly one session\n");
3260
+ process.exitCode = 2;
3261
+ return;
3262
+ }
3263
+ if (opts.json && !opts.promptStdin) {
3264
+ process.stderr.write("[h2a] --json launch requires --prompt-stdin\n");
3265
+ process.exitCode = 2;
3266
+ return;
3267
+ }
3268
+ if (opts.json && (!path || !isAbsolute(path))) {
3269
+ process.stderr.write("[h2a] --json launch requires an explicit absolute workspace path\n");
3270
+ process.exitCode = 2;
3271
+ return;
3272
+ }
3273
+ if (opts.json && !opts.name) {
3274
+ process.stderr.write("[h2a] --json launch requires --name\n");
3275
+ process.exitCode = 2;
3276
+ return;
3277
+ }
3278
+ if (opts.name) {
3279
+ try {
3280
+ assertSafeName(opts.name);
3281
+ if (opts.name.length > 64) {
3282
+ throw new Error("job/session name must be at most 64 characters");
3283
+ }
3284
+ }
3285
+ catch (error) {
3286
+ process.stderr.write(`[h2a] ${error.message}\n`);
3287
+ process.exitCode = 2;
3288
+ return;
3289
+ }
3290
+ }
3201
3291
  const cwd = path ? resolve(path) : process.cwd();
3202
3292
  // count==1 keeps the exact prior behaviour (label = opts.name, which may
3203
3293
  // be undefined -> slug derives from cwd). count>1 fans out distinct
@@ -3205,12 +3295,7 @@ export async function main(argv) {
3205
3295
  const labels = count > 1
3206
3296
  ? fanoutLabels(opts.name ?? basename(cwd), count)
3207
3297
  : [opts.name];
3208
- const existingLocalSessions = labels
3209
- .map((label) => {
3210
- const slug = slugify(label ?? cwd);
3211
- return findLocalSession(slug) ? slug : undefined;
3212
- })
3213
- .filter((slug) => slug !== undefined);
3298
+ const existingLocalSessions = existingLocalSessionSlugs(labels, cwd);
3214
3299
  if (existingLocalSessions.length > 0) {
3215
3300
  for (const slug of existingLocalSessions) {
3216
3301
  process.stderr.write(`[h2a] local session ${slug} already exists; no new ${profile} was started.\n`);
@@ -3220,6 +3305,15 @@ export async function main(argv) {
3220
3305
  process.exitCode = 1;
3221
3306
  return;
3222
3307
  }
3308
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
3309
+ process.stderr.write(`[h2a] workspace is not an existing directory: ${cwd}\n`);
3310
+ process.exitCode = 2;
3311
+ return;
3312
+ }
3313
+ let initialPrompt;
3314
+ if (opts.promptStdin) {
3315
+ initialPrompt = readFileSync(0, "utf8");
3316
+ }
3223
3317
  // Single-writer guard: refuse to resume a conversation another live
3224
3318
  // session (local registry / remote pod) is already writing.
3225
3319
  if (opts.resume) {
@@ -3248,22 +3342,154 @@ export async function main(argv) {
3248
3342
  process.exitCode = 1;
3249
3343
  return;
3250
3344
  }
3251
- await injectLlmMeshGatewayEnv(gatewayMode);
3345
+ if (structuredLaunch &&
3346
+ profile === "codex" &&
3347
+ gatewayMode === "gateway") {
3348
+ process.stderr.write("[h2a] --gw is unsupported for structured codex launches (llm-mesh is Anthropic-compatible)\n");
3349
+ process.exitCode = 2;
3350
+ return;
3351
+ }
3352
+ const launchGatewayMode = structuredLaunch && profile === "codex" ? "direct" : gatewayMode;
3353
+ let activeGateway;
3354
+ try {
3355
+ if (structuredLaunch) {
3356
+ activeGateway = await prepareStructuredGateway(launchGatewayMode);
3357
+ }
3358
+ else {
3359
+ activeGateway = await injectLlmMeshGatewayEnv(gatewayMode);
3360
+ }
3361
+ }
3362
+ catch (error) {
3363
+ process.stderr.write(`[h2a] ${error.message}\n`);
3364
+ process.exitCode = 1;
3365
+ return;
3366
+ }
3252
3367
  const useBare = shouldUseClaudeBare(profile);
3253
3368
  const command = localCliCommand(profile);
3254
- const args = opts.resume
3255
- ? localResumeArgs(profile, opts.resume, { bare: useBare })
3256
- : localStartArgs(profile, { bare: useBare });
3369
+ let args;
3370
+ try {
3371
+ args =
3372
+ structuredLaunch && isAgentLaunchProfile(profile)
3373
+ ? buildAgentLaunchArgs({
3374
+ profile,
3375
+ ...(initialPrompt !== undefined
3376
+ ? { prompt: initialPrompt }
3377
+ : {}),
3378
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
3379
+ ...(opts.effort !== undefined
3380
+ ? { effort: opts.effort }
3381
+ : {}),
3382
+ ...(opts.resume !== undefined
3383
+ ? { resumeId: opts.resume }
3384
+ : {}),
3385
+ ...(opts.headless ? { headless: true } : {}),
3386
+ ...(useBare ? { bare: true } : {}),
3387
+ })
3388
+ : opts.resume
3389
+ ? localResumeArgs(profile, opts.resume, { bare: useBare })
3390
+ : localStartArgs(profile, { bare: useBare });
3391
+ }
3392
+ catch (error) {
3393
+ process.stderr.write(`[h2a] ${error.message}\n`);
3394
+ process.exitCode = 2;
3395
+ return;
3396
+ }
3257
3397
  if (opts.resume && args.length === 0) {
3258
3398
  process.stderr.write(`[h2a] profile "${profile}" has no verified local resume argv; start it without -r/--resume\n`);
3259
3399
  process.exitCode = 1;
3260
3400
  return;
3261
3401
  }
3262
3402
  const h2a = getH2aConfig();
3403
+ const h2aSidecar = opts.h2a ?? h2a.enabled;
3404
+ if (opts.headless && h2aSidecar) {
3405
+ process.stderr.write("[h2a] --headless requires --no-h2a (a sidecar would keep the run-once session alive)\n");
3406
+ process.exitCode = 2;
3407
+ return;
3408
+ }
3409
+ if (opts.headless && !opts.name) {
3410
+ process.stderr.write("[h2a] --headless requires --name\n");
3411
+ process.exitCode = 2;
3412
+ return;
3413
+ }
3263
3414
  const started = [];
3264
3415
  for (const label of labels) {
3265
- const { name, slug } = startLocalSession(profile, command, cwd, args, label);
3266
- // Auto-enroll in the live-session registry (feeds `remote ls`/`restore`).
3416
+ let name;
3417
+ let slug;
3418
+ let outputLog;
3419
+ let resultJson;
3420
+ let agentPane;
3421
+ let promptFile;
3422
+ if (opts.headless) {
3423
+ const runDir = join(cwd, ".h2a", "runs", label);
3424
+ mkdirSync(runDir, { recursive: true });
3425
+ outputLog = join(runDir, "output.log");
3426
+ resultJson = join(runDir, "result.json");
3427
+ ({ name, slug, agentPane, promptFile } = startHeadlessSession(profile, command, cwd, args, resultJson, outputLog, label, getTmuxProfileConfig().profile, initialPrompt, structuredLaunch));
3428
+ }
3429
+ else {
3430
+ ({ name, slug, agentPane } = startLocalSession(profile, command, cwd, args, label, getTmuxProfileConfig().profile, {
3431
+ ...(opts.resume !== undefined
3432
+ ? { resumeId: opts.resume }
3433
+ : {}),
3434
+ ...(h2aSidecar ? { h2aCommand: h2a.command } : {}),
3435
+ ...(initialPrompt !== undefined
3436
+ ? { terminateOnAgentExit: true }
3437
+ : {}),
3438
+ ...(structuredLaunch ? { refuseExisting: true } : {}),
3439
+ }));
3440
+ }
3441
+ if (structuredLaunch && !agentPane) {
3442
+ cleanupHeadlessPromptFile(promptFile);
3443
+ killLocalSession(name);
3444
+ process.stderr.write(`[h2a] could not capture the exact agent pane for ${slug}; the partial session was stopped\n`);
3445
+ process.exitCode = 1;
3446
+ return;
3447
+ }
3448
+ let h2aSidecarStarted = false;
3449
+ if (h2aSidecar) {
3450
+ if (structuredLaunch) {
3451
+ try {
3452
+ h2aSidecarStarted = Boolean(await startH2aWindowVerified(name, cwd, h2a.command, agentPane));
3453
+ }
3454
+ catch (error) {
3455
+ process.stderr.write(`[h2a] required h2a sidecar verification failed: ${error.message}\n`);
3456
+ h2aSidecarStarted = false;
3457
+ }
3458
+ }
3459
+ else {
3460
+ h2aSidecarStarted = startH2aWindow(name, cwd, h2a.command);
3461
+ }
3462
+ if (!h2aSidecarStarted && structuredLaunch) {
3463
+ cleanupHeadlessPromptFile(promptFile);
3464
+ killLocalSession(name);
3465
+ process.stderr.write(`[h2a] required h2a sidecar failed for ${slug}; the partial session was stopped\n`);
3466
+ process.exitCode = 1;
3467
+ return;
3468
+ }
3469
+ if (h2aSidecarStarted) {
3470
+ process.stderr.write(`[h2a] h2a window started in ${slug} (${h2a.command})\n`);
3471
+ }
3472
+ }
3473
+ if (!opts.headless &&
3474
+ initialPrompt !== undefined &&
3475
+ !sendKeysLiteral(agentPane, initialPrompt)) {
3476
+ cleanupHeadlessPromptFile(promptFile);
3477
+ killLocalSession(name);
3478
+ process.stderr.write(`[h2a] failed to deliver the initial prompt to ${slug}; the partial session was stopped\n`);
3479
+ process.exitCode = 1;
3480
+ return;
3481
+ }
3482
+ const pid = agentPane
3483
+ ? localSessionPanePid(agentPane)
3484
+ : undefined;
3485
+ if (structuredLaunch && pid === undefined) {
3486
+ cleanupHeadlessPromptFile(promptFile);
3487
+ killLocalSession(name);
3488
+ process.stderr.write(`[h2a] could not verify the agent pane pid for ${slug}; the partial session was stopped\n`);
3489
+ process.exitCode = 1;
3490
+ return;
3491
+ }
3492
+ // Auto-enroll in the live-session registry (feeds `h2a ls`/`restore`).
3267
3493
  // Pin the gateway posture ONLY when the user was explicit (--gw/--no-gw);
3268
3494
  // an "auto" launch stays unpinned so restore follows the live default.
3269
3495
  enrollFromRun({
@@ -3273,15 +3499,17 @@ export async function main(argv) {
3273
3499
  cwd,
3274
3500
  ...(opts.resume !== undefined ? { convId: opts.resume } : {}),
3275
3501
  ...(gatewayMode !== "auto" ? { gatewayMode } : {}),
3502
+ ...(opts.background ? { sessionClass: "background" } : {}),
3503
+ });
3504
+ started.push({
3505
+ name,
3506
+ slug,
3507
+ ...(agentPane !== undefined ? { pane: agentPane } : {}),
3508
+ h2aSidecar: h2aSidecarStarted,
3509
+ ...(pid !== undefined ? { pid } : {}),
3510
+ ...(outputLog !== undefined ? { outputLog } : {}),
3511
+ ...(resultJson !== undefined ? { resultJson } : {}),
3276
3512
  });
3277
- started.push({ name, slug });
3278
- // h2a launcher contract (opt-in): --h2a forces it; `h2a.enabled` makes
3279
- // it the default. Never fails the run.
3280
- if (opts.h2a || h2a.enabled) {
3281
- if (startH2aWindow(name, cwd, h2a.command)) {
3282
- process.stderr.write(`[h2a] h2a window started in ${slug} (${h2a.command})\n`);
3283
- }
3284
- }
3285
3513
  }
3286
3514
  if (count > 1) {
3287
3515
  process.stderr.write(`[h2a] ${started.length} ${profile} agents started in ${cwd}: ${started.map((s) => s.slug).join(", ")}\n` +
@@ -3289,9 +3517,41 @@ export async function main(argv) {
3289
3517
  return; // never auto-attach a fleet
3290
3518
  }
3291
3519
  const only = started[0];
3520
+ if (opts.json) {
3521
+ process.stdout.write(`${JSON.stringify({
3522
+ kind: "h2a.run.result",
3523
+ version: 1,
3524
+ apiVersion: H2A_RUN_API_VERSION,
3525
+ runtimeVersion: H2A_RUNTIME_VERSION,
3526
+ ok: true,
3527
+ state: "started",
3528
+ session: {
3529
+ id: only.slug,
3530
+ tmuxSession: only.name,
3531
+ profile,
3532
+ workspace: cwd,
3533
+ mode: opts.headless ? "headless" : "interactive",
3534
+ background: opts.background === true,
3535
+ gateway: activeGateway ? "gateway" : "direct",
3536
+ h2aSidecar: only.h2aSidecar,
3537
+ ...(only.pane !== undefined ? { pane: only.pane } : {}),
3538
+ ...(only.pid !== undefined ? { pid: only.pid } : {}),
3539
+ },
3540
+ attach: opts.headless
3541
+ ? null
3542
+ : { command: "h2a", args: ["attach", only.slug] },
3543
+ ...(only.outputLog !== undefined
3544
+ ? {
3545
+ logs: { path: only.outputLog },
3546
+ result: { path: only.resultJson },
3547
+ }
3548
+ : {}),
3549
+ })}\n`);
3550
+ return;
3551
+ }
3292
3552
  process.stderr.write(`[h2a] local session ${only.slug} started (${profile}${opts.resume ? ` --resume ${opts.resume}` : ""} in ${cwd})\n`);
3293
3553
  // Single local runs hand off the terminal by default: there is no value
3294
- // in making users type a second `remote attach <slug>` command. Commander
3554
+ // in making users type a second `h2a attach <slug>` command. Commander
3295
3555
  // sets opts.attach=false only for --no-attach.
3296
3556
  if (opts.attach === false) {
3297
3557
  process.stderr.write(`[h2a] attach with: h2a attach ${only.slug}\n`);
@@ -3305,9 +3565,9 @@ export async function main(argv) {
3305
3565
  // ---------------------------------------------------------------------------
3306
3566
  program
3307
3567
  .command("delegate <type> <task>")
3308
- .description("Delegate a task to a LIVE agent (claude/codex/agy) in a detached tmux session, primed with <task> (passed as a single argv — never shell-concatenated) and with the h2a side-window so the parent/master dialogue works. Returns a job id. Supervise with `remote jobs`; drain the queue with `remote jobs conduct`. `--remote` runs the job CONCURRENTLY in a SCW Pod on the shared RWX volume (subPath per job). Beyond the concurrency cap (default 16) the job is QUEUED (pending) and started by the conductor as slots free.")
3568
+ .description("Delegate a task to a LIVE agent (claude/codex/agy) in a detached tmux session, primed with <task> (passed as a single argv — never shell-concatenated) and with the h2a side-window so the parent/master dialogue works. Returns a job id. Supervise with `h2a jobs`; drain the queue with `h2a jobs conduct`. `--remote` runs the job CONCURRENTLY in a SCW Pod on the shared RWX volume (subPath per job). Beyond the concurrency cap (default 16) the job is QUEUED (pending) and started by the conductor as slots free.")
3309
3569
  .option("--remote [url]", "run the job in a SCW Pod (concurrent; isolated by a per-job workspace subPath on the shared RWX volume) instead of a local tmux session; optional control-plane URL (default: the configured remote)")
3310
- .option("--cwd <path>", "run the agent in this directory as-is (default: a dedicated git worktree under .remote/jobs/<id>/wt when cwd is a repo, else cwd). Local only.")
3570
+ .option("--cwd <path>", "run the agent in this directory as-is (default: a dedicated git worktree under .h2a/jobs/<id>/wt when cwd is a repo, else cwd). Local only.")
3311
3571
  .option("--name <label>", "job id / tmux slug (letters/digits/_/-; default: <type>-<random>)")
3312
3572
  .option("--headless", "run-once-exit batch mode: claude -p / codex exec, stdout→output.log, write result.json, then END the session (no live agent; agy has no headless mode)")
3313
3573
  .option("--on-done <h2a-instance>", "h2a instance to notify with a `job.done` envelope when the job ends, and the parent for the decision channel (e.g. claude:remote:abc / claude:job:foo). Alias: --parent.")
@@ -3316,7 +3576,7 @@ export async function main(argv) {
3316
3576
  .option("--max-depth <d>", "spawn-depth budget granted to this job (clamp 1–3, default 1, à la Hermes). A LOCAL job inherits a DECREMENTED budget via REMOTE_DELEGATE_DEPTH; at 0 a nested `delegate` refuses. With --remote the budget is clamped to 1 (no env channel reaches the Pod yet → a job-in-a-Pod does not re-delegate).")
3317
3577
  .option("--track <wpId>", "mirror this job as a track item under workpackage <wpId> (`track item new --parent`, realized on done/failed). Best-effort: skipped silently if track is absent.")
3318
3578
  .option("--model <model>", "model override for the spawned agent (claude: --model; codex: -m). E.g. claude-sonnet-4-6, claude-opus-4-8, o3.")
3319
- .option("--effort <level>", "reasoning-effort override (claude only: --effort low|medium|high|xhigh|max). Silently ignored for non-claude types.")
3579
+ .option("--effort <level>", "reasoning-effort override (claude: --effort low|medium|high|xhigh|max; codex: model_reasoning_effort low|medium|high|xhigh).")
3320
3580
  .option("--account <id>", "force a specific account from the pool (bypass selectAccountWithFallback). E.g. claude-code-1234567890.")
3321
3581
  .action(async (type, task, opts) => {
3322
3582
  if (!isDelegateType(type)) {
@@ -3325,6 +3585,14 @@ export async function main(argv) {
3325
3585
  return;
3326
3586
  }
3327
3587
  const jobType = type;
3588
+ try {
3589
+ assertDelegateEffort(jobType, opts.effort);
3590
+ }
3591
+ catch (err) {
3592
+ process.stderr.write(`[h2a] ${err.message}\n`);
3593
+ process.exitCode = 1;
3594
+ return;
3595
+ }
3328
3596
  const jobId = opts.name ?? `${jobType}-${Math.random().toString(36).slice(2, 8)}`;
3329
3597
  // Parent h2a instance to notify on done + answer decisions (best-effort).
3330
3598
  const callbackTo = opts.onDone ?? opts.parent;
@@ -3352,7 +3620,7 @@ export async function main(argv) {
3352
3620
  // --- Concurrency cap (P4). The cap applies to BOTH local and remote
3353
3621
  // (shared RWX is multi-node; no CSI packing limit). Beyond `running`
3354
3622
  // jobs at the cap, ENQUEUE the job (jobState:"pending") instead of
3355
- // launching it; the conductor (`remote jobs conduct`) starts it later.
3623
+ // launching it; the conductor (`h2a jobs conduct`) starts it later.
3356
3624
  const cap = opts.maxConcurrent !== undefined
3357
3625
  ? Number.parseInt(opts.maxConcurrent, 10)
3358
3626
  : (getMaxConcurrent() ?? DEFAULT_MAX_CONCURRENT);
@@ -3445,7 +3713,7 @@ export async function main(argv) {
3445
3713
  if (!claimed && listJobs().some((e) => e.id === jobId)) {
3446
3714
  // The entry exists but the cap is full → stay queued.
3447
3715
  process.stderr.write(`[h2a] queued job ${jobId} (${jobType}${opts.headless ? " headless" : ""}${isRemote ? " remote" : ""}) — ` +
3448
- `${effectiveCap} concurrent slot(s) busy. Start it with: remote jobs conduct\n`);
3716
+ `${effectiveCap} concurrent slot(s) busy. Start it with: h2a jobs conduct\n`);
3449
3717
  process.stdout.write(`${jobId}\n`);
3450
3718
  return;
3451
3719
  }
@@ -3489,8 +3757,8 @@ export async function main(argv) {
3489
3757
  return;
3490
3758
  }
3491
3759
  process.stderr.write(`[h2a] delegated ${result.target === "remote" ? "REMOTE " : ""}job ${jobId} (${jobType}${opts.headless ? " headless" : ""}) ${result.target === "remote" ? "→ " : "in "}${result.detail}\n` +
3492
- `[h2a] supervise: remote jobs status ${jobId}` +
3493
- (opts.headless ? "" : ` attach: remote jobs attach ${jobId}`) +
3760
+ `[h2a] supervise: h2a jobs status ${jobId}` +
3761
+ (opts.headless ? "" : ` attach: h2a jobs attach ${jobId}`) +
3494
3762
  "\n");
3495
3763
  process.stdout.write(`${jobId}\n`);
3496
3764
  });
@@ -3812,7 +4080,7 @@ export async function main(argv) {
3812
4080
  await reconcileJobs();
3813
4081
  const job = listJobs().find((e) => e.id === id);
3814
4082
  if (!job) {
3815
- process.stderr.write(`[h2a] no job "${id}" (see: remote jobs ls)\n`);
4083
+ process.stderr.write(`[h2a] no job "${id}" (see: h2a jobs ls)\n`);
3816
4084
  process.exitCode = 1;
3817
4085
  return;
3818
4086
  }
@@ -3865,11 +4133,11 @@ export async function main(argv) {
3865
4133
  .action(async (id) => {
3866
4134
  const job = listJobs().find((e) => e.id === id);
3867
4135
  // REMOTE job (P2): exec into the Pod's tmux over the configured tunnel,
3868
- // reusing the same path as `remote attach <id> --exec`.
4136
+ // reusing the same path as `h2a attach <id> --exec`.
3869
4137
  if (job?.kind === "remote") {
3870
4138
  const remoteId = job.remoteId;
3871
4139
  if (!remoteId) {
3872
- process.stderr.write(`[h2a] remote job "${id}" has no session id recorded (see: remote jobs status ${id})\n`);
4140
+ process.stderr.write(`[h2a] remote job "${id}" has no session id recorded (see: h2a jobs status ${id})\n`);
3873
4141
  process.exitCode = 1;
3874
4142
  return;
3875
4143
  }
@@ -3892,7 +4160,7 @@ export async function main(argv) {
3892
4160
  // LOCAL job (P1): attach into the detached tmux session.
3893
4161
  const name = job?.tmuxSession ?? localSessionName(id);
3894
4162
  if (!findLocalSession(name)) {
3895
- process.stderr.write(`[h2a] no live tmux session for job "${id}" (it may have ended; see: remote jobs status ${id})\n`);
4163
+ process.stderr.write(`[h2a] no live tmux session for job "${id}" (it may have ended; see: h2a jobs status ${id})\n`);
3896
4164
  process.exitCode = 1;
3897
4165
  return;
3898
4166
  }
@@ -3904,7 +4172,7 @@ export async function main(argv) {
3904
4172
  .action((id) => {
3905
4173
  const job = listJobs().find((e) => e.id === id);
3906
4174
  if (!job) {
3907
- process.stderr.write(`[h2a] no job "${id}" (see: remote jobs ls)\n`);
4175
+ process.stderr.write(`[h2a] no job "${id}" (see: h2a jobs ls)\n`);
3908
4176
  process.exitCode = 1;
3909
4177
  return;
3910
4178
  }
@@ -3916,7 +4184,7 @@ export async function main(argv) {
3916
4184
  }
3917
4185
  // REMOTE job: the output lives in the Pod's tmux, not a local pane.
3918
4186
  if (job.kind === "remote") {
3919
- process.stderr.write(`[h2a] remote job "${id}" runs in a Pod — view it live with: remote jobs attach ${id}\n`);
4187
+ process.stderr.write(`[h2a] remote job "${id}" runs in a Pod — view it live with: h2a jobs attach ${id}\n`);
3920
4188
  process.exitCode = 1;
3921
4189
  return;
3922
4190
  }
@@ -3931,7 +4199,7 @@ export async function main(argv) {
3931
4199
  });
3932
4200
  // P3 — the decision channel (h2a). A delegated INTERACTIVE job can emit a
3933
4201
  // `decision.requested` envelope; the parent lists them here and answers with
3934
- // `decide`. Both ride the local h2a inbox + `remote h2a bridge` to the Pod.
4202
+ // `decide`. Both ride the local h2a inbox + `h2a relay bridge` to the Pod.
3935
4203
  jobsCommand
3936
4204
  .command("decisions")
3937
4205
  .description("List unanswered `decision.requested` envelopes from delegated jobs (read from the local h2a inbox), with the asking job id and its question.")
@@ -3954,7 +4222,7 @@ export async function main(argv) {
3954
4222
  });
3955
4223
  jobsCommand
3956
4224
  .command("decide <jobId> <answer>")
3957
- .description("Answer a job's `decision.requested`: write a `decision.reply` envelope into the job's h2a inbox (carried to its Pod by `remote h2a bridge`). <answer> is a single argv — never shell-concatenated.")
4225
+ .description("Answer a job's `decision.requested`: write a `decision.reply` envelope into the job's h2a inbox (carried to its Pod by `h2a relay bridge`). <answer> is a single argv — never shell-concatenated.")
3958
4226
  .option("--from <h2a-instance>", "parent/master h2a instance to attribute the reply to (default: the job's recorded parent, else `remote:cli`)")
3959
4227
  .action((jobId, answer, opts) => {
3960
4228
  try {
@@ -3967,7 +4235,7 @@ export async function main(argv) {
3967
4235
  }
3968
4236
  const job = listJobs().find((e) => e.id === jobId);
3969
4237
  if (!job) {
3970
- process.stderr.write(`[h2a] no job "${jobId}" (see: remote jobs ls)\n`);
4238
+ process.stderr.write(`[h2a] no job "${jobId}" (see: h2a jobs ls)\n`);
3971
4239
  process.exitCode = 1;
3972
4240
  return;
3973
4241
  }
@@ -3982,7 +4250,7 @@ export async function main(argv) {
3982
4250
  process.stderr.write(`[h2a] decision.reply for ${jobId} → ${envelope.to}` +
3983
4251
  `${written ? "" : " (already present, not overwritten)"}\n` +
3984
4252
  `[h2a] envelope: ${path}\n` +
3985
- `[h2a] delivered to the job's Pod on the next: remote h2a bridge\n`);
4253
+ `[h2a] delivered to the job's Pod on the next: h2a relay bridge\n`);
3986
4254
  }
3987
4255
  catch (error) {
3988
4256
  process.stderr.write(`[h2a] failed to write decision.reply: ${(error instanceof Error ? error.message : String(error)).slice(0, 160)}\n`);
@@ -4269,7 +4537,7 @@ export async function main(argv) {
4269
4537
  return { launched: false, detail: result.error };
4270
4538
  }
4271
4539
  process.stderr.write(`[h2a] conductor-launch: launched ${host} conductor ${slug} for ${request.workspaceId} in ${result.detail}\n` +
4272
- `[h2a] supervise: remote jobs status ${slug} attach: remote jobs attach ${slug}\n`);
4540
+ `[h2a] supervise: h2a jobs status ${slug} attach: h2a jobs attach ${slug}\n`);
4273
4541
  return { launched: true, detail: `${host} ${slug}` };
4274
4542
  };
4275
4543
  /** One pass: handle the freshest unprocessed launch envelope (or report none). */
@@ -4429,7 +4697,7 @@ export async function main(argv) {
4429
4697
  }
4430
4698
  const pane = resolveAgentPaneForInstance(target);
4431
4699
  if (!pane) {
4432
- process.stderr.write(`[h2a] wake-request: no agent pane for ${target} — not launched by remote, skipping (reason: ${reason || "none"})\n`);
4700
+ process.stderr.write(`[h2a] wake-request: no agent pane for ${target} — not launched by h2a, skipping (reason: ${reason || "none"})\n`);
4433
4701
  continue;
4434
4702
  }
4435
4703
  // Guard (h2a 0.74.0 parity): defer send-keys if a human was active in the
@@ -4481,7 +4749,7 @@ export async function main(argv) {
4481
4749
  .command("wake-request")
4482
4750
  .description("Handle an h2a `wake-request` envelope: read the local h2a inbox for a wake-request, " +
4483
4751
  "resolve the target agent pane from launch records, and send tmux send-keys to wake it. " +
4484
- "No-op if no pane is known for the target (agent not launched by remote). " +
4752
+ "No-op if no pane is known for the target (agent not launched by h2a). " +
4485
4753
  "Idempotent: ignores duplicate wake-requests received within 60s. " +
4486
4754
  "--watch loops in the foreground.")
4487
4755
  .option("--root <path>", "h2a root (default: ~/h2a-workspace/.h2a)")
@@ -4588,7 +4856,7 @@ export async function main(argv) {
4588
4856
  // Reliability slice 2 (throttle phase 2) — auto-resume RATE-LIMITED INTERACTIVE
4589
4857
  // local tmux sessions, staggered + hands-off.
4590
4858
  //
4591
- // INTERACTIVE `remote run` sessions (claude/codex/agy in a live pane) do NOT
4859
+ // INTERACTIVE `h2a run` sessions (claude/codex/agy in a live pane) do NOT
4592
4860
  // exit on a transient provider rate-limit — they STALL until a human pokes them.
4593
4861
  // This passes the pure `planInteractiveResume` the per-session pane tail
4594
4862
  // (detectThrottle source), `#{session_attached}` (the HARD detached-only guard),
@@ -4743,18 +5011,19 @@ export async function main(argv) {
4743
5011
  process.exitCode = await watchRefreshLoop(minutes, async () => runOnce());
4744
5012
  });
4745
5013
  // ---------------------------------------------------------------------------
4746
- // h2a — bridge the local agent network (~/h2a-workspace/.h2a) with session Pods
5014
+ // relay — bridge the local agent network (~/h2a-workspace/.h2a) with session Pods
4747
5015
  // ---------------------------------------------------------------------------
4748
- const h2aCommand = program
4749
- .command("h2a")
5016
+ const relayCommand = program
5017
+ .command("relay")
5018
+ .alias("h2a")
4750
5019
  .description("h2a agent-network helpers (local file store: ~/h2a-workspace/.h2a)");
4751
- h2aCommand
5020
+ relayCommand
4752
5021
  .command("ping <instance>")
4753
5022
  .description("Drop an `h2a.ping` envelope into the local h2a inbox for <instance> (e.g. codex:remote:sess-1). Use --bridge to push it to a remote session immediately.")
4754
5023
  .option("--from <instance>", "sender h2a instance", "remote:cli")
4755
5024
  .option("-m, --message <text>", "ping message body", "ping")
4756
5025
  .option("--root <path>", "local h2a root (default: ~/h2a-workspace/.h2a)")
4757
- .option("--bridge [sessionId]", "after queuing the ping, run one h2a bridge pass for this remote session (defaults from <instance> when it is <tool>:remote:<sessionId>)")
5026
+ .option("--bridge [sessionId]", "after queuing the ping, run one relay bridge pass for this remote session (defaults from <instance> when it is <tool>:remote:<sessionId>)")
4758
5027
  .action(async (instance, opts) => {
4759
5028
  const ping = sendH2aPing({
4760
5029
  to: instance,
@@ -4763,7 +5032,7 @@ export async function main(argv) {
4763
5032
  cwd: process.cwd(),
4764
5033
  ...(opts.root !== undefined ? { localRoot: opts.root } : {}),
4765
5034
  });
4766
- process.stderr.write(`[h2a] h2a ping ${ping.written ? "queued" : "already queued"} for ${instance}: ${ping.path}\n`);
5035
+ process.stderr.write(`[h2a] relay ping ${ping.written ? "queued" : "already queued"} for ${instance}: ${ping.path}\n`);
4767
5036
  if (opts.bridge === undefined)
4768
5037
  return;
4769
5038
  const sessionId = typeof opts.bridge === "string"
@@ -4781,28 +5050,28 @@ export async function main(argv) {
4781
5050
  const { bridgeSession } = await import("./h2a-bridge.js");
4782
5051
  try {
4783
5052
  const r = await bridgeSession(sessionId, { profile });
4784
- process.stderr.write(`[h2a] h2a bridge ${sessionId} (${profile}) pulled=${r.pulled} pushed=${r.pushed} skipped=${r.skipped}` +
5053
+ process.stderr.write(`[h2a] relay bridge ${sessionId} (${profile}) pulled=${r.pulled} pushed=${r.pushed} skipped=${r.skipped}` +
4785
5054
  `${r.failed > 0 ? ` failed=${r.failed}` : ""}\n`);
4786
5055
  if (r.failed > 0)
4787
5056
  process.exitCode = 1;
4788
5057
  }
4789
5058
  catch (error) {
4790
- process.stderr.write(`[h2a] h2a bridge ${sessionId} failed: ${(error instanceof Error ? error.message : String(error)).slice(0, 200)}\n`);
5059
+ process.stderr.write(`[h2a] relay bridge ${sessionId} failed: ${(error instanceof Error ? error.message : String(error)).slice(0, 200)}\n`);
4791
5060
  process.exitCode = 1;
4792
5061
  }
4793
5062
  });
4794
- h2aCommand
5063
+ relayCommand
4795
5064
  .command("bridge [sessionId]")
4796
5065
  .description("Bridge h2a envelopes with session Pod(s) over kubectl exec: PULL envelopes the Pod's agent emitted (Pod inbox/* minus the Pod's own instances) into the local inboxes, PUSH local envelopes addressed to the Pod's instances (<tool>:remote:<sessionId> + the Pod's registry) into the Pod. Idempotent by file name (existing files are skipped, never overwritten), NEVER deletes (acks/cleanup belong to h2a). A Pod without ~/h2a-workspace/.h2a gets the skeleton + README. Without sessionId: every live session.")
4797
5066
  .option("--watch <minutes>", "repeat the bridge every N minutes in the FOREGROUND; Ctrl-C to stop")
4798
5067
  .addHelpText("after", [
4799
5068
  "",
4800
5069
  "Examples:",
4801
- " $ remote h2a bridge sess-abc one pass for one session",
4802
- " $ remote h2a bridge one pass for every live session",
4803
- " $ remote h2a bridge --watch 5 foreground loop, every 5 min",
5070
+ " $ h2a relay bridge sess-abc one pass for one session",
5071
+ " $ h2a relay bridge one pass for every live session",
5072
+ " $ h2a relay bridge --watch 5 foreground loop, every 5 min",
4804
5073
  " Run the watch in a dedicated tmux window, e.g.:",
4805
- " tmux new-window -n h2a-bridge 'remote h2a bridge --watch 5'",
5074
+ " tmux new-window -n h2a-bridge 'h2a relay bridge --watch 5'",
4806
5075
  "",
4807
5076
  ].join("\n"))
4808
5077
  .action(async (sessionId, opts) => {
@@ -4848,13 +5117,13 @@ export async function main(argv) {
4848
5117
  const r = await bridgeSession(s.id, { profile: s.profile });
4849
5118
  if (r.failed > 0)
4850
5119
  failed += 1;
4851
- process.stderr.write(`[h2a] h2a bridge ${s.id} (${s.profile}) pulled=${r.pulled} pushed=${r.pushed} skipped=${r.skipped}` +
5120
+ process.stderr.write(`[h2a] relay bridge ${s.id} (${s.profile}) pulled=${r.pulled} pushed=${r.pushed} skipped=${r.skipped}` +
4852
5121
  `${r.failed > 0 ? ` failed=${r.failed}` : ""}` +
4853
5122
  `${r.scaffolded ? " (pod .h2a scaffolded)" : ""}\n`);
4854
5123
  }
4855
5124
  catch (error) {
4856
5125
  failed += 1;
4857
- process.stderr.write(`[h2a] h2a bridge ${s.id} failed: ${(error instanceof Error ? error.message : String(error)).slice(0, 200)}\n`);
5126
+ process.stderr.write(`[h2a] relay bridge ${s.id} failed: ${(error instanceof Error ? error.message : String(error)).slice(0, 200)}\n`);
4858
5127
  }
4859
5128
  }
4860
5129
  return { failed };
@@ -4966,7 +5235,16 @@ export async function main(argv) {
4966
5235
  if (result.backupPath) {
4967
5236
  process.stderr.write(`[h2a] backup: ${result.backupPath}\n`);
4968
5237
  }
4969
- process.stderr.write(`[h2a] installed ${result.installed.join(" + ")} enroll hooks in ${result.settingsPath}\n`);
5238
+ process.stderr.write(`[h2a] ${[
5239
+ result.installed.length > 0
5240
+ ? `installed ${result.installed.join(" + ")}`
5241
+ : undefined,
5242
+ result.migrated.length > 0
5243
+ ? `migrated ${result.migrated.join(" + ")}`
5244
+ : undefined,
5245
+ ]
5246
+ .filter((part) => part !== undefined)
5247
+ .join("; ")} enroll hooks in ${result.settingsPath}\n`);
4970
5248
  return;
4971
5249
  }
4972
5250
  if (opts.hook) {
@@ -5030,12 +5308,12 @@ export async function main(argv) {
5030
5308
  // Local tmux session? (unless an explicit URL/sessionId pair is given).
5031
5309
  if (second === undefined && !looksLikeUrl(first)) {
5032
5310
  const local = findLocalSession(first);
5033
- // A session started by `remote run` is enrolled in the registry as
5311
+ // A session started by `h2a run` is enrolled in the registry as
5034
5312
  // kind:"local-tmux". Trust that durable record even when a transient
5035
5313
  // `tmux list-sessions` miss hides the live session — otherwise a purely
5036
5314
  // LOCAL session is mis-routed to a k8s Pod (kubectl exec session-<name>
5037
5315
  // → NotFound → reconnect loop) as soon as a tunnel is configured.
5038
- // `remote ls` already merges registry + tmux; attach must not be less
5316
+ // `h2a ls` already merges registry + tmux; attach must not be less
5039
5317
  // reliable. When the registry says local but tmux can't reach it,
5040
5318
  // attachLocalSession fails with a clear LOCAL error (never a Pod).
5041
5319
  const localName = local?.name ?? localTmuxSessionForName(first, loadRegistry());
@@ -5908,6 +6186,8 @@ if (isEntryPoint()) {
5908
6186
  process.exitCode = 1;
5909
6187
  });
5910
6188
  }
6189
+ export { main as dispatchH2a };
6190
+ /** @deprecated Compatibility for older h2a cores; new cores require dispatchH2a. */
5911
6191
  export { main as dispatch };
5912
6192
  /**
5913
6193
  * h2a-facing surface: the read-only remote-agents projection consumed by the