@sentropic/h2a-cli 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/bin.js +4 -1
  2. package/dist/bin.js.map +1 -1
  3. package/dist/cli-contract.d.ts.map +1 -1
  4. package/dist/cli-contract.js +20 -2
  5. package/dist/cli-contract.js.map +1 -1
  6. package/dist/cli.d.ts +25 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +294 -25
  9. package/dist/cli.js.map +1 -1
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp.d.ts +1 -1
  15. package/dist/mcp.d.ts.map +1 -1
  16. package/dist/mcp.js +2 -1
  17. package/dist/mcp.js.map +1 -1
  18. package/dist/runtime/governance/conductor.d.ts +67 -0
  19. package/dist/runtime/governance/conductor.d.ts.map +1 -0
  20. package/dist/runtime/governance/conductor.js +77 -0
  21. package/dist/runtime/governance/conductor.js.map +1 -0
  22. package/dist/runtime/governance/index.d.ts +2 -0
  23. package/dist/runtime/governance/index.d.ts.map +1 -0
  24. package/dist/runtime/governance/index.js +2 -0
  25. package/dist/runtime/governance/index.js.map +1 -0
  26. package/dist/runtime/identity/index.d.ts +2 -1
  27. package/dist/runtime/identity/index.d.ts.map +1 -1
  28. package/dist/runtime/identity/index.js +1 -1
  29. package/dist/runtime/identity/index.js.map +1 -1
  30. package/dist/runtime/identity/live.d.ts.map +1 -1
  31. package/dist/runtime/identity/live.js +7 -2
  32. package/dist/runtime/identity/live.js.map +1 -1
  33. package/dist/runtime/identity/readers.d.ts +31 -0
  34. package/dist/runtime/identity/readers.d.ts.map +1 -1
  35. package/dist/runtime/identity/readers.js +134 -0
  36. package/dist/runtime/identity/readers.js.map +1 -1
  37. package/dist/runtime/mcp/handlers.d.ts +8 -0
  38. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  39. package/dist/runtime/mcp/handlers.js +44 -2
  40. package/dist/runtime/mcp/handlers.js.map +1 -1
  41. package/dist/runtime/mcp/server.d.ts.map +1 -1
  42. package/dist/runtime/mcp/server.js +3 -1
  43. package/dist/runtime/mcp/server.js.map +1 -1
  44. package/dist/runtime/mcp/tools.d.ts.map +1 -1
  45. package/dist/runtime/mcp/tools.js +18 -1
  46. package/dist/runtime/mcp/tools.js.map +1 -1
  47. package/package.json +2 -2
  48. package/skills/h2a/SKILL.md +19 -1
package/dist/cli.js CHANGED
@@ -36,20 +36,20 @@
36
36
  * The full machine-readable manifest lives in `./cli-contract.ts`
37
37
  * (`H2A_CLI_VERB_CONTRACTS`). Human-readable reference: `docs/cli-contract.md`.
38
38
  */
39
- import { spawnSync } from "node:child_process";
39
+ import { execFileSync, spawnSync } from "node:child_process";
40
40
  import { generateKeyPairSync } from "node:crypto";
41
41
  import { copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
42
- import { homedir } from "node:os";
42
+ import { homedir, hostname } from "node:os";
43
43
  import { dirname, join, resolve as resolvePath } from "node:path";
44
44
  import { fileURLToPath } from "node:url";
45
- import { H2A_ATTESTER_COMPREHENSION_RIGHT, H2A_COMPREHENSION_ATTESTATION_BODY_KIND, H2A_DECLARATION_INTERET_BODY_KIND, H2A_ORG_MANIFEST_FILENAME, H2A_ORG_PROPOSAL_BODY_KIND, H2A_ORG_RATIFIED_BODY_KIND, H2A_ROLES, H2A_WORK_STATUSES, auditNhiPosture, buildComprehensionAttestation, canAttestComprehension, checkEnvelopeFreshness, computeHash, createEnvelope, diffOrgManifest, effectiveOrgInstances, isComprehensionAttestation, nhiAttestationEnvelope, nhiInventory, nhiTrustBundle, orgAssignmentEnvelope, parseOrgManifest, signCanonical, signEnvelope, subagentAddress, validateOrgManifest, verifyComprehensionAttestation } from "@sentropic/h2a";
45
+ import { H2A_ATTESTER_COMPREHENSION_RIGHT, H2A_COMPREHENSION_ATTESTATION_BODY_KIND, H2A_DECLARATION_INTERET_BODY_KIND, H2A_ORG_MANIFEST_FILENAME, H2A_ORG_PROPOSAL_BODY_KIND, H2A_ORG_RATIFIED_BODY_KIND, H2A_ROLES, H2A_WORK_STATUSES, auditNhiPosture, buildComprehensionAttestation, canAttestComprehension, checkEnvelopeFreshness, computeHash, createEnvelope, deriveWorkspaceId, diffOrgManifest, effectiveOrgInstances, isComprehensionAttestation, nhiAttestationEnvelope, nhiInventory, nhiTrustBundle, orgAssignmentEnvelope, parseOrgManifest, signCanonical, signEnvelope, subagentAddress, validateOrgManifest, verifyComprehensionAttestation } from "@sentropic/h2a";
46
46
  import { H2A_CLAUDE_HOST } from "./hosts/claude.js";
47
47
  import { H2A_CODEX_HOST } from "./hosts/codex.js";
48
48
  import { H2A_GEMINI_HOST } from "./hosts/gemini.js";
49
49
  import { H2A_AGY_HOST } from "./hosts/agy.js";
50
50
  import { H2A_CLI_MCP_TOOL_NAMES } from "./mcp.js";
51
51
  import { renderStopHook, claudeStopHookEntry, claudeDriveReceiveHookEntry, isH2ADriveReceiveHook, isH2ARecordHook, codexPluginManifest, codexMarketplaceManifest, codexPluginTrustCommands, H2A_CODEX_PLUGIN_NAME, H2A_HOST_PLUGIN_HOSTS } from "./hosts/plugin.js";
52
- import { H2A_STORE_SCHEMA_VERSION, assertHostQualifiedAddress, canonicalAddress, createLocalStore, listPresence, resolveRecipient, safePathSegment, sanitizeStorePaths } from "./runtime/local-files/index.js";
52
+ import { H2A_STORE_SCHEMA_VERSION, assertHostQualifiedAddress, canonicalAddress, createLocalStore, listPresence, readPresence, resolveRecipient, safePathSegment, sanitizeStorePaths, writePresence } from "./runtime/local-files/index.js";
53
53
  import { runMcpStdio } from "./runtime/mcp/index.js";
54
54
  import { renderK8sSidecar } from "./runtime/deploy/k8s-sidecar.js";
55
55
  import { renderK8sTenant } from "./runtime/deploy/k8s-tenant.js";
@@ -63,6 +63,7 @@ import { authorizeDrive, chainDriver, formatSignedDriveInstruction, headlessDriv
63
63
  import { verifyEnvelopeSysmlRef } from "./runtime/sysml/index.js";
64
64
  import { checkUpgrade, performUpgrade, currentCliVersion, upgradeCachePath, canReexec, reexecSelf, H2A_AUTO_UPGRADE_CHECK_TTL_MS, H2A_REEXEC_GUARD_ENV, H2A_UPGRADE_CHECK_TTL_MS } from "./runtime/upgrade/index.js";
65
65
  import { resolveLiveIdentity } from "./runtime/identity/index.js";
66
+ import { conductorFor } from "./runtime/governance/conductor.js";
66
67
  const HERE = dirname(fileURLToPath(import.meta.url));
67
68
  // `dist/cli.js` lives in `packages/h2a-cli/dist/`; skills are at
68
69
  // `packages/h2a-cli/skills/`. Two levels up from the dist file.
@@ -172,7 +173,10 @@ export function renderCliHelp() {
172
173
  "",
173
174
  "High-level coordination (DEC-054):",
174
175
  " h2a connect --host <codex|claude|gemini|agy|remote> [--root <path>] [--instance <id>] [--name <display>]",
175
- " h2a doctor [--root <path>] [--scan <dir>]",
176
+ " h2a conductor [--workspace <id|path>] [--root <path>] (who is the live conductor/owner of a workspace — derived from presence; conductor=role CONDUCTOR if set, else null; candidates=in-workspace live agents)",
177
+ " h2a doctor [--root <path>] [--scan <dir>] [--prune] (--prune deletes host-less/phantom/orphan inbox dirs + stray buses; dry-run by default)",
178
+ " h2a keepalive [--root <path>] [--interval <ms>] [--once] (external keepalive prober — refreshes presence for agents whose tmux pane is still alive)",
179
+ " h2a rename --instance <id> --name <name> [--root <path>] (set a live session's display name so peers can find it via discover --name)",
176
180
  " h2a status [--root <path>] [--scope <s>] [--instance <i>]",
177
181
  " h2a sessions [--root <path>] [--scope <s>] [--instance <i>]",
178
182
  " h2a thread --id <threadId> --instance <self> [--root <path>] (the ordered conversation for a thread, from your inbox+outbox)",
@@ -219,12 +223,12 @@ function parseFlags(argv) {
219
223
  /**
220
224
  * Resolve the h2a store root, with its provenance.
221
225
  *
222
- * Precedence: explicit `--root` flag → `H2A_ROOT` env → the `cwd/.h2a` fallback.
223
- * Honoring `H2A_ROOT` here brings the stdio CLI (incl. `mcp-serve`) to parity
224
- * with the HTTP/k8s servers, which already read it (mcp-http/serve.ts). The
225
- * `cwd` fallback silently forks an agent onto a repo-local bus that no peer on
226
- * the shared root can see — the split-brain root failure (F4). Callers on
227
- * long-lived paths (`mcp-serve`, `connect`) warn when `source === "cwd"`.
226
+ * Precedence: explicit `--root` flag → `H2A_ROOT` env → the shared global
227
+ * default `~/h2a-workspace/.h2a`. The global default lets all agents on the
228
+ * same machine share one bus without any explicit configuration — eliminating
229
+ * the split-brain failure (F4) that the old `cwd/.h2a` fallback caused.
230
+ * Callers on long-lived paths (`mcp-serve`, `connect`) warn when a repo-local
231
+ * `.h2a` exists and is being ignored in favour of the shared bus.
228
232
  */
229
233
  function resolveRootInfo(flags, cwd) {
230
234
  if (flags.root)
@@ -232,24 +236,43 @@ function resolveRootInfo(flags, cwd) {
232
236
  const env = process.env.H2A_ROOT;
233
237
  if (env && env.length > 0)
234
238
  return { root: env, source: "env" };
235
- return { root: join(cwd(), ".h2a"), source: "cwd" };
239
+ return { root: join(homedir(), "h2a-workspace", ".h2a"), source: "default" };
236
240
  }
237
241
  function resolveRoot(flags, cwd) {
238
242
  return resolveRootInfo(flags, cwd).root;
239
243
  }
240
244
  /**
241
- * Warn (once, to stderr) when the root was taken from the `cwd` fallback rather
242
- * than an explicit `--root`/`H2A_ROOT`. Used by the long-lived `mcp-serve` /
243
- * `connect` paths that establish an agent's bus — a silent cwd fork there is the
244
- * split-brain root bug. One-shot verbs stay quiet.
245
+ * Warn (once, to stderr) when the root was taken from the shared default but a
246
+ * DIFFERENT repo-local `.h2a` exists in the current working directory. The
247
+ * user may have intended to use the local bus — so we alert them to pass
248
+ * `--root <cwd>/.h2a` if that was their intent. When the default IS the right
249
+ * bus (no local `.h2a` around), stay silent. Used by `mcp-serve` and `connect`.
245
250
  */
246
251
  function warnIfCwdRootFallback(flags, cwd, streams) {
247
252
  const info = resolveRootInfo(flags, cwd);
248
- if (info.source !== "cwd")
253
+ if (info.source !== "default")
249
254
  return;
250
- streams.stderr.write(`h2a: no --root/H2A_ROOT set — using this repo's local bus ${info.root}. ` +
251
- `Agents on different roots cannot see each other; if you meant the shared bus, ` +
252
- `set H2A_ROOT (e.g. ~/h2a-workspace/.h2a) or pass --root. Run \`h2a doctor\` to check.\n`);
255
+ const cwdLocal = join(cwd(), ".h2a");
256
+ try {
257
+ if (!existsSync(cwdLocal))
258
+ return;
259
+ const resolvedLocal = realpathSync(cwdLocal);
260
+ const resolvedRoot = (() => {
261
+ try {
262
+ return realpathSync(info.root);
263
+ }
264
+ catch {
265
+ return info.root;
266
+ }
267
+ })();
268
+ if (resolvedLocal === resolvedRoot)
269
+ return;
270
+ streams.stderr.write(`h2a: a repo-local .h2a exists here but I'm using the shared bus ${info.root}. ` +
271
+ `Pass --root ${cwdLocal} if you meant the local one.\n`);
272
+ }
273
+ catch {
274
+ // silently ignore filesystem errors
275
+ }
253
276
  }
254
277
  /**
255
278
  * Resolve `causationId` / `correlationId` for a new negotiation event.
@@ -2970,6 +2993,70 @@ function cmdStatus(flags, streams) {
2970
2993
  return 3;
2971
2994
  }
2972
2995
  }
2996
+ /**
2997
+ * Read the stable machine-id for workspace derivation (mirrors live.ts).
2998
+ * Used only by `cmdConductor` when the caller passes a filesystem path.
2999
+ */
3000
+ function cliReadMachineId() {
3001
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
3002
+ try {
3003
+ const id = readFileSync(p, "utf8").trim();
3004
+ if (id.length > 0)
3005
+ return id;
3006
+ }
3007
+ catch {
3008
+ // try next source
3009
+ }
3010
+ }
3011
+ return hostname() || "unknown-machine";
3012
+ }
3013
+ /**
3014
+ * `h2a conductor [--workspace <id|path>] [--root <path>]`
3015
+ *
3016
+ * Resolve the live conductor/owner of a workspace (WP-G1, read-only).
3017
+ * --workspace accepts a workspace id (`ws:…`) OR a filesystem path; a path
3018
+ * is resolved to a workspaceId via the same derivation presence uses. If
3019
+ * omitted, defaults to cwd.
3020
+ *
3021
+ * Output shape: resource (JSON of ConductorResolution). Exit 0 always.
3022
+ */
3023
+ function cmdConductor(flags, streams) {
3024
+ const cwd = streams.cwd ?? (() => process.cwd());
3025
+ const root = resolveRoot(flags, cwd);
3026
+ let workspaceId;
3027
+ const wsFlag = flags.workspace;
3028
+ if (!wsFlag) {
3029
+ // Default to cwd
3030
+ const cwdPath = cwd();
3031
+ let realPath = cwdPath;
3032
+ try {
3033
+ realPath = realpathSync(cwdPath);
3034
+ }
3035
+ catch { /* use cwd as-is */ }
3036
+ workspaceId = deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
3037
+ }
3038
+ else if (wsFlag.startsWith("ws:")) {
3039
+ workspaceId = wsFlag;
3040
+ }
3041
+ else {
3042
+ // Treat as a filesystem path
3043
+ let realPath = wsFlag;
3044
+ try {
3045
+ realPath = realpathSync(wsFlag);
3046
+ }
3047
+ catch { /* use as-is */ }
3048
+ workspaceId = deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
3049
+ }
3050
+ try {
3051
+ const result = conductorFor({ root, workspaceId });
3052
+ streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
3053
+ return 0;
3054
+ }
3055
+ catch (error) {
3056
+ streams.stderr.write(`h2a conductor: ${error.message}\n`);
3057
+ return 1;
3058
+ }
3059
+ }
2973
3060
  function cmdDoctor(flags, streams) {
2974
3061
  const cwd = streams.cwd ?? (() => process.cwd());
2975
3062
  const root = resolveRoot(flags, cwd);
@@ -3037,11 +3124,11 @@ function cmdDoctor(flags, streams) {
3037
3124
  // (a) rootSource: record where the root came from
3038
3125
  const rootInfo = resolveRootInfo(flags, cwd);
3039
3126
  report.rootSource = rootInfo.source;
3040
- if (rootInfo.source === "cwd") {
3127
+ if (rootInfo.source === "default") {
3041
3128
  warnings.push({
3042
3129
  check: "rootSource",
3043
- message: `The bus root is the repo-local cwd fallback (${root}). ` +
3044
- `Set H2A_ROOT or pass --root <shared-bus-path> so all agents see each other. ` +
3130
+ message: `The bus root is the global shared default (${root}). ` +
3131
+ `Set H2A_ROOT or pass --root <path> to use a different bus. ` +
3045
3132
  `Agents on different roots cannot exchange messages.`
3046
3133
  });
3047
3134
  }
@@ -3072,6 +3159,8 @@ function cmdDoctor(flags, streams) {
3072
3159
  }
3073
3160
  // (c) inboxHygiene: scan inbox for case/slug duplicates, host-less dirs, phantom 3-segment dirs
3074
3161
  const inboxDir = join(root, "inbox");
3162
+ // Collect dirs to prune (with --prune only).
3163
+ const pruned = [];
3075
3164
  if (existsSync(inboxDir)) {
3076
3165
  let inboxEntries = [];
3077
3166
  try {
@@ -3111,7 +3200,7 @@ function cmdDoctor(flags, streams) {
3111
3200
  examples,
3112
3201
  message: `${dupGroups.length} case/slug duplicate group(s) in inbox — ` +
3113
3202
  `different dir names map to the same canonical address. ` +
3114
- `Examples: ${examples.join(", ")}`
3203
+ `Examples: ${examples.join(", ")}. (Not pruned — too risky; resolve manually.)`
3115
3204
  });
3116
3205
  }
3117
3206
  // Host-less dirs: first segment (before first __) is not a known host
@@ -3129,6 +3218,18 @@ function cmdDoctor(flags, streams) {
3129
3218
  message: `${hostless.length} host-less inbox dir(s) (first segment is not claude/codex/gemini/remote). ` +
3130
3219
  `Examples: ${examples.join(", ")}`
3131
3220
  });
3221
+ if (flags.prune !== undefined) {
3222
+ for (const name of hostless) {
3223
+ const dir = join(inboxDir, name);
3224
+ try {
3225
+ rmSync(dir, { recursive: true, force: true });
3226
+ pruned.push({ name, path: dir });
3227
+ }
3228
+ catch (error) {
3229
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3230
+ }
3231
+ }
3232
+ }
3132
3233
  }
3133
3234
  // Phantom 3-segment dirs: exactly 3 __ segments but 3rd is not a 12-char hex session id
3134
3235
  const VALID_TAIL_RE = /^[0-9a-f]{12}$/i;
@@ -3148,12 +3249,72 @@ function cmdDoctor(flags, streams) {
3148
3249
  message: `${phantom.length} phantom 3-segment inbox dir(s) where the tail is not a 12-char hex session id. ` +
3149
3250
  `Examples: ${examples.join(", ")}`
3150
3251
  });
3252
+ if (flags.prune !== undefined) {
3253
+ for (const name of phantom) {
3254
+ const dir = join(inboxDir, name);
3255
+ try {
3256
+ rmSync(dir, { recursive: true, force: true });
3257
+ pruned.push({ name, path: dir });
3258
+ }
3259
+ catch (error) {
3260
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3261
+ }
3262
+ }
3263
+ }
3264
+ }
3265
+ // Orphan 3-segment dirs: 3 segments, valid hex tail, but UUID is NOT in registry/instances.jsonl
3266
+ if (flags.prune !== undefined) {
3267
+ const instancesFile = join(root, "registry", "instances.jsonl");
3268
+ let registeredUuids = new Set();
3269
+ try {
3270
+ const content = readFileSync(instancesFile, "utf8");
3271
+ for (const line of content.split("\n")) {
3272
+ const trimmed = line.trim();
3273
+ if (!trimmed)
3274
+ continue;
3275
+ try {
3276
+ const obj = JSON.parse(trimmed);
3277
+ const id = typeof obj.instance === "string" ? obj.instance : (typeof obj.id === "string" ? obj.id : "");
3278
+ // Extract the hex tail segment from the instance id (e.g. host:label:abc123456789)
3279
+ const parts = id.split(":");
3280
+ if (parts.length >= 3) {
3281
+ const tail = parts[parts.length - 1];
3282
+ if (VALID_TAIL_RE.test(tail))
3283
+ registeredUuids.add(tail.toLowerCase());
3284
+ }
3285
+ }
3286
+ catch {
3287
+ // skip malformed
3288
+ }
3289
+ }
3290
+ }
3291
+ catch {
3292
+ // instances file absent — keep set empty (prune all orphans below)
3293
+ }
3294
+ const orphan3seg = inboxEntries.filter((entry) => {
3295
+ const segs = entry.split("__");
3296
+ if (segs.length !== 3)
3297
+ return false;
3298
+ if (!VALID_TAIL_RE.test(segs[2]))
3299
+ return false; // already handled as phantom
3300
+ return !registeredUuids.has(segs[2].toLowerCase());
3301
+ });
3302
+ for (const name of orphan3seg) {
3303
+ const dir = join(inboxDir, name);
3304
+ try {
3305
+ rmSync(dir, { recursive: true, force: true });
3306
+ pruned.push({ name, path: dir });
3307
+ }
3308
+ catch (error) {
3309
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3310
+ }
3311
+ }
3151
3312
  }
3152
3313
  }
3153
3314
  }
3154
3315
  // (d) --scan <dir>: find immediate child buses (ONE level deep)
3316
+ const strayBuses = [];
3155
3317
  if (flags.scan) {
3156
- const strayBuses = [];
3157
3318
  try {
3158
3319
  const scanChildren = readdirSync(flags.scan);
3159
3320
  for (const child of scanChildren) {
@@ -3183,8 +3344,23 @@ function cmdDoctor(flags, streams) {
3183
3344
  message: `${strayBuses.length} stray repo-local .h2a bus(es) found under ${flags.scan} — ` +
3184
3345
  `candidate split-brain forks. Each may have agents on a different root.`
3185
3346
  });
3347
+ if (flags.prune !== undefined) {
3348
+ for (const bus of strayBuses) {
3349
+ const busPath = bus.path;
3350
+ try {
3351
+ rmSync(busPath, { recursive: true, force: true });
3352
+ pruned.push({ name: busPath, path: busPath });
3353
+ }
3354
+ catch (error) {
3355
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${busPath}: ${error.message}\n`);
3356
+ }
3357
+ }
3358
+ }
3186
3359
  }
3187
3360
  }
3361
+ if (flags.prune !== undefined) {
3362
+ report.pruned = pruned;
3363
+ }
3188
3364
  streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
3189
3365
  return report.ok ? 0 : 2;
3190
3366
  }
@@ -3714,6 +3890,93 @@ function cmdDeployTenant(flags, streams) {
3714
3890
  }, null, 2)}\n`);
3715
3891
  return 0;
3716
3892
  }
3893
+ // ── WP-5: keepalive ────────────────────────────────────────────────────────
3894
+ /**
3895
+ * Single-pass keepalive logic, testable without real tmux.
3896
+ *
3897
+ * For each presence file under `root`, if the session has a
3898
+ * `launchContext.tmux.pane` that is in `livePanes`, rewrite its `heartbeatAt`
3899
+ * to `now` so the session does not expire.
3900
+ */
3901
+ export function keepaliveOnce(opts) {
3902
+ const { root, livePanes } = opts;
3903
+ const nowIso = (opts.now ?? new Date()).toISOString();
3904
+ const refreshed = [];
3905
+ // includeExpired=true so we can refresh sessions that are about to expire
3906
+ const sessions = listPresence(root, { includeExpired: true });
3907
+ for (const session of sessions) {
3908
+ const pane = session.launchContext?.tmux?.pane;
3909
+ if (!pane)
3910
+ continue;
3911
+ if (!livePanes.has(pane))
3912
+ continue;
3913
+ // Rewrite heartbeatAt to now
3914
+ const updated = { ...session, heartbeatAt: nowIso };
3915
+ try {
3916
+ writePresence(root, updated);
3917
+ refreshed.push({ instance: session.instance, sessionId: session.sessionId, pane });
3918
+ }
3919
+ catch {
3920
+ // best-effort — ignore write failures
3921
+ }
3922
+ }
3923
+ return refreshed;
3924
+ }
3925
+ /**
3926
+ * `h2a keepalive [--root <path>] [--interval <ms>] [--once]`
3927
+ *
3928
+ * External keepalive prober: runs by the launcher/remote so a host-suspended
3929
+ * `mcp-serve` still shows live as long as its tmux pane is alive. `--once`
3930
+ * does a single pass then exits 0. Without `--once`, loops on an unref'd
3931
+ * interval (default 30 000 ms).
3932
+ */
3933
+ export async function cmdKeepalive(flags, streams) {
3934
+ const cwd = streams.cwd ?? (() => process.cwd());
3935
+ const root = resolveRoot(flags, cwd);
3936
+ const intervalMs = flags.interval ? Number.parseInt(flags.interval, 10) : 30_000;
3937
+ if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
3938
+ streams.stderr.write(`h2a keepalive: --interval must be >= 1000 ms (got "${flags.interval}")\n`);
3939
+ return 1;
3940
+ }
3941
+ function getLivePanes() {
3942
+ try {
3943
+ const out = execFileSync("tmux", ["list-panes", "-aF", "#{pane_id}"], {
3944
+ encoding: "utf8",
3945
+ timeout: 5000
3946
+ });
3947
+ const panes = new Set(out
3948
+ .split("\n")
3949
+ .map((l) => l.trim())
3950
+ .filter((l) => l.length > 0));
3951
+ return panes;
3952
+ }
3953
+ catch {
3954
+ streams.stderr.write("h2a keepalive: tmux not available or returned an error — treating live-pane set as empty.\n");
3955
+ return new Set();
3956
+ }
3957
+ }
3958
+ function runOnce() {
3959
+ const livePanes = getLivePanes();
3960
+ const refreshed = keepaliveOnce({ root, livePanes });
3961
+ for (const item of refreshed) {
3962
+ streams.stdout.write(`h2a keepalive: refreshed ${item.instance} (session ${item.sessionId}, pane ${item.pane})\n`);
3963
+ }
3964
+ }
3965
+ runOnce();
3966
+ if (flags.once !== undefined) {
3967
+ return 0;
3968
+ }
3969
+ // Long-running loop — unref the interval so it does not keep the process alive.
3970
+ return new Promise((resolve) => {
3971
+ const timer = setInterval(() => {
3972
+ runOnce();
3973
+ }, intervalMs);
3974
+ timer.unref();
3975
+ // The process will exit naturally when there is nothing else keeping it alive.
3976
+ // For tests that want to abort, they should pass --once.
3977
+ void resolve; // keep linter happy
3978
+ });
3979
+ }
3717
3980
  export function runCli(argv = process.argv.slice(2), streams = {
3718
3981
  stdout: process.stdout,
3719
3982
  stderr: process.stderr
@@ -3798,6 +4061,12 @@ export function runCli(argv = process.argv.slice(2), streams = {
3798
4061
  return cmdDoctor(flags, streams);
3799
4062
  if (command === "connect")
3800
4063
  return cmdConnect(flags, streams);
4064
+ if (command === "conductor")
4065
+ return cmdConductor(flags, streams);
4066
+ if (command === "keepalive") {
4067
+ streams.stderr.write("h2a keepalive: async command — run via the h2a binary, not the synchronous API.\n");
4068
+ return 1;
4069
+ }
3801
4070
  if (command === "install-skills")
3802
4071
  return cmdInstallSkills(flags, streams);
3803
4072
  if (command === "deploy")