@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.
- package/dist/bin.js +4 -1
- package/dist/bin.js.map +1 -1
- package/dist/cli-contract.d.ts.map +1 -1
- package/dist/cli-contract.js +11 -2
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli.d.ts +25 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +426 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime/identity/index.d.ts +2 -1
- package/dist/runtime/identity/index.d.ts.map +1 -1
- package/dist/runtime/identity/index.js +1 -1
- package/dist/runtime/identity/index.js.map +1 -1
- package/dist/runtime/identity/live.d.ts.map +1 -1
- package/dist/runtime/identity/live.js +7 -2
- package/dist/runtime/identity/live.js.map +1 -1
- package/dist/runtime/identity/readers.d.ts +31 -0
- package/dist/runtime/identity/readers.d.ts.map +1 -1
- package/dist/runtime/identity/readers.js +134 -0
- package/dist/runtime/identity/readers.js.map +1 -1
- package/dist/runtime/local-files/index.d.ts +1 -1
- package/dist/runtime/local-files/index.d.ts.map +1 -1
- package/dist/runtime/local-files/index.js +1 -1
- package/dist/runtime/local-files/index.js.map +1 -1
- package/dist/runtime/local-files/paths.d.ts +40 -0
- package/dist/runtime/local-files/paths.d.ts.map +1 -1
- package/dist/runtime/local-files/paths.js +74 -0
- package/dist/runtime/local-files/paths.js.map +1 -1
- package/dist/runtime/local-files/store.d.ts.map +1 -1
- package/dist/runtime/local-files/store.js +7 -5
- package/dist/runtime/local-files/store.js.map +1 -1
- package/dist/runtime/mcp/handlers.d.ts.map +1 -1
- package/dist/runtime/mcp/handlers.js +30 -2
- package/dist/runtime/mcp/handlers.js.map +1 -1
- package/dist/runtime/mcp/tools.js +1 -1
- package/package.json +2 -2
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|