@sentropic/h2a-cli 0.57.0 → 0.61.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 (41) 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 +11 -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 +426 -8
  9. package/dist/cli.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/runtime/identity/index.d.ts +2 -1
  15. package/dist/runtime/identity/index.d.ts.map +1 -1
  16. package/dist/runtime/identity/index.js +1 -1
  17. package/dist/runtime/identity/index.js.map +1 -1
  18. package/dist/runtime/identity/live.d.ts.map +1 -1
  19. package/dist/runtime/identity/live.js +7 -2
  20. package/dist/runtime/identity/live.js.map +1 -1
  21. package/dist/runtime/identity/readers.d.ts +31 -0
  22. package/dist/runtime/identity/readers.d.ts.map +1 -1
  23. package/dist/runtime/identity/readers.js +134 -0
  24. package/dist/runtime/identity/readers.js.map +1 -1
  25. package/dist/runtime/local-files/index.d.ts +1 -1
  26. package/dist/runtime/local-files/index.d.ts.map +1 -1
  27. package/dist/runtime/local-files/index.js +1 -1
  28. package/dist/runtime/local-files/index.js.map +1 -1
  29. package/dist/runtime/local-files/paths.d.ts +40 -0
  30. package/dist/runtime/local-files/paths.d.ts.map +1 -1
  31. package/dist/runtime/local-files/paths.js +74 -0
  32. package/dist/runtime/local-files/paths.js.map +1 -1
  33. package/dist/runtime/local-files/store.d.ts.map +1 -1
  34. package/dist/runtime/local-files/store.js +7 -5
  35. package/dist/runtime/local-files/store.js.map +1 -1
  36. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  37. package/dist/runtime/mcp/handlers.js +30 -2
  38. package/dist/runtime/mcp/handlers.js.map +1 -1
  39. package/dist/runtime/mcp/tools.js +1 -1
  40. package/package.json +2 -2
  41. package/skills/h2a/SKILL.md +11 -1
package/dist/cli.js CHANGED
@@ -36,9 +36,9 @@
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
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
41
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
42
42
  import { homedir } from "node:os";
43
43
  import { dirname, join, resolve as resolvePath } from "node:path";
44
44
  import { fileURLToPath } from "node:url";
@@ -49,7 +49,7 @@ 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, 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";
@@ -172,7 +172,9 @@ export function renderCliHelp() {
172
172
  "",
173
173
  "High-level coordination (DEC-054):",
174
174
  " h2a connect --host <codex|claude|gemini|agy|remote> [--root <path>] [--instance <id>] [--name <display>]",
175
- " h2a doctor [--root <path>]",
175
+ " h2a doctor [--root <path>] [--scan <dir>] [--prune] (--prune deletes host-less/phantom/orphan inbox dirs + stray buses; dry-run by default)",
176
+ " h2a keepalive [--root <path>] [--interval <ms>] [--once] (external keepalive prober — refreshes presence for agents whose tmux pane is still alive)",
177
+ " h2a rename --instance <id> --name <name> [--root <path>] (set a live session's display name so peers can find it via discover --name)",
176
178
  " h2a status [--root <path>] [--scope <s>] [--instance <i>]",
177
179
  " h2a sessions [--root <path>] [--scope <s>] [--instance <i>]",
178
180
  " h2a thread --id <threadId> --instance <self> [--root <path>] (the ordered conversation for a thread, from your inbox+outbox)",
@@ -216,10 +218,59 @@ function parseFlags(argv) {
216
218
  }
217
219
  return { command, flags };
218
220
  }
219
- function resolveRoot(flags, cwd) {
221
+ /**
222
+ * Resolve the h2a store root, with its provenance.
223
+ *
224
+ * Precedence: explicit `--root` flag → `H2A_ROOT` env → the shared global
225
+ * default `~/h2a-workspace/.h2a`. The global default lets all agents on the
226
+ * same machine share one bus without any explicit configuration — eliminating
227
+ * the split-brain failure (F4) that the old `cwd/.h2a` fallback caused.
228
+ * Callers on long-lived paths (`mcp-serve`, `connect`) warn when a repo-local
229
+ * `.h2a` exists and is being ignored in favour of the shared bus.
230
+ */
231
+ function resolveRootInfo(flags, cwd) {
220
232
  if (flags.root)
221
- return flags.root;
222
- return join(cwd(), ".h2a");
233
+ return { root: flags.root, source: "flag" };
234
+ const env = process.env.H2A_ROOT;
235
+ if (env && env.length > 0)
236
+ return { root: env, source: "env" };
237
+ return { root: join(homedir(), "h2a-workspace", ".h2a"), source: "default" };
238
+ }
239
+ function resolveRoot(flags, cwd) {
240
+ return resolveRootInfo(flags, cwd).root;
241
+ }
242
+ /**
243
+ * Warn (once, to stderr) when the root was taken from the shared default but a
244
+ * DIFFERENT repo-local `.h2a` exists in the current working directory. The
245
+ * user may have intended to use the local bus — so we alert them to pass
246
+ * `--root <cwd>/.h2a` if that was their intent. When the default IS the right
247
+ * bus (no local `.h2a` around), stay silent. Used by `mcp-serve` and `connect`.
248
+ */
249
+ function warnIfCwdRootFallback(flags, cwd, streams) {
250
+ const info = resolveRootInfo(flags, cwd);
251
+ if (info.source !== "default")
252
+ return;
253
+ const cwdLocal = join(cwd(), ".h2a");
254
+ try {
255
+ if (!existsSync(cwdLocal))
256
+ return;
257
+ const resolvedLocal = realpathSync(cwdLocal);
258
+ const resolvedRoot = (() => {
259
+ try {
260
+ return realpathSync(info.root);
261
+ }
262
+ catch {
263
+ return info.root;
264
+ }
265
+ })();
266
+ if (resolvedLocal === resolvedRoot)
267
+ return;
268
+ streams.stderr.write(`h2a: a repo-local .h2a exists here but I'm using the shared bus ${info.root}. ` +
269
+ `Pass --root ${cwdLocal} if you meant the local one.\n`);
270
+ }
271
+ catch {
272
+ // silently ignore filesystem errors
273
+ }
223
274
  }
224
275
  /**
225
276
  * Resolve `causationId` / `correlationId` for a new negotiation event.
@@ -434,6 +485,20 @@ function cmdMailbox(argv, mailbox, streams) {
434
485
  streams.stderr.write(`\n${error.message}\n`);
435
486
  return 1;
436
487
  }
488
+ // WP-2: resolve-before-send — legibility gate (does NOT change destination).
489
+ const root = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
490
+ const liveSessions = listPresence(root).map((s) => s.instance);
491
+ const registeredIds = store.listInstances().map((i) => i.instance ?? i.id);
492
+ const resolution = resolveRecipient({
493
+ target: flags.instance,
494
+ liveInstances: liveSessions,
495
+ registeredInstances: registeredIds
496
+ });
497
+ if (resolution.kind === "refuse") {
498
+ streams.stderr.write(`\nh2a: ${resolution.reason}${resolution.candidates ? `\ncandidates: ${resolution.candidates.join(", ")}` : ""}\n`);
499
+ return 1;
500
+ }
501
+ // deliver / deliver-dormant / deliver-hint → proceed with put below.
437
502
  }
438
503
  try {
439
504
  if (mailbox === "inbox") {
@@ -447,7 +512,24 @@ function cmdMailbox(argv, mailbox, streams) {
447
512
  const recipientLive = mailbox === "inbox"
448
513
  ? listPresence(store.paths.root).some((s) => canonicalAddress(s.instance) === canonicalAddress(flags.instance))
449
514
  : undefined;
450
- streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, mailbox, instance: flags.instance, ...(recipientLive !== undefined ? { recipientLive } : {}) }, null, 2)}\n`);
515
+ // WP-2: enrich stdout with resolution metadata.
516
+ let resolutionMeta = {};
517
+ if (mailbox === "inbox") {
518
+ const root2 = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
519
+ const liveSessions2 = listPresence(root2).map((s) => s.instance);
520
+ const registeredIds2 = store.listInstances().map((i) => i.instance ?? i.id);
521
+ const resolution2 = resolveRecipient({
522
+ target: flags.instance,
523
+ liveInstances: liveSessions2,
524
+ registeredInstances: registeredIds2
525
+ });
526
+ resolutionMeta = {
527
+ resolution: resolution2.kind,
528
+ ...(resolution2.kind === "deliver-hint" ? { liveCandidate: resolution2.liveCandidate, reason: resolution2.reason } : {}),
529
+ ...(resolution2.kind === "deliver-dormant" ? { reason: resolution2.reason, dormant: true } : {})
530
+ };
531
+ }
532
+ streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, mailbox, instance: flags.instance, ...(recipientLive !== undefined ? { recipientLive } : {}), ...resolutionMeta }, null, 2)}\n`);
451
533
  return 0;
452
534
  }
453
535
  catch (error) {
@@ -1093,6 +1175,7 @@ export async function runMcpServe(flags, io = {
1093
1175
  stderr: process.stderr
1094
1176
  }) {
1095
1177
  const cwd = io.cwd ?? (() => process.cwd());
1178
+ warnIfCwdRootFallback(flags, cwd, { stderr: io.stderr, stdout: io.stdout, cwd });
1096
1179
  const root = resolveRoot(flags, cwd);
1097
1180
  const autoOpen = resolveAutoOpen(flags, cwd);
1098
1181
  // DEC-107/108 (EVO-8 levels 2/3): version handling at boot. **Opt-in** — no
@@ -2914,9 +2997,11 @@ function cmdDoctor(flags, streams) {
2914
2997
  const report = {
2915
2998
  ok: true,
2916
2999
  root,
3000
+ warnings: [],
2917
3001
  checks: {}
2918
3002
  };
2919
3003
  const checks = report.checks;
3004
+ const warnings = report.warnings;
2920
3005
  // 1. Root reachable
2921
3006
  if (!existsSync(root)) {
2922
3007
  checks.rootExists = { ok: false, message: `root does not exist: ${root}` };
@@ -2969,6 +3054,247 @@ function cmdDoctor(flags, streams) {
2969
3054
  }
2970
3055
  // 4. h2a binary reachable (self-check via existing API)
2971
3056
  checks.cliBinary = { ok: true };
3057
+ // ── Warning checks (do NOT flip report.ok) ──────────────────────────────
3058
+ // (a) rootSource: record where the root came from
3059
+ const rootInfo = resolveRootInfo(flags, cwd);
3060
+ report.rootSource = rootInfo.source;
3061
+ if (rootInfo.source === "default") {
3062
+ warnings.push({
3063
+ check: "rootSource",
3064
+ message: `The bus root is the global shared default (${root}). ` +
3065
+ `Set H2A_ROOT or pass --root <path> to use a different bus. ` +
3066
+ `Agents on different roots cannot exchange messages.`
3067
+ });
3068
+ }
3069
+ // (b) splitBrain: a repo-local .h2a exists alongside a DIFFERENT active root
3070
+ const cwdLocal = join(cwd(), ".h2a");
3071
+ try {
3072
+ if (existsSync(cwdLocal)) {
3073
+ const resolvedLocal = realpathSync(cwdLocal);
3074
+ const resolvedRoot = (() => {
3075
+ try {
3076
+ return realpathSync(root);
3077
+ }
3078
+ catch {
3079
+ return root;
3080
+ }
3081
+ })();
3082
+ if (resolvedLocal !== resolvedRoot) {
3083
+ warnings.push({
3084
+ check: "splitBrain",
3085
+ message: `A repo-local .h2a (${cwdLocal}) exists alongside a DIFFERENT active root (${root}). ` +
3086
+ `Messages written to the repo-local bus are invisible to peers on ${root} — split-brain.`
3087
+ });
3088
+ }
3089
+ }
3090
+ }
3091
+ catch {
3092
+ // silently ignore (the cwd-local path may not be resolvable)
3093
+ }
3094
+ // (c) inboxHygiene: scan inbox for case/slug duplicates, host-less dirs, phantom 3-segment dirs
3095
+ const inboxDir = join(root, "inbox");
3096
+ // Collect dirs to prune (with --prune only).
3097
+ const pruned = [];
3098
+ if (existsSync(inboxDir)) {
3099
+ let inboxEntries = [];
3100
+ try {
3101
+ inboxEntries = readdirSync(inboxDir);
3102
+ }
3103
+ catch {
3104
+ // not readable — skip hygiene check
3105
+ }
3106
+ if (inboxEntries.length > 0) {
3107
+ const KNOWN_HOSTS = new Set(["claude", "codex", "gemini", "remote"]);
3108
+ // Reconstruct an address from a dir name: double-underscore → colon.
3109
+ function dirToAddress(name) {
3110
+ return name.replace(/__/g, ":");
3111
+ }
3112
+ // Case/slug duplicates: two dir names that map to the same canonicalAddress
3113
+ const byCanonical = new Map();
3114
+ for (const entry of inboxEntries) {
3115
+ const addr = dirToAddress(entry);
3116
+ let canonical;
3117
+ try {
3118
+ canonical = canonicalAddress(addr);
3119
+ }
3120
+ catch {
3121
+ canonical = addr.toLowerCase();
3122
+ }
3123
+ const group = byCanonical.get(canonical) ?? [];
3124
+ group.push(entry);
3125
+ byCanonical.set(canonical, group);
3126
+ }
3127
+ const dupGroups = [...byCanonical.values()].filter((g) => g.length > 1);
3128
+ if (dupGroups.length > 0) {
3129
+ const examples = dupGroups.slice(0, 5).map((g) => g.join(" | "));
3130
+ warnings.push({
3131
+ check: "inboxHygiene",
3132
+ kind: "caseDuplicates",
3133
+ count: dupGroups.length,
3134
+ examples,
3135
+ message: `${dupGroups.length} case/slug duplicate group(s) in inbox — ` +
3136
+ `different dir names map to the same canonical address. ` +
3137
+ `Examples: ${examples.join(", ")}. (Not pruned — too risky; resolve manually.)`
3138
+ });
3139
+ }
3140
+ // Host-less dirs: first segment (before first __) is not a known host
3141
+ const hostless = inboxEntries.filter((entry) => {
3142
+ const firstSeg = entry.split("__")[0];
3143
+ return !KNOWN_HOSTS.has(firstSeg);
3144
+ });
3145
+ if (hostless.length > 0) {
3146
+ const examples = hostless.slice(0, 5);
3147
+ warnings.push({
3148
+ check: "inboxHygiene",
3149
+ kind: "hostlessDirs",
3150
+ count: hostless.length,
3151
+ examples,
3152
+ message: `${hostless.length} host-less inbox dir(s) (first segment is not claude/codex/gemini/remote). ` +
3153
+ `Examples: ${examples.join(", ")}`
3154
+ });
3155
+ if (flags.prune !== undefined) {
3156
+ for (const name of hostless) {
3157
+ const dir = join(inboxDir, name);
3158
+ try {
3159
+ rmSync(dir, { recursive: true, force: true });
3160
+ pruned.push({ name, path: dir });
3161
+ }
3162
+ catch (error) {
3163
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3164
+ }
3165
+ }
3166
+ }
3167
+ }
3168
+ // Phantom 3-segment dirs: exactly 3 __ segments but 3rd is not a 12-char hex session id
3169
+ const VALID_TAIL_RE = /^[0-9a-f]{12}$/i;
3170
+ const phantom = inboxEntries.filter((entry) => {
3171
+ const segs = entry.split("__");
3172
+ if (segs.length !== 3)
3173
+ return false;
3174
+ return !VALID_TAIL_RE.test(segs[2]);
3175
+ });
3176
+ if (phantom.length > 0) {
3177
+ const examples = phantom.slice(0, 5);
3178
+ warnings.push({
3179
+ check: "inboxHygiene",
3180
+ kind: "phantomThreeSegment",
3181
+ count: phantom.length,
3182
+ examples,
3183
+ message: `${phantom.length} phantom 3-segment inbox dir(s) where the tail is not a 12-char hex session id. ` +
3184
+ `Examples: ${examples.join(", ")}`
3185
+ });
3186
+ if (flags.prune !== undefined) {
3187
+ for (const name of phantom) {
3188
+ const dir = join(inboxDir, name);
3189
+ try {
3190
+ rmSync(dir, { recursive: true, force: true });
3191
+ pruned.push({ name, path: dir });
3192
+ }
3193
+ catch (error) {
3194
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3195
+ }
3196
+ }
3197
+ }
3198
+ }
3199
+ // Orphan 3-segment dirs: 3 segments, valid hex tail, but UUID is NOT in registry/instances.jsonl
3200
+ if (flags.prune !== undefined) {
3201
+ const instancesFile = join(root, "registry", "instances.jsonl");
3202
+ let registeredUuids = new Set();
3203
+ try {
3204
+ const content = readFileSync(instancesFile, "utf8");
3205
+ for (const line of content.split("\n")) {
3206
+ const trimmed = line.trim();
3207
+ if (!trimmed)
3208
+ continue;
3209
+ try {
3210
+ const obj = JSON.parse(trimmed);
3211
+ const id = typeof obj.instance === "string" ? obj.instance : (typeof obj.id === "string" ? obj.id : "");
3212
+ // Extract the hex tail segment from the instance id (e.g. host:label:abc123456789)
3213
+ const parts = id.split(":");
3214
+ if (parts.length >= 3) {
3215
+ const tail = parts[parts.length - 1];
3216
+ if (VALID_TAIL_RE.test(tail))
3217
+ registeredUuids.add(tail.toLowerCase());
3218
+ }
3219
+ }
3220
+ catch {
3221
+ // skip malformed
3222
+ }
3223
+ }
3224
+ }
3225
+ catch {
3226
+ // instances file absent — keep set empty (prune all orphans below)
3227
+ }
3228
+ const orphan3seg = inboxEntries.filter((entry) => {
3229
+ const segs = entry.split("__");
3230
+ if (segs.length !== 3)
3231
+ return false;
3232
+ if (!VALID_TAIL_RE.test(segs[2]))
3233
+ return false; // already handled as phantom
3234
+ return !registeredUuids.has(segs[2].toLowerCase());
3235
+ });
3236
+ for (const name of orphan3seg) {
3237
+ const dir = join(inboxDir, name);
3238
+ try {
3239
+ rmSync(dir, { recursive: true, force: true });
3240
+ pruned.push({ name, path: dir });
3241
+ }
3242
+ catch (error) {
3243
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3244
+ }
3245
+ }
3246
+ }
3247
+ }
3248
+ }
3249
+ // (d) --scan <dir>: find immediate child buses (ONE level deep)
3250
+ const strayBuses = [];
3251
+ if (flags.scan) {
3252
+ try {
3253
+ const scanChildren = readdirSync(flags.scan);
3254
+ for (const child of scanChildren) {
3255
+ const candidateBus = join(flags.scan, child, ".h2a");
3256
+ if (existsSync(candidateBus)) {
3257
+ const instancesFile = join(candidateBus, "registry", "instances.jsonl");
3258
+ let instances = 0;
3259
+ try {
3260
+ const content = readFileSync(instancesFile, "utf8");
3261
+ instances = content.split("\n").filter((l) => l.trim().length > 0).length;
3262
+ }
3263
+ catch {
3264
+ instances = 0;
3265
+ }
3266
+ strayBuses.push({ path: candidateBus, instances });
3267
+ }
3268
+ }
3269
+ }
3270
+ catch (error) {
3271
+ streams.stderr.write(`h2a doctor: --scan ${flags.scan}: ${error.message}\n`);
3272
+ }
3273
+ if (strayBuses.length > 0) {
3274
+ warnings.push({
3275
+ check: "strayBuses",
3276
+ count: strayBuses.length,
3277
+ buses: strayBuses,
3278
+ message: `${strayBuses.length} stray repo-local .h2a bus(es) found under ${flags.scan} — ` +
3279
+ `candidate split-brain forks. Each may have agents on a different root.`
3280
+ });
3281
+ if (flags.prune !== undefined) {
3282
+ for (const bus of strayBuses) {
3283
+ const busPath = bus.path;
3284
+ try {
3285
+ rmSync(busPath, { recursive: true, force: true });
3286
+ pruned.push({ name: busPath, path: busPath });
3287
+ }
3288
+ catch (error) {
3289
+ streams.stderr.write(`h2a doctor --prune: cannot remove ${busPath}: ${error.message}\n`);
3290
+ }
3291
+ }
3292
+ }
3293
+ }
3294
+ }
3295
+ if (flags.prune !== undefined) {
3296
+ report.pruned = pruned;
3297
+ }
2972
3298
  streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
2973
3299
  return report.ok ? 0 : 2;
2974
3300
  }
@@ -3089,6 +3415,7 @@ function cmdConnect(flags, streams) {
3089
3415
  return 1;
3090
3416
  }
3091
3417
  const cwd = streams.cwd ?? (() => process.cwd());
3418
+ warnIfCwdRootFallback(flags, cwd, streams);
3092
3419
  const root = resolveRoot(flags, cwd);
3093
3420
  const identity = resolveLiveIdentity({
3094
3421
  root,
@@ -3497,6 +3824,93 @@ function cmdDeployTenant(flags, streams) {
3497
3824
  }, null, 2)}\n`);
3498
3825
  return 0;
3499
3826
  }
3827
+ // ── WP-5: keepalive ────────────────────────────────────────────────────────
3828
+ /**
3829
+ * Single-pass keepalive logic, testable without real tmux.
3830
+ *
3831
+ * For each presence file under `root`, if the session has a
3832
+ * `launchContext.tmux.pane` that is in `livePanes`, rewrite its `heartbeatAt`
3833
+ * to `now` so the session does not expire.
3834
+ */
3835
+ export function keepaliveOnce(opts) {
3836
+ const { root, livePanes } = opts;
3837
+ const nowIso = (opts.now ?? new Date()).toISOString();
3838
+ const refreshed = [];
3839
+ // includeExpired=true so we can refresh sessions that are about to expire
3840
+ const sessions = listPresence(root, { includeExpired: true });
3841
+ for (const session of sessions) {
3842
+ const pane = session.launchContext?.tmux?.pane;
3843
+ if (!pane)
3844
+ continue;
3845
+ if (!livePanes.has(pane))
3846
+ continue;
3847
+ // Rewrite heartbeatAt to now
3848
+ const updated = { ...session, heartbeatAt: nowIso };
3849
+ try {
3850
+ writePresence(root, updated);
3851
+ refreshed.push({ instance: session.instance, sessionId: session.sessionId, pane });
3852
+ }
3853
+ catch {
3854
+ // best-effort — ignore write failures
3855
+ }
3856
+ }
3857
+ return refreshed;
3858
+ }
3859
+ /**
3860
+ * `h2a keepalive [--root <path>] [--interval <ms>] [--once]`
3861
+ *
3862
+ * External keepalive prober: runs by the launcher/remote so a host-suspended
3863
+ * `mcp-serve` still shows live as long as its tmux pane is alive. `--once`
3864
+ * does a single pass then exits 0. Without `--once`, loops on an unref'd
3865
+ * interval (default 30 000 ms).
3866
+ */
3867
+ export async function cmdKeepalive(flags, streams) {
3868
+ const cwd = streams.cwd ?? (() => process.cwd());
3869
+ const root = resolveRoot(flags, cwd);
3870
+ const intervalMs = flags.interval ? Number.parseInt(flags.interval, 10) : 30_000;
3871
+ if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
3872
+ streams.stderr.write(`h2a keepalive: --interval must be >= 1000 ms (got "${flags.interval}")\n`);
3873
+ return 1;
3874
+ }
3875
+ function getLivePanes() {
3876
+ try {
3877
+ const out = execFileSync("tmux", ["list-panes", "-aF", "#{pane_id}"], {
3878
+ encoding: "utf8",
3879
+ timeout: 5000
3880
+ });
3881
+ const panes = new Set(out
3882
+ .split("\n")
3883
+ .map((l) => l.trim())
3884
+ .filter((l) => l.length > 0));
3885
+ return panes;
3886
+ }
3887
+ catch {
3888
+ streams.stderr.write("h2a keepalive: tmux not available or returned an error — treating live-pane set as empty.\n");
3889
+ return new Set();
3890
+ }
3891
+ }
3892
+ function runOnce() {
3893
+ const livePanes = getLivePanes();
3894
+ const refreshed = keepaliveOnce({ root, livePanes });
3895
+ for (const item of refreshed) {
3896
+ streams.stdout.write(`h2a keepalive: refreshed ${item.instance} (session ${item.sessionId}, pane ${item.pane})\n`);
3897
+ }
3898
+ }
3899
+ runOnce();
3900
+ if (flags.once !== undefined) {
3901
+ return 0;
3902
+ }
3903
+ // Long-running loop — unref the interval so it does not keep the process alive.
3904
+ return new Promise((resolve) => {
3905
+ const timer = setInterval(() => {
3906
+ runOnce();
3907
+ }, intervalMs);
3908
+ timer.unref();
3909
+ // The process will exit naturally when there is nothing else keeping it alive.
3910
+ // For tests that want to abort, they should pass --once.
3911
+ void resolve; // keep linter happy
3912
+ });
3913
+ }
3500
3914
  export function runCli(argv = process.argv.slice(2), streams = {
3501
3915
  stdout: process.stdout,
3502
3916
  stderr: process.stderr
@@ -3581,6 +3995,10 @@ export function runCli(argv = process.argv.slice(2), streams = {
3581
3995
  return cmdDoctor(flags, streams);
3582
3996
  if (command === "connect")
3583
3997
  return cmdConnect(flags, streams);
3998
+ if (command === "keepalive") {
3999
+ streams.stderr.write("h2a keepalive: async command — run via the h2a binary, not the synchronous API.\n");
4000
+ return 1;
4001
+ }
3584
4002
  if (command === "install-skills")
3585
4003
  return cmdInstallSkills(flags, streams);
3586
4004
  if (command === "deploy")