@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.
- 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 +20 -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 +294 -25
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +2 -1
- package/dist/mcp.js.map +1 -1
- package/dist/runtime/governance/conductor.d.ts +67 -0
- package/dist/runtime/governance/conductor.d.ts.map +1 -0
- package/dist/runtime/governance/conductor.js +77 -0
- package/dist/runtime/governance/conductor.js.map +1 -0
- package/dist/runtime/governance/index.d.ts +2 -0
- package/dist/runtime/governance/index.d.ts.map +1 -0
- package/dist/runtime/governance/index.js +2 -0
- package/dist/runtime/governance/index.js.map +1 -0
- 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/mcp/handlers.d.ts +8 -0
- package/dist/runtime/mcp/handlers.d.ts.map +1 -1
- package/dist/runtime/mcp/handlers.js +44 -2
- package/dist/runtime/mcp/handlers.js.map +1 -1
- package/dist/runtime/mcp/server.d.ts.map +1 -1
- package/dist/runtime/mcp/server.js +3 -1
- package/dist/runtime/mcp/server.js.map +1 -1
- package/dist/runtime/mcp/tools.d.ts.map +1 -1
- package/dist/runtime/mcp/tools.js +18 -1
- package/dist/runtime/mcp/tools.js.map +1 -1
- package/package.json +2 -2
- 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
|
|
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
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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(
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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 !== "
|
|
253
|
+
if (info.source !== "default")
|
|
249
254
|
return;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 === "
|
|
3127
|
+
if (rootInfo.source === "default") {
|
|
3041
3128
|
warnings.push({
|
|
3042
3129
|
check: "rootSource",
|
|
3043
|
-
message: `The bus root is the
|
|
3044
|
-
`Set H2A_ROOT or pass --root <
|
|
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")
|