@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.
- package/dist/agent-launch-args.d.ts +26 -0
- package/dist/agent-launch-args.d.ts.map +1 -0
- package/dist/agent-launch-args.js +65 -0
- package/dist/agent-launch-args.js.map +1 -0
- package/dist/delegate.d.ts +17 -8
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +70 -33
- package/dist/delegate.js.map +1 -1
- package/dist/enroll.d.ts +7 -3
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +65 -13
- package/dist/enroll.js.map +1 -1
- package/dist/h2a-bridge.d.ts +1 -1
- package/dist/h2a-bridge.js +2 -2
- package/dist/h2a-bridge.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +364 -84
- package/dist/index.js.map +1 -1
- package/dist/launch-context.d.ts +2 -0
- package/dist/launch-context.d.ts.map +1 -1
- package/dist/launch-context.js +10 -5
- package/dist/launch-context.js.map +1 -1
- package/dist/llm-gateway-runtime/accounts.d.ts +17 -3
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/accounts.js +44 -7
- package/dist/llm-gateway-runtime/accounts.js.map +1 -1
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/index.js +6 -0
- package/dist/llm-gateway-runtime/index.js.map +1 -1
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/model-catalog.js +12 -2
- package/dist/llm-gateway-runtime/model-catalog.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js +13 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +5 -2
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.js +41 -18
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -1
- package/dist/llm-gateway-runtime/sticky.d.ts +11 -2
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/sticky.js +325 -31
- package/dist/llm-gateway-runtime/sticky.js.map +1 -1
- package/dist/readiness.js +2 -2
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +6 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +10 -1
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +2 -0
- package/dist/restore.js.map +1 -1
- package/dist/tmux.d.ts +71 -13
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +301 -34
- package/dist/tmux.js.map +1 -1
- 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/
|
|
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 `
|
|
91
|
-
* of dead members reuses the existing `
|
|
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 `
|
|
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 `
|
|
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 (`
|
|
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 `
|
|
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
|
-
|
|
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:
|
|
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] ?? "
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// (`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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
|
-
|
|
3266
|
-
|
|
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 `
|
|
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 `
|
|
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 .
|
|
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
|
|
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 (`
|
|
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:
|
|
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:
|
|
3493
|
-
(opts.headless ? "" : ` attach:
|
|
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:
|
|
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 `
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 + `
|
|
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 `
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
//
|
|
5014
|
+
// relay — bridge the local agent network (~/h2a-workspace/.h2a) with session Pods
|
|
4747
5015
|
// ---------------------------------------------------------------------------
|
|
4748
|
-
const
|
|
4749
|
-
.command("
|
|
5016
|
+
const relayCommand = program
|
|
5017
|
+
.command("relay")
|
|
5018
|
+
.alias("h2a")
|
|
4750
5019
|
.description("h2a agent-network helpers (local file store: ~/h2a-workspace/.h2a)");
|
|
4751
|
-
|
|
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
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
-
" $
|
|
4802
|
-
" $
|
|
4803
|
-
" $
|
|
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 '
|
|
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]
|
|
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]
|
|
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]
|
|
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 `
|
|
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
|
-
// `
|
|
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
|