@sentropic/h2a-cli 0.75.0 → 0.78.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/index.d.ts +1 -37
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -41
- package/dist/index.js.map +1 -1
- package/package.json +4 -15
- package/dist/bin.d.ts +0 -3
- package/dist/bin.d.ts.map +0 -1
- package/dist/bin.js +0 -83
- package/dist/bin.js.map +0 -1
- package/dist/cli-contract.d.ts +0 -62
- package/dist/cli-contract.d.ts.map +0 -1
- package/dist/cli-contract.js +0 -794
- package/dist/cli-contract.js.map +0 -1
- package/dist/cli.d.ts +0 -254
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -4672
- package/dist/cli.js.map +0 -1
- package/dist/hosts/agy.d.ts +0 -14
- package/dist/hosts/agy.d.ts.map +0 -1
- package/dist/hosts/agy.js +0 -47
- package/dist/hosts/agy.js.map +0 -1
- package/dist/hosts/claude.d.ts +0 -12
- package/dist/hosts/claude.d.ts.map +0 -1
- package/dist/hosts/claude.js +0 -46
- package/dist/hosts/claude.js.map +0 -1
- package/dist/hosts/codex.d.ts +0 -65
- package/dist/hosts/codex.d.ts.map +0 -1
- package/dist/hosts/codex.js +0 -45
- package/dist/hosts/codex.js.map +0 -1
- package/dist/hosts/gemini.d.ts +0 -12
- package/dist/hosts/gemini.d.ts.map +0 -1
- package/dist/hosts/gemini.js +0 -46
- package/dist/hosts/gemini.js.map +0 -1
- package/dist/hosts/plugin.d.ts +0 -151
- package/dist/hosts/plugin.d.ts.map +0 -1
- package/dist/hosts/plugin.js +0 -188
- package/dist/hosts/plugin.js.map +0 -1
- package/dist/mcp.d.ts +0 -2
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js +0 -32
- package/dist/mcp.js.map +0 -1
- package/dist/runtime/blockage/index.d.ts +0 -3
- package/dist/runtime/blockage/index.d.ts.map +0 -1
- package/dist/runtime/blockage/index.js +0 -3
- package/dist/runtime/blockage/index.js.map +0 -1
- package/dist/runtime/blockage/notifiers.d.ts +0 -52
- package/dist/runtime/blockage/notifiers.d.ts.map +0 -1
- package/dist/runtime/blockage/notifiers.js +0 -74
- package/dist/runtime/blockage/notifiers.js.map +0 -1
- package/dist/runtime/blockage/registry.d.ts +0 -30
- package/dist/runtime/blockage/registry.d.ts.map +0 -1
- package/dist/runtime/blockage/registry.js +0 -86
- package/dist/runtime/blockage/registry.js.map +0 -1
- package/dist/runtime/deploy/k8s-sidecar.d.ts +0 -92
- package/dist/runtime/deploy/k8s-sidecar.d.ts.map +0 -1
- package/dist/runtime/deploy/k8s-sidecar.js +0 -166
- package/dist/runtime/deploy/k8s-sidecar.js.map +0 -1
- package/dist/runtime/deploy/k8s-tenant.d.ts +0 -86
- package/dist/runtime/deploy/k8s-tenant.d.ts.map +0 -1
- package/dist/runtime/deploy/k8s-tenant.js +0 -255
- package/dist/runtime/deploy/k8s-tenant.js.map +0 -1
- package/dist/runtime/drive/inbox-wake.d.ts +0 -36
- package/dist/runtime/drive/inbox-wake.d.ts.map +0 -1
- package/dist/runtime/drive/inbox-wake.js +0 -50
- package/dist/runtime/drive/inbox-wake.js.map +0 -1
- package/dist/runtime/drive/index.d.ts +0 -173
- package/dist/runtime/drive/index.d.ts.map +0 -1
- package/dist/runtime/drive/index.js +0 -503
- package/dist/runtime/drive/index.js.map +0 -1
- package/dist/runtime/drumbeat/deciders.d.ts +0 -32
- package/dist/runtime/drumbeat/deciders.d.ts.map +0 -1
- package/dist/runtime/drumbeat/deciders.js +0 -65
- package/dist/runtime/drumbeat/deciders.js.map +0 -1
- package/dist/runtime/drumbeat/decisions.d.ts +0 -19
- package/dist/runtime/drumbeat/decisions.d.ts.map +0 -1
- package/dist/runtime/drumbeat/decisions.js +0 -34
- package/dist/runtime/drumbeat/decisions.js.map +0 -1
- package/dist/runtime/drumbeat/inbox-relance.d.ts +0 -29
- package/dist/runtime/drumbeat/inbox-relance.d.ts.map +0 -1
- package/dist/runtime/drumbeat/inbox-relance.js +0 -101
- package/dist/runtime/drumbeat/inbox-relance.js.map +0 -1
- package/dist/runtime/drumbeat/index.d.ts +0 -8
- package/dist/runtime/drumbeat/index.d.ts.map +0 -1
- package/dist/runtime/drumbeat/index.js +0 -8
- package/dist/runtime/drumbeat/index.js.map +0 -1
- package/dist/runtime/drumbeat/registry.d.ts +0 -60
- package/dist/runtime/drumbeat/registry.d.ts.map +0 -1
- package/dist/runtime/drumbeat/registry.js +0 -115
- package/dist/runtime/drumbeat/registry.js.map +0 -1
- package/dist/runtime/drumbeat/relaunchers.d.ts +0 -111
- package/dist/runtime/drumbeat/relaunchers.d.ts.map +0 -1
- package/dist/runtime/drumbeat/relaunchers.js +0 -260
- package/dist/runtime/drumbeat/relaunchers.js.map +0 -1
- package/dist/runtime/drumbeat/scan.d.ts +0 -36
- package/dist/runtime/drumbeat/scan.d.ts.map +0 -1
- package/dist/runtime/drumbeat/scan.js +0 -41
- package/dist/runtime/drumbeat/scan.js.map +0 -1
- package/dist/runtime/drumbeat/watch.d.ts +0 -74
- package/dist/runtime/drumbeat/watch.d.ts.map +0 -1
- package/dist/runtime/drumbeat/watch.js +0 -216
- package/dist/runtime/drumbeat/watch.js.map +0 -1
- package/dist/runtime/escalation/index.d.ts +0 -2
- package/dist/runtime/escalation/index.d.ts.map +0 -1
- package/dist/runtime/escalation/index.js +0 -2
- package/dist/runtime/escalation/index.js.map +0 -1
- package/dist/runtime/escalation/registry.d.ts +0 -44
- package/dist/runtime/escalation/registry.d.ts.map +0 -1
- package/dist/runtime/escalation/registry.js +0 -79
- package/dist/runtime/escalation/registry.js.map +0 -1
- package/dist/runtime/governance/claims.d.ts +0 -43
- package/dist/runtime/governance/claims.d.ts.map +0 -1
- package/dist/runtime/governance/claims.js +0 -93
- package/dist/runtime/governance/claims.js.map +0 -1
- package/dist/runtime/governance/conductor.d.ts +0 -79
- package/dist/runtime/governance/conductor.d.ts.map +0 -1
- package/dist/runtime/governance/conductor.js +0 -106
- package/dist/runtime/governance/conductor.js.map +0 -1
- package/dist/runtime/governance/index.d.ts +0 -5
- package/dist/runtime/governance/index.d.ts.map +0 -1
- package/dist/runtime/governance/index.js +0 -5
- package/dist/runtime/governance/index.js.map +0 -1
- package/dist/runtime/governance/launch-check.d.ts +0 -93
- package/dist/runtime/governance/launch-check.d.ts.map +0 -1
- package/dist/runtime/governance/launch-check.js +0 -113
- package/dist/runtime/governance/launch-check.js.map +0 -1
- package/dist/runtime/governance/spawns.d.ts +0 -50
- package/dist/runtime/governance/spawns.d.ts.map +0 -1
- package/dist/runtime/governance/spawns.js +0 -91
- package/dist/runtime/governance/spawns.js.map +0 -1
- package/dist/runtime/identity/bindings.d.ts +0 -84
- package/dist/runtime/identity/bindings.d.ts.map +0 -1
- package/dist/runtime/identity/bindings.js +0 -112
- package/dist/runtime/identity/bindings.js.map +0 -1
- package/dist/runtime/identity/index.d.ts +0 -11
- package/dist/runtime/identity/index.d.ts.map +0 -1
- package/dist/runtime/identity/index.js +0 -6
- package/dist/runtime/identity/index.js.map +0 -1
- package/dist/runtime/identity/live.d.ts +0 -26
- package/dist/runtime/identity/live.d.ts.map +0 -1
- package/dist/runtime/identity/live.js +0 -232
- package/dist/runtime/identity/live.js.map +0 -1
- package/dist/runtime/identity/migration.d.ts +0 -55
- package/dist/runtime/identity/migration.d.ts.map +0 -1
- package/dist/runtime/identity/migration.js +0 -100
- package/dist/runtime/identity/migration.js.map +0 -1
- package/dist/runtime/identity/readers.d.ts +0 -42
- package/dist/runtime/identity/readers.d.ts.map +0 -1
- package/dist/runtime/identity/readers.js +0 -248
- package/dist/runtime/identity/readers.js.map +0 -1
- package/dist/runtime/identity/resolver.d.ts +0 -50
- package/dist/runtime/identity/resolver.d.ts.map +0 -1
- package/dist/runtime/identity/resolver.js +0 -61
- package/dist/runtime/identity/resolver.js.map +0 -1
- package/dist/runtime/identity/workspace-id.d.ts +0 -43
- package/dist/runtime/identity/workspace-id.d.ts.map +0 -1
- package/dist/runtime/identity/workspace-id.js +0 -138
- package/dist/runtime/identity/workspace-id.js.map +0 -1
- package/dist/runtime/local-files/index.d.ts +0 -8
- package/dist/runtime/local-files/index.d.ts.map +0 -1
- package/dist/runtime/local-files/index.js +0 -8
- package/dist/runtime/local-files/index.js.map +0 -1
- package/dist/runtime/local-files/lease.d.ts +0 -62
- package/dist/runtime/local-files/lease.d.ts.map +0 -1
- package/dist/runtime/local-files/lease.js +0 -249
- package/dist/runtime/local-files/lease.js.map +0 -1
- package/dist/runtime/local-files/locks.d.ts +0 -43
- package/dist/runtime/local-files/locks.d.ts.map +0 -1
- package/dist/runtime/local-files/locks.js +0 -197
- package/dist/runtime/local-files/locks.js.map +0 -1
- package/dist/runtime/local-files/migrate.d.ts +0 -31
- package/dist/runtime/local-files/migrate.d.ts.map +0 -1
- package/dist/runtime/local-files/migrate.js +0 -104
- package/dist/runtime/local-files/migrate.js.map +0 -1
- package/dist/runtime/local-files/paths.d.ts +0 -146
- package/dist/runtime/local-files/paths.d.ts.map +0 -1
- package/dist/runtime/local-files/paths.js +0 -233
- package/dist/runtime/local-files/paths.js.map +0 -1
- package/dist/runtime/local-files/presence.d.ts +0 -80
- package/dist/runtime/local-files/presence.d.ts.map +0 -1
- package/dist/runtime/local-files/presence.js +0 -250
- package/dist/runtime/local-files/presence.js.map +0 -1
- package/dist/runtime/local-files/schema.d.ts +0 -47
- package/dist/runtime/local-files/schema.d.ts.map +0 -1
- package/dist/runtime/local-files/schema.js +0 -77
- package/dist/runtime/local-files/schema.js.map +0 -1
- package/dist/runtime/local-files/store.d.ts +0 -132
- package/dist/runtime/local-files/store.d.ts.map +0 -1
- package/dist/runtime/local-files/store.js +0 -997
- package/dist/runtime/local-files/store.js.map +0 -1
- package/dist/runtime/loop/index.d.ts +0 -73
- package/dist/runtime/loop/index.d.ts.map +0 -1
- package/dist/runtime/loop/index.js +0 -107
- package/dist/runtime/loop/index.js.map +0 -1
- package/dist/runtime/mcp/handlers.d.ts +0 -196
- package/dist/runtime/mcp/handlers.d.ts.map +0 -1
- package/dist/runtime/mcp/handlers.js +0 -1025
- package/dist/runtime/mcp/handlers.js.map +0 -1
- package/dist/runtime/mcp/index.d.ts +0 -7
- package/dist/runtime/mcp/index.d.ts.map +0 -1
- package/dist/runtime/mcp/index.js +0 -7
- package/dist/runtime/mcp/index.js.map +0 -1
- package/dist/runtime/mcp/notifications.d.ts +0 -47
- package/dist/runtime/mcp/notifications.d.ts.map +0 -1
- package/dist/runtime/mcp/notifications.js +0 -204
- package/dist/runtime/mcp/notifications.js.map +0 -1
- package/dist/runtime/mcp/server.d.ts +0 -45
- package/dist/runtime/mcp/server.d.ts.map +0 -1
- package/dist/runtime/mcp/server.js +0 -94
- package/dist/runtime/mcp/server.js.map +0 -1
- package/dist/runtime/mcp/sessions.d.ts +0 -68
- package/dist/runtime/mcp/sessions.d.ts.map +0 -1
- package/dist/runtime/mcp/sessions.js +0 -152
- package/dist/runtime/mcp/sessions.js.map +0 -1
- package/dist/runtime/mcp/stdio.d.ts +0 -72
- package/dist/runtime/mcp/stdio.d.ts.map +0 -1
- package/dist/runtime/mcp/stdio.js +0 -274
- package/dist/runtime/mcp/stdio.js.map +0 -1
- package/dist/runtime/mcp/tools.d.ts +0 -19
- package/dist/runtime/mcp/tools.d.ts.map +0 -1
- package/dist/runtime/mcp/tools.js +0 -525
- package/dist/runtime/mcp/tools.js.map +0 -1
- package/dist/runtime/mcp-http/app.d.ts +0 -33
- package/dist/runtime/mcp-http/app.d.ts.map +0 -1
- package/dist/runtime/mcp-http/app.js +0 -162
- package/dist/runtime/mcp-http/app.js.map +0 -1
- package/dist/runtime/mcp-http/hosted-mcp-server.d.ts +0 -14
- package/dist/runtime/mcp-http/hosted-mcp-server.d.ts.map +0 -1
- package/dist/runtime/mcp-http/hosted-mcp-server.js +0 -38
- package/dist/runtime/mcp-http/hosted-mcp-server.js.map +0 -1
- package/dist/runtime/mcp-http/index.d.ts +0 -18
- package/dist/runtime/mcp-http/index.d.ts.map +0 -1
- package/dist/runtime/mcp-http/index.js +0 -18
- package/dist/runtime/mcp-http/index.js.map +0 -1
- package/dist/runtime/mcp-http/main.d.ts +0 -2
- package/dist/runtime/mcp-http/main.d.ts.map +0 -1
- package/dist/runtime/mcp-http/main.js +0 -14
- package/dist/runtime/mcp-http/main.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/broker-login.d.ts +0 -50
- package/dist/runtime/mcp-http/oauth/broker-login.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/broker-login.js +0 -41
- package/dist/runtime/mcp-http/oauth/broker-login.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/broker-routes.d.ts +0 -45
- package/dist/runtime/mcp-http/oauth/broker-routes.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/broker-routes.js +0 -54
- package/dist/runtime/mcp-http/oauth/broker-routes.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/config.d.ts +0 -49
- package/dist/runtime/mcp-http/oauth/config.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/config.js +0 -69
- package/dist/runtime/mcp-http/oauth/config.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/crypto.d.ts +0 -14
- package/dist/runtime/mcp-http/oauth/crypto.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/crypto.js +0 -29
- package/dist/runtime/mcp-http/oauth/crypto.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/file-store.d.ts +0 -48
- package/dist/runtime/mcp-http/oauth/file-store.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/file-store.js +0 -101
- package/dist/runtime/mcp-http/oauth/file-store.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/hono-oauth-router.d.ts +0 -5
- package/dist/runtime/mcp-http/oauth/hono-oauth-router.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/hono-oauth-router.js +0 -92
- package/dist/runtime/mcp-http/oauth/hono-oauth-router.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/oidc-rp.d.ts +0 -56
- package/dist/runtime/mcp-http/oauth/oidc-rp.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/oidc-rp.js +0 -63
- package/dist/runtime/mcp-http/oauth/oidc-rp.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/redirect-uri.d.ts +0 -11
- package/dist/runtime/mcp-http/oauth/redirect-uri.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/redirect-uri.js +0 -29
- package/dist/runtime/mcp-http/oauth/redirect-uri.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/single-tenant-provider.d.ts +0 -70
- package/dist/runtime/mcp-http/oauth/single-tenant-provider.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/single-tenant-provider.js +0 -247
- package/dist/runtime/mcp-http/oauth/single-tenant-provider.js.map +0 -1
- package/dist/runtime/mcp-http/oauth/tenancy.d.ts +0 -3
- package/dist/runtime/mcp-http/oauth/tenancy.d.ts.map +0 -1
- package/dist/runtime/mcp-http/oauth/tenancy.js +0 -18
- package/dist/runtime/mcp-http/oauth/tenancy.js.map +0 -1
- package/dist/runtime/mcp-http/readonly-allowlist.d.ts +0 -24
- package/dist/runtime/mcp-http/readonly-allowlist.d.ts.map +0 -1
- package/dist/runtime/mcp-http/readonly-allowlist.js +0 -43
- package/dist/runtime/mcp-http/readonly-allowlist.js.map +0 -1
- package/dist/runtime/mcp-http/serve.d.ts +0 -39
- package/dist/runtime/mcp-http/serve.d.ts.map +0 -1
- package/dist/runtime/mcp-http/serve.js +0 -100
- package/dist/runtime/mcp-http/serve.js.map +0 -1
- package/dist/runtime/mirror/accept.d.ts +0 -57
- package/dist/runtime/mirror/accept.d.ts.map +0 -1
- package/dist/runtime/mirror/accept.js +0 -70
- package/dist/runtime/mirror/accept.js.map +0 -1
- package/dist/runtime/mirror/build.d.ts +0 -44
- package/dist/runtime/mirror/build.d.ts.map +0 -1
- package/dist/runtime/mirror/build.js +0 -37
- package/dist/runtime/mirror/build.js.map +0 -1
- package/dist/runtime/mirror/index.d.ts +0 -9
- package/dist/runtime/mirror/index.d.ts.map +0 -1
- package/dist/runtime/mirror/index.js +0 -9
- package/dist/runtime/mirror/index.js.map +0 -1
- package/dist/runtime/mirror/serve.d.ts +0 -21
- package/dist/runtime/mirror/serve.d.ts.map +0 -1
- package/dist/runtime/mirror/serve.js +0 -141
- package/dist/runtime/mirror/serve.js.map +0 -1
- package/dist/runtime/nhi.d.ts +0 -15
- package/dist/runtime/nhi.d.ts.map +0 -1
- package/dist/runtime/nhi.js +0 -26
- package/dist/runtime/nhi.js.map +0 -1
- package/dist/runtime/remote/accept.d.ts +0 -50
- package/dist/runtime/remote/accept.d.ts.map +0 -1
- package/dist/runtime/remote/accept.js +0 -54
- package/dist/runtime/remote/accept.js.map +0 -1
- package/dist/runtime/remote/client.d.ts +0 -27
- package/dist/runtime/remote/client.d.ts.map +0 -1
- package/dist/runtime/remote/client.js +0 -34
- package/dist/runtime/remote/client.js.map +0 -1
- package/dist/runtime/remote/index.d.ts +0 -5
- package/dist/runtime/remote/index.d.ts.map +0 -1
- package/dist/runtime/remote/index.js +0 -5
- package/dist/runtime/remote/index.js.map +0 -1
- package/dist/runtime/remote/serve.d.ts +0 -27
- package/dist/runtime/remote/serve.d.ts.map +0 -1
- package/dist/runtime/remote/serve.js +0 -26
- package/dist/runtime/remote/serve.js.map +0 -1
- package/dist/runtime/remote/server.d.ts +0 -36
- package/dist/runtime/remote/server.d.ts.map +0 -1
- package/dist/runtime/remote/server.js +0 -100
- package/dist/runtime/remote/server.js.map +0 -1
- package/dist/runtime/sysml/client.d.ts +0 -44
- package/dist/runtime/sysml/client.d.ts.map +0 -1
- package/dist/runtime/sysml/client.js +0 -52
- package/dist/runtime/sysml/client.js.map +0 -1
- package/dist/runtime/sysml/disclosure.d.ts +0 -23
- package/dist/runtime/sysml/disclosure.d.ts.map +0 -1
- package/dist/runtime/sysml/disclosure.js +0 -53
- package/dist/runtime/sysml/disclosure.js.map +0 -1
- package/dist/runtime/sysml/index.d.ts +0 -4
- package/dist/runtime/sysml/index.d.ts.map +0 -1
- package/dist/runtime/sysml/index.js +0 -4
- package/dist/runtime/sysml/index.js.map +0 -1
- package/dist/runtime/sysml/verify.d.ts +0 -32
- package/dist/runtime/sysml/verify.d.ts.map +0 -1
- package/dist/runtime/sysml/verify.js +0 -67
- package/dist/runtime/sysml/verify.js.map +0 -1
- package/dist/runtime/upgrade/index.d.ts +0 -96
- package/dist/runtime/upgrade/index.d.ts.map +0 -1
- package/dist/runtime/upgrade/index.js +0 -185
- package/dist/runtime/upgrade/index.js.map +0 -1
- package/dist/runtime/version/agent-version.d.ts +0 -15
- package/dist/runtime/version/agent-version.d.ts.map +0 -1
- package/dist/runtime/version/agent-version.js +0 -53
- package/dist/runtime/version/agent-version.js.map +0 -1
- package/skills/h2a/SKILL.md +0 -345
package/dist/cli.js
DELETED
|
@@ -1,4672 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `h2a` CLI dispatcher — stable JSON output contract + exit-code table (DEC-034).
|
|
3
|
-
*
|
|
4
|
-
* Output shapes
|
|
5
|
-
* -------------
|
|
6
|
-
* Every JSON-emitting verb writes ONE of three canonical envelopes on stdout:
|
|
7
|
-
*
|
|
8
|
-
* - **resource** — bare JSON of a single entity. Used by verbs that return the
|
|
9
|
-
* persisted/loaded record itself (`negotiate open`, `negotiate status`,
|
|
10
|
-
* `negotiate event`, `negotiate offer`, `negotiate counter`, `negotiate sign`,
|
|
11
|
-
* `inbox pop`, `host setup --print`).
|
|
12
|
-
* - **list** — bare JSON array. Used by `hosts`, `mcp-tools`, `discover`,
|
|
13
|
-
* `inbox read`, `outbox read`, `negotiate journal`.
|
|
14
|
-
* - **action** — `{ ok: true, ...details }` confirmation envelope. Used by
|
|
15
|
-
* verbs that perform side effects without a natural entity to return
|
|
16
|
-
* (`init`, `register`, `inbox put`, `outbox put`, `negotiate stabilize`,
|
|
17
|
-
* `host setup --write`).
|
|
18
|
-
*
|
|
19
|
-
* Stderr lines always follow `h2a <verb> [sub]: <message>` so callers can
|
|
20
|
-
* grep them deterministically. The `mcp-serve` verb is a long-running
|
|
21
|
-
* JSON-RPC 2.0 stdio transport and does not fit the envelope contract.
|
|
22
|
-
*
|
|
23
|
-
* Exit codes
|
|
24
|
-
* ----------
|
|
25
|
-
*
|
|
26
|
-
* - `0` — success.
|
|
27
|
-
* - `1` — user error: missing/bad flag, invalid JSON, validation failure on
|
|
28
|
-
* caller-supplied data, unknown verb/subverb/host.
|
|
29
|
-
* - `2` — runtime/state error: store conflict or business-rule violation
|
|
30
|
-
* (negotiation not found, already open, already stabilized, signature
|
|
31
|
-
* fails verification, quorum incomplete, broken journal, divergent
|
|
32
|
-
* pre-existing config file refusing merge without `--force`).
|
|
33
|
-
* - `3` — I/O / OS error: file unreadable, permission denied, write
|
|
34
|
-
* refused by the filesystem.
|
|
35
|
-
*
|
|
36
|
-
* The full machine-readable manifest lives in `./cli-contract.ts`
|
|
37
|
-
* (`H2A_CLI_VERB_CONTRACTS`). Human-readable reference: `docs/cli-contract.md`.
|
|
38
|
-
*/
|
|
39
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
40
|
-
import { generateKeyPairSync } from "node:crypto";
|
|
41
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
42
|
-
import { homedir, hostname } from "node:os";
|
|
43
|
-
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
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, deriveWorkspaceId, diffOrgManifest, effectiveOrgInstances, isComprehensionAttestation, nhiAttestationEnvelope, nhiInventory, deriveConnectionConfidence, nhiTrustBundle, orgAssignmentEnvelope, parseOrgManifest, signCanonical, signEnvelope, subagentAddress, validateOrgManifest, verifyComprehensionAttestation } from "@sentropic/h2a";
|
|
46
|
-
import { H2A_CLAUDE_HOST } from "./hosts/claude.js";
|
|
47
|
-
import { H2A_CODEX_HOST } from "./hosts/codex.js";
|
|
48
|
-
import { H2A_GEMINI_HOST } from "./hosts/gemini.js";
|
|
49
|
-
import { H2A_AGY_HOST } from "./hosts/agy.js";
|
|
50
|
-
import { H2A_CLI_MCP_TOOL_NAMES } from "./mcp.js";
|
|
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, reapAllDeadPresence, reapDeadInstancePresence, readPresence, resolveRecipient, safePathSegment, sanitizeStorePaths, writePresence } from "./runtime/local-files/index.js";
|
|
53
|
-
import { runMcpStdio } from "./runtime/mcp/index.js";
|
|
54
|
-
import { renderK8sSidecar } from "./runtime/deploy/k8s-sidecar.js";
|
|
55
|
-
import { renderK8sTenant } from "./runtime/deploy/k8s-tenant.js";
|
|
56
|
-
import { remoteServerForStore, sendRemoteEnvelope } from "./runtime/remote/index.js";
|
|
57
|
-
import { buildInstanceMirror, mirrorServerForStore } from "./runtime/mirror/index.js";
|
|
58
|
-
import { recordStop, scanDrumbeat, clearDrumbeatEntry, runDrumbeatWatch as runDrumbeatWatchLoop, loggingRelauncher, localTmuxRelauncher, headlessRelauncher, remoteRelauncher, chainRelauncher, relanceFromInbox, loggingDecider, subagentDecider, H2A_DEFAULT_MAX_RELANCES } from "./runtime/drumbeat/index.js";
|
|
59
|
-
import { gatherNhiSnapshot } from "./runtime/nhi.js";
|
|
60
|
-
import { raiseBlockage, listBlockages, resolveBlockage } from "./runtime/blockage/index.js";
|
|
61
|
-
import { recordEscalation, listEscalations, clearEscalation } from "./runtime/escalation/index.js";
|
|
62
|
-
import { authorizeDrive, chainDriver, formatSignedDriveInstruction, headlessDriver, localTmuxDriver, loggingDriver, nativeBackchannelDriver, remoteDriveServerForStore, verifyDriveOnReceive } from "./runtime/drive/index.js";
|
|
63
|
-
import { verifyEnvelopeSysmlRef } from "./runtime/sysml/index.js";
|
|
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
|
-
import { resolveLiveIdentity } from "./runtime/identity/index.js";
|
|
66
|
-
import { conductorFor } from "./runtime/governance/conductor.js";
|
|
67
|
-
import { appendConductorClaim } from "./runtime/governance/claims.js";
|
|
68
|
-
import { conductorLaunchCheck } from "./runtime/governance/launch-check.js";
|
|
69
|
-
import { lastSpawnRequestAt, recordSpawnRequest, spawnAllowed } from "./runtime/governance/spawns.js";
|
|
70
|
-
import { createObjectiveLoop, listLoopEvents, listObjectiveLoops, readObjectiveLoop } from "./runtime/loop/index.js";
|
|
71
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
72
|
-
// `dist/cli.js` lives in `packages/h2a-cli/dist/`; skills are at
|
|
73
|
-
// `packages/h2a-cli/skills/`. Two levels up from the dist file.
|
|
74
|
-
const SKILLS_DIR = resolvePath(HERE, "..", "skills");
|
|
75
|
-
/**
|
|
76
|
-
* Pattern matchers used to map known store-level error messages to exit code
|
|
77
|
-
* 2 (state/runtime conflict) instead of the default 1 (user error). Anything
|
|
78
|
-
* not matched here keeps the conservative 1 — DEC-034 explicitly opts for
|
|
79
|
-
* "don't over-promote".
|
|
80
|
-
*/
|
|
81
|
-
const STORE_STATE_ERROR_PATTERNS = [
|
|
82
|
-
/already registered/i,
|
|
83
|
-
/already open/i,
|
|
84
|
-
/already stabilized/i,
|
|
85
|
-
/not found/i,
|
|
86
|
-
/no such envelope/i,
|
|
87
|
-
/fails verification/i,
|
|
88
|
-
/no artifactHash has the full quorum/i,
|
|
89
|
-
/no offer\/counter event matches/i,
|
|
90
|
-
/stabilized artifact already on disk/i,
|
|
91
|
-
/attester-comprehension/i,
|
|
92
|
-
// DEC-069/070/072: subagent binding/routing/revocation preconditions.
|
|
93
|
-
/not registered/i,
|
|
94
|
-
/revoked/i,
|
|
95
|
-
/key not active/i,
|
|
96
|
-
/parent-not-agents/,
|
|
97
|
-
/parent-instance-mismatch/,
|
|
98
|
-
/capabilities-exceed-parent/
|
|
99
|
-
];
|
|
100
|
-
function classifyStoreError(message) {
|
|
101
|
-
for (const pattern of STORE_STATE_ERROR_PATTERNS) {
|
|
102
|
-
if (pattern.test(message))
|
|
103
|
-
return 2;
|
|
104
|
-
}
|
|
105
|
-
return 1;
|
|
106
|
-
}
|
|
107
|
-
const CLI_HOSTS = [
|
|
108
|
-
H2A_CODEX_HOST,
|
|
109
|
-
H2A_CLAUDE_HOST,
|
|
110
|
-
H2A_GEMINI_HOST,
|
|
111
|
-
H2A_AGY_HOST
|
|
112
|
-
];
|
|
113
|
-
export function renderCliHelp() {
|
|
114
|
-
return [
|
|
115
|
-
"h2a",
|
|
116
|
-
"",
|
|
117
|
-
"Human-to-agent coordination CLI.",
|
|
118
|
-
"",
|
|
119
|
-
"Usage:",
|
|
120
|
-
" h2a --help",
|
|
121
|
-
" h2a hosts",
|
|
122
|
-
" h2a mcp-tools",
|
|
123
|
-
" h2a init [--root <path>]",
|
|
124
|
-
" h2a register --json <json> [--root <path>]",
|
|
125
|
-
" h2a discover [--role <role>] [--scope <scope>] [--root <path>]",
|
|
126
|
-
" h2a subagent register --parent <instance> --name <name> [--capabilities a,b] [--root <path>]",
|
|
127
|
-
" h2a subagent list [--parent <instance>] [--root <path>]",
|
|
128
|
-
" h2a subagent route --to <subagent-address> --json <envelope> [--mailbox inbox|outbox] [--root <path>]",
|
|
129
|
-
" h2a subagent inbox --parent <instance> [--root <path>]",
|
|
130
|
-
" h2a subagent audit (--id <subagent-address> | --parent <instance>) [--root <path>]",
|
|
131
|
-
" h2a subagent revoke --id <subagent-address> [--reason <text>] [--root <path>]",
|
|
132
|
-
" h2a negotiate open --json <record-json> [--root <path>]",
|
|
133
|
-
" h2a negotiate status --id <id> --status <status> [--root <path>]",
|
|
134
|
-
" h2a negotiate event --id <id> --json <payload-json> [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
|
|
135
|
-
" h2a negotiate offer --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
|
|
136
|
-
" h2a negotiate counter --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
|
|
137
|
-
" h2a negotiate sign --id <id> --instance <id> --artifact <json> --private-key <pem-path> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
|
|
138
|
-
" h2a negotiate stabilize --id <id> [--event-id <id>] [--root <path>]",
|
|
139
|
-
" h2a negotiate journal --id <id> [--root <path>]",
|
|
140
|
-
" h2a declare-interest --negotiation <id> --instance <id> --interets <a,b> [--bindings <scope,...>] [--masque-impact-collectif] [--event-id <id>] [--root <path>]",
|
|
141
|
-
" h2a conflict-posture --negotiation <id> [--root <path>]",
|
|
142
|
-
" h2a dossier --negotiation <id> [--presenter <id>] [--advisory-gate] [--event-id <id>] [--root <path>]",
|
|
143
|
-
" h2a confiance --negotiation <id> [--root <path>]",
|
|
144
|
-
" h2a attest-comprehension --instance <id> --dossier <file|sha256:...> --private-key <pem-path> [--negotiation <id> | --to <instance>] [--role <role>] [--scope <scope>] [--root <path>]",
|
|
145
|
-
" h2a comprehension list --negotiation <id> [--root <path>]",
|
|
146
|
-
" h2a comprehension verify --json <event-or-envelope-json> --public-key <pem-file>",
|
|
147
|
-
"",
|
|
148
|
-
"Auto-propagation (DEC-033):",
|
|
149
|
-
" offer/counter/sign/event inherit causationId from the previous journal",
|
|
150
|
-
" entry's id, and correlationId from the previous entry's correlationId.",
|
|
151
|
-
" Explicit --causation-id / --correlation-id flags always override the",
|
|
152
|
-
" inherited default; pass them on the first offer to start a fresh thread.",
|
|
153
|
-
" h2a inbox put --instance <id> --json <envelope> [--root <path>]",
|
|
154
|
-
" h2a inbox read --instance <id> [--root <path>]",
|
|
155
|
-
" h2a inbox pop --instance <id> --envelope <id> [--root <path>]",
|
|
156
|
-
" h2a outbox put --instance <id> --json <envelope> [--root <path>]",
|
|
157
|
-
" h2a outbox read --instance <id> [--root <path>]",
|
|
158
|
-
" h2a mcp-serve [--root <path>] [--auto-open [--host <h>] [--instance <id>] [--scope <s>]] [--wake <native|logging>] [--upgrade-check | --auto-upgrade [--no-restart]] (--auto-open joins the bus at startup; --wake injects a signed h2a-tagged wake into the host on inbox arrival, EVO-1, needs --auto-open; --auto-upgrade self-updates + restarts in place; --upgrade-check = notice only; both opt-in/no network by default; /h2a disconnect to leave)",
|
|
159
|
-
" h2a upgrade [--check] (--check: report current vs latest; bare: npm i -g @sentropic/h2a-cli@latest)",
|
|
160
|
-
" h2a remote serve [--port <n>] [--host <h>] [--path </h2a/envelopes>] [--root <path>]",
|
|
161
|
-
" h2a remote send --url <u> --instance <signer> --private-key <pem> --json <envelope>",
|
|
162
|
-
" h2a remote mirror-serve [--port <n>] [--host <h>] [--path </h2a/mirror>] [--enrolled-keys-file <json>] [--root <path>] (EVO-13 instance-mirror ingester; enrolled keys also via H2A_MIRROR_ENROLLED_KEYS base64)",
|
|
163
|
-
" h2a remote mirror --url <u> --instance <id> --private-key <pem> [--root <path>] (push this instance's registration to a remote ingester)",
|
|
164
|
-
" h2a drive --from <instance> --to <instance> --instruction <text> --private-key <pem> [--driver logging|native|local-tmux|headless|auto] [--host <host>] [--root <path>]",
|
|
165
|
-
" h2a drive receive --to <instance> (--line <signed-line> | --stdin) [--ignore-non-drive] [--root <path>] (verify-before-act gate for host hooks)",
|
|
166
|
-
" h2a drive serve --to <instance> --inject-command <command> [--port <n>] [--host <h>] [--path </h2a/drive>] [--root <path>] (remote verify-before-inject service)",
|
|
167
|
-
" h2a drumbeat record --instance <id> --status <working|paused|done|blocked|out-of-tokens> [--command <c>] [--resume-command <c>] [--tmux-session <s> --tmux-pane <p>] [--root <path>]",
|
|
168
|
-
" h2a drumbeat scan [--max-relances <n>] [--root <path>]",
|
|
169
|
-
" h2a drumbeat clear --instance <id> [--root <path>]",
|
|
170
|
-
" h2a drumbeat escalations [--root <path>]",
|
|
171
|
-
" h2a drumbeat relance-inbox [--instance <id>] [--relauncher logging|local-tmux|headless|auto] [--root <path>]",
|
|
172
|
-
" h2a drumbeat watch [--interval-ms <n>] [--max-relances <n>] [--relauncher logging|local-tmux|remote|headless|auto] [--instance <signer> --private-key <pem>] [--decider logging|<command>] [--decider-after <k>] [--decider-enforce] [--root <path>]",
|
|
173
|
-
" h2a host setup --host <codex|claude|gemini|agy> [--root <path>] [--print | --write <file>] [--force] [--no-wake] (renders mcp-serve --auto-open --auto-upgrade --wake local-tmux by default; --no-wake drops wake)",
|
|
174
|
-
" h2a host status [--host <name>]",
|
|
175
|
-
" h2a host plugin --host <codex|claude|gemini|agy> --instance <id> [--status <work-status>] [--root <path>] [--write <settings.json> [--force]] [--scaffold <dir>] (--write installs the Stop hook for claude|gemini|codex; --scaffold writes codex's full local marketplace + trust step; agy is poll-only)",
|
|
176
|
-
" h2a store migrate [--from <v>] [--to <v>] [--sanitize-paths] [--dry-run] [--root <path>]",
|
|
177
|
-
"",
|
|
178
|
-
"High-level coordination (DEC-054):",
|
|
179
|
-
" h2a connect --host <codex|claude|gemini|agy|remote> [--root <path>] [--instance <id>] [--name <display>]",
|
|
180
|
-
" 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)",
|
|
181
|
-
" h2a conductor-launch-check [--workspace <id|path>] [--root <path>] [--idle-ms <ms>] (DRY-RUN: polls track workspace-activity; recommends launching a conductor if work is stalled and none is live — h2a does NOT spawn; launch parked pending spawn policy + remote)",
|
|
182
|
-
" h2a conductor-launch --workspace <id|path> [--root <path>] [--idle-ms <ms>] [--confirm] [--remote <instance>] [--instance <self>] (D3 EMIT: if stalled+no conductor, emits a launch-REQUEST envelope to a live remote agent — gated by --confirm + 1/30min/workspace cap; h2a NEVER spawns; remote does the actual spawn)",
|
|
183
|
-
" h2a doctor [--root <path>] [--scan <dir>] [--prune] (--prune deletes host-less/phantom/orphan inbox dirs + stray buses; dry-run by default)",
|
|
184
|
-
" h2a keepalive [--root <path>] [--interval <ms>] [--once] (external keepalive prober — refreshes presence for agents whose tmux pane is still alive)",
|
|
185
|
-
" h2a rename --instance <id> --name <name> [--root <path>] (set a live session's display name so peers can find it via discover --name)",
|
|
186
|
-
" h2a status [--root <path>] [--scope <s>] [--instance <i>]",
|
|
187
|
-
" h2a sessions [--root <path>] [--scope <s>] [--instance <i>]",
|
|
188
|
-
" h2a thread --id <threadId> --instance <self> [--root <path>] (the ordered conversation for a thread, from your inbox+outbox)",
|
|
189
|
-
" h2a keys generate --instance <id> [--out <dir>] [--root <path>]",
|
|
190
|
-
" h2a keys add --instance <id> --public-key <pem-file> [--root <path>]",
|
|
191
|
-
" h2a keys list --instance <id> [--root <path>]",
|
|
192
|
-
" h2a keys revoke --instance <id> --public-key <pem-file> [--root <path>]",
|
|
193
|
-
" h2a nhi report [--long-lived-days <n>] [--root <path>]",
|
|
194
|
-
" h2a nhi inventory [--long-lived-days <n>] [--root <path>]",
|
|
195
|
-
" h2a nhi attest --instance <id> --private-key <pem-file> [--role <role>] [--scope <scope>] [--root <path>]",
|
|
196
|
-
" h2a nhi offboard --instance <id> [--reason <text>] [--root <path>]",
|
|
197
|
-
" h2a nhi export --instance <id> --trust-domain <domain> [--root <path>]",
|
|
198
|
-
" h2a blockage raise --instance <id> --reason <text> [--scope <s>] [--needs <text>] [--root <path>]",
|
|
199
|
-
" h2a blockage list [--scope <s>] [--active] [--root <path>]",
|
|
200
|
-
" h2a blockage resolve --instance <id> [--by <id>] [--root <path>]",
|
|
201
|
-
" h2a sysml verify --json <envelope> --public-key <pem-file> [--by <id>] [--content-integrity --api-base <url> [--auth <token>]]",
|
|
202
|
-
" h2a install-skills --host <claude|codex|gemini|agy> [--scope user|project] [--force]",
|
|
203
|
-
" h2a deploy k8s-sidecar [--instance <id>] [--host <h>] [--root <path>] [--image <ref>] [--cli-version <ver>] [--write <file>]",
|
|
204
|
-
" h2a deploy k8s-tenant [--namespace <ns>] [--root <path>] [--replicas <n>] [--storage <size>] [--storage-class <sc>] [--lease-ms <ms>] [--image <ref>] [--cli-version <ver>] [--write <file>]",
|
|
205
|
-
" h2a loop create --name <n> --goal <text> [--repo <path[:role]>] [--track <json>] [--agent <host:role:placement>] [--root <path>]",
|
|
206
|
-
" h2a loop list [--root <path>]",
|
|
207
|
-
" h2a loop status <loopId> [--root <path>]",
|
|
208
|
-
" h2a loop agents <loopId> [--root <path>]",
|
|
209
|
-
" h2a loop attach <loopId> --agent <selector> [--root <path>]",
|
|
210
|
-
" h2a loop logs <loopId> [--agent <selector>] [--root <path>]",
|
|
211
|
-
"",
|
|
212
|
-
`Hosts: ${CLI_HOSTS.map((host) => host.host).join(", ")}`,
|
|
213
|
-
`MCP tools: ${H2A_CLI_MCP_TOOL_NAMES.join(", ")}`
|
|
214
|
-
].join("\n");
|
|
215
|
-
}
|
|
216
|
-
function parseFlags(argv) {
|
|
217
|
-
const [command, ...rest] = argv;
|
|
218
|
-
const flags = {};
|
|
219
|
-
for (let i = 0; i < rest.length; i++) {
|
|
220
|
-
const token = rest[i];
|
|
221
|
-
if (token.startsWith("--")) {
|
|
222
|
-
const key = token.slice(2);
|
|
223
|
-
const next = rest[i + 1];
|
|
224
|
-
if (next !== undefined && !next.startsWith("--")) {
|
|
225
|
-
flags[key] = next;
|
|
226
|
-
i++;
|
|
227
|
-
}
|
|
228
|
-
else {
|
|
229
|
-
flags[key] = "true";
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return { command, flags };
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Resolve the h2a store root, with its provenance.
|
|
237
|
-
*
|
|
238
|
-
* Precedence: explicit `--root` flag → `H2A_ROOT` env → the shared global
|
|
239
|
-
* default `~/h2a-workspace/.h2a`. The global default lets all agents on the
|
|
240
|
-
* same machine share one bus without any explicit configuration — eliminating
|
|
241
|
-
* the split-brain failure (F4) that the old `cwd/.h2a` fallback caused.
|
|
242
|
-
* Callers on long-lived paths (`mcp-serve`, `connect`) warn when a repo-local
|
|
243
|
-
* `.h2a` exists and is being ignored in favour of the shared bus.
|
|
244
|
-
*/
|
|
245
|
-
function resolveRootInfo(flags, cwd) {
|
|
246
|
-
if (flags.root)
|
|
247
|
-
return { root: flags.root, source: "flag" };
|
|
248
|
-
const env = process.env.H2A_ROOT;
|
|
249
|
-
if (env && env.length > 0)
|
|
250
|
-
return { root: env, source: "env" };
|
|
251
|
-
return { root: join(homedir(), "h2a-workspace", ".h2a"), source: "default" };
|
|
252
|
-
}
|
|
253
|
-
function resolveRoot(flags, cwd) {
|
|
254
|
-
return resolveRootInfo(flags, cwd).root;
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Warn (once, to stderr) when the resolved root (from the shared default OR
|
|
258
|
-
* from H2A_ROOT env) differs from a repo-local `.h2a` that exists in the
|
|
259
|
-
* current working directory. The user may have intended to use the local bus —
|
|
260
|
-
* so we alert them to pass `--root <cwd>/.h2a` if that was their intent. When
|
|
261
|
-
* no local `.h2a` exists around the cwd, or when an explicit `--root` flag was
|
|
262
|
-
* used (the user was unambiguous), stay silent. Used by `mcp-serve` and `connect`.
|
|
263
|
-
*/
|
|
264
|
-
function warnIfCwdRootFallback(flags, cwd, streams) {
|
|
265
|
-
const info = resolveRootInfo(flags, cwd);
|
|
266
|
-
// Only warn for implicit resolution (default or env). An explicit --root flag
|
|
267
|
-
// means the user was unambiguous about which bus to use.
|
|
268
|
-
if (info.source === "flag")
|
|
269
|
-
return;
|
|
270
|
-
const cwdLocal = join(cwd(), ".h2a");
|
|
271
|
-
try {
|
|
272
|
-
if (!existsSync(cwdLocal))
|
|
273
|
-
return;
|
|
274
|
-
const resolvedLocal = realpathSync(cwdLocal);
|
|
275
|
-
const resolvedRoot = (() => {
|
|
276
|
-
try {
|
|
277
|
-
return realpathSync(info.root);
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return info.root;
|
|
281
|
-
}
|
|
282
|
-
})();
|
|
283
|
-
if (resolvedLocal === resolvedRoot)
|
|
284
|
-
return;
|
|
285
|
-
streams.stderr.write(`h2a: a repo-local .h2a exists here but I'm using the shared bus ${info.root}. ` +
|
|
286
|
-
`Pass --root ${cwdLocal} if you meant the local one.\n`);
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
// silently ignore filesystem errors
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Resolve `causationId` / `correlationId` for a new negotiation event.
|
|
294
|
-
*
|
|
295
|
-
* - Explicit `--causation-id` / `--correlation-id` flags always win.
|
|
296
|
-
* - Otherwise, the values are inherited from the **previous journal entry**
|
|
297
|
-
* on the same negotiation (DEC-033): `causationId` defaults to the previous
|
|
298
|
-
* entry's `id`, `correlationId` is propagated as-is so the whole negotiation
|
|
299
|
-
* acts as a single correlation thread by default.
|
|
300
|
-
*/
|
|
301
|
-
function resolveCausationCorrelation(flags, previous) {
|
|
302
|
-
const explicitCausation = flags["causation-id"];
|
|
303
|
-
const explicitCorrelation = flags["correlation-id"];
|
|
304
|
-
const out = {};
|
|
305
|
-
if (explicitCausation) {
|
|
306
|
-
out.causationId = explicitCausation;
|
|
307
|
-
}
|
|
308
|
-
else if (previous) {
|
|
309
|
-
out.causationId = previous.id;
|
|
310
|
-
}
|
|
311
|
-
if (explicitCorrelation) {
|
|
312
|
-
out.correlationId = explicitCorrelation;
|
|
313
|
-
}
|
|
314
|
-
else if (previous && previous.correlationId !== undefined) {
|
|
315
|
-
out.correlationId = previous.correlationId;
|
|
316
|
-
}
|
|
317
|
-
return out;
|
|
318
|
-
}
|
|
319
|
-
function cmdInit(flags, streams) {
|
|
320
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
321
|
-
const root = resolveRoot(flags, cwd);
|
|
322
|
-
try {
|
|
323
|
-
const store = createLocalStore({ root });
|
|
324
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, root: store.paths.root }, null, 2)}\n`);
|
|
325
|
-
return 0;
|
|
326
|
-
}
|
|
327
|
-
catch (error) {
|
|
328
|
-
// `createLocalStore` is the one place this verb can fail, and the only
|
|
329
|
-
// failure mode in practice is filesystem-level (cannot mkdir under root,
|
|
330
|
-
// permission denied, …). Surface those as exit code 3.
|
|
331
|
-
streams.stderr.write(`h2a init: ${error.message}\n`);
|
|
332
|
-
return 3;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
function cmdRegister(flags, streams) {
|
|
336
|
-
if (!flags.json) {
|
|
337
|
-
streams.stderr.write("h2a register: --json <registration-json> is required\n");
|
|
338
|
-
return 1;
|
|
339
|
-
}
|
|
340
|
-
let registration;
|
|
341
|
-
try {
|
|
342
|
-
registration = JSON.parse(flags.json);
|
|
343
|
-
}
|
|
344
|
-
catch (error) {
|
|
345
|
-
streams.stderr.write(`h2a register: invalid JSON (${error.message})\n`);
|
|
346
|
-
return 1;
|
|
347
|
-
}
|
|
348
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
349
|
-
const root = resolveRoot(flags, cwd);
|
|
350
|
-
const store = createLocalStore({ root });
|
|
351
|
-
try {
|
|
352
|
-
store.registerInstance(registration);
|
|
353
|
-
}
|
|
354
|
-
catch (error) {
|
|
355
|
-
const message = error.message;
|
|
356
|
-
streams.stderr.write(`h2a register: ${message}\n`);
|
|
357
|
-
return classifyStoreError(message);
|
|
358
|
-
}
|
|
359
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, id: registration.id, root: store.paths.root }, null, 2)}\n`);
|
|
360
|
-
return 0;
|
|
361
|
-
}
|
|
362
|
-
function cmdSubagent(argv, streams) {
|
|
363
|
-
// DEC-068 (V2): manage addressable subagent bindings under a parent AGENTS
|
|
364
|
-
// instance. `register` constructs + persists a binding; `list` enumerates.
|
|
365
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
366
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
367
|
-
const root = resolveRoot(flags, cwd);
|
|
368
|
-
const store = createLocalStore({ root });
|
|
369
|
-
if (sub === "list") {
|
|
370
|
-
const entries = flags.parent
|
|
371
|
-
? store.listSubagentsOf(flags.parent)
|
|
372
|
-
: store.listSubagents();
|
|
373
|
-
// DEC-072: annotate each binding with its derived lifecycle status.
|
|
374
|
-
const annotated = entries.map((b) => ({
|
|
375
|
-
...b,
|
|
376
|
-
status: store.subagentStatus(b.id)
|
|
377
|
-
}));
|
|
378
|
-
streams.stdout.write(`${JSON.stringify(annotated, null, 2)}\n`);
|
|
379
|
-
return 0;
|
|
380
|
-
}
|
|
381
|
-
if (sub === "revoke") {
|
|
382
|
-
if (!flags.id) {
|
|
383
|
-
streams.stderr.write("h2a subagent revoke: --id <subagent-address> is required\n");
|
|
384
|
-
return 1;
|
|
385
|
-
}
|
|
386
|
-
try {
|
|
387
|
-
store.revokeSubagent(flags.id, flags.reason);
|
|
388
|
-
}
|
|
389
|
-
catch (error) {
|
|
390
|
-
const message = error.message;
|
|
391
|
-
streams.stderr.write(`h2a subagent revoke: ${message}\n`);
|
|
392
|
-
return classifyStoreError(message);
|
|
393
|
-
}
|
|
394
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, id: flags.id, status: "revoked", ...(flags.reason ? { reason: flags.reason } : {}) }, null, 2)}\n`);
|
|
395
|
-
return 0;
|
|
396
|
-
}
|
|
397
|
-
if (sub === "route") {
|
|
398
|
-
if (!flags.to || !flags.json) {
|
|
399
|
-
streams.stderr.write("h2a subagent route: --to <subagent-address> and --json <envelope-json> are required\n");
|
|
400
|
-
return 1;
|
|
401
|
-
}
|
|
402
|
-
const mailbox = flags.mailbox === "outbox" ? "outbox" : "inbox";
|
|
403
|
-
if (flags.mailbox && flags.mailbox !== "inbox" && flags.mailbox !== "outbox") {
|
|
404
|
-
streams.stderr.write(`h2a subagent route: --mailbox must be inbox or outbox (got "${flags.mailbox}")\n`);
|
|
405
|
-
return 1;
|
|
406
|
-
}
|
|
407
|
-
let envelope;
|
|
408
|
-
try {
|
|
409
|
-
envelope = JSON.parse(flags.json);
|
|
410
|
-
}
|
|
411
|
-
catch (error) {
|
|
412
|
-
streams.stderr.write(`h2a subagent route: invalid JSON (${error.message})\n`);
|
|
413
|
-
return 1;
|
|
414
|
-
}
|
|
415
|
-
try {
|
|
416
|
-
store.routeToSubagent(flags.to, envelope, mailbox);
|
|
417
|
-
}
|
|
418
|
-
catch (error) {
|
|
419
|
-
const message = error.message;
|
|
420
|
-
streams.stderr.write(`h2a subagent route: ${message}\n`);
|
|
421
|
-
return classifyStoreError(message);
|
|
422
|
-
}
|
|
423
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, to: flags.to, mailbox }, null, 2)}\n`);
|
|
424
|
-
return 0;
|
|
425
|
-
}
|
|
426
|
-
if (sub === "inbox") {
|
|
427
|
-
if (!flags.parent) {
|
|
428
|
-
streams.stderr.write("h2a subagent inbox: --parent <instance> is required\n");
|
|
429
|
-
return 1;
|
|
430
|
-
}
|
|
431
|
-
const fanIn = store.readSubagentInboxes(flags.parent);
|
|
432
|
-
streams.stdout.write(`${JSON.stringify(fanIn, null, 2)}\n`);
|
|
433
|
-
return 0;
|
|
434
|
-
}
|
|
435
|
-
if (sub === "audit") {
|
|
436
|
-
if (!flags.id && !flags.parent) {
|
|
437
|
-
streams.stderr.write("h2a subagent audit: --id <subagent-address> or --parent <instance> is required\n");
|
|
438
|
-
return 1;
|
|
439
|
-
}
|
|
440
|
-
const events = flags.id
|
|
441
|
-
? store.readSubagentAudit(flags.id)
|
|
442
|
-
: store.readSubagentAuditOf(flags.parent);
|
|
443
|
-
streams.stdout.write(`${JSON.stringify(events, null, 2)}\n`);
|
|
444
|
-
return 0;
|
|
445
|
-
}
|
|
446
|
-
if (sub === "register") {
|
|
447
|
-
if (!flags.parent || !flags.name) {
|
|
448
|
-
streams.stderr.write("h2a subagent register: --parent <instance> and --name <name> are required\n");
|
|
449
|
-
return 1;
|
|
450
|
-
}
|
|
451
|
-
const capabilities = flags.capabilities
|
|
452
|
-
? flags.capabilities.split(",").map((c) => c.trim()).filter((c) => c.length > 0)
|
|
453
|
-
: undefined;
|
|
454
|
-
const binding = {
|
|
455
|
-
id: subagentAddress(flags.parent, flags.name),
|
|
456
|
-
parentInstance: flags.parent,
|
|
457
|
-
name: flags.name,
|
|
458
|
-
...(capabilities ? { capabilities } : {}),
|
|
459
|
-
createdAt: new Date().toISOString()
|
|
460
|
-
};
|
|
461
|
-
try {
|
|
462
|
-
store.registerSubagent(binding);
|
|
463
|
-
}
|
|
464
|
-
catch (error) {
|
|
465
|
-
const message = error.message;
|
|
466
|
-
streams.stderr.write(`h2a subagent register: ${message}\n`);
|
|
467
|
-
return classifyStoreError(message);
|
|
468
|
-
}
|
|
469
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, id: binding.id, parentInstance: binding.parentInstance, root: store.paths.root }, null, 2)}\n`);
|
|
470
|
-
return 0;
|
|
471
|
-
}
|
|
472
|
-
streams.stderr.write(`h2a subagent: subcommand required (supported: register, list, route, inbox, audit, revoke)\n`);
|
|
473
|
-
return 1;
|
|
474
|
-
}
|
|
475
|
-
function cmdMailbox(argv, mailbox, streams) {
|
|
476
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
477
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
478
|
-
const root = resolveRoot(flags, cwd);
|
|
479
|
-
const store = createLocalStore({ root });
|
|
480
|
-
if (!flags.instance) {
|
|
481
|
-
streams.stderr.write(`h2a ${mailbox} ${sub ?? ""}: --instance <id> required\n`);
|
|
482
|
-
return 1;
|
|
483
|
-
}
|
|
484
|
-
if (sub === "put") {
|
|
485
|
-
if (!flags.json) {
|
|
486
|
-
streams.stderr.write(`h2a ${mailbox} put: --json <envelope-json> required\n`);
|
|
487
|
-
return 1;
|
|
488
|
-
}
|
|
489
|
-
let envelope;
|
|
490
|
-
try {
|
|
491
|
-
envelope = JSON.parse(flags.json);
|
|
492
|
-
}
|
|
493
|
-
catch (error) {
|
|
494
|
-
streams.stderr.write(`h2a ${mailbox} put: invalid JSON (${error.message})\n`);
|
|
495
|
-
return 1;
|
|
496
|
-
}
|
|
497
|
-
if (mailbox === "inbox") {
|
|
498
|
-
try {
|
|
499
|
-
assertHostQualifiedAddress(flags.instance, "recipient");
|
|
500
|
-
}
|
|
501
|
-
catch (error) {
|
|
502
|
-
streams.stderr.write(`\n${error.message}\n`);
|
|
503
|
-
return 1;
|
|
504
|
-
}
|
|
505
|
-
// WP-2: resolve-before-send — legibility gate (does NOT change destination).
|
|
506
|
-
const root = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
|
|
507
|
-
const liveSessions = listPresence(root).map((s) => s.instance);
|
|
508
|
-
const registeredIds = store.listInstances().map((i) => i.instance ?? i.id);
|
|
509
|
-
const resolution = resolveRecipient({
|
|
510
|
-
target: flags.instance,
|
|
511
|
-
liveInstances: liveSessions,
|
|
512
|
-
registeredInstances: registeredIds
|
|
513
|
-
});
|
|
514
|
-
if (resolution.kind === "refuse") {
|
|
515
|
-
streams.stderr.write(`\nh2a: ${resolution.reason}${resolution.candidates ? `\ncandidates: ${resolution.candidates.join(", ")}` : ""}\n`);
|
|
516
|
-
return 1;
|
|
517
|
-
}
|
|
518
|
-
// deliver / deliver-dormant / deliver-hint → proceed with put below.
|
|
519
|
-
}
|
|
520
|
-
try {
|
|
521
|
-
if (mailbox === "inbox") {
|
|
522
|
-
store.putInboxMessage(flags.instance, envelope);
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
store.putOutboxMessage(flags.instance, envelope);
|
|
526
|
-
}
|
|
527
|
-
// Bug-2 backstop: tell the caller whether the recipient is actually live
|
|
528
|
-
// (the write always succeeds — a dormant deposit-for-wake is legitimate).
|
|
529
|
-
const recipientLive = mailbox === "inbox"
|
|
530
|
-
? listPresence(store.paths.root).some((s) => canonicalAddress(s.instance) === canonicalAddress(flags.instance))
|
|
531
|
-
: undefined;
|
|
532
|
-
// WP-2: enrich stdout with resolution metadata.
|
|
533
|
-
let resolutionMeta = {};
|
|
534
|
-
if (mailbox === "inbox") {
|
|
535
|
-
const root2 = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
|
|
536
|
-
const liveSessions2 = listPresence(root2).map((s) => s.instance);
|
|
537
|
-
const registeredIds2 = store.listInstances().map((i) => i.instance ?? i.id);
|
|
538
|
-
const resolution2 = resolveRecipient({
|
|
539
|
-
target: flags.instance,
|
|
540
|
-
liveInstances: liveSessions2,
|
|
541
|
-
registeredInstances: registeredIds2
|
|
542
|
-
});
|
|
543
|
-
resolutionMeta = {
|
|
544
|
-
resolution: resolution2.kind,
|
|
545
|
-
...(resolution2.kind === "deliver-hint" ? { liveCandidate: resolution2.liveCandidate, reason: resolution2.reason } : {}),
|
|
546
|
-
...(resolution2.kind === "deliver-dormant" ? { reason: resolution2.reason, dormant: true } : {})
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, mailbox, instance: flags.instance, ...(recipientLive !== undefined ? { recipientLive } : {}), ...resolutionMeta }, null, 2)}\n`);
|
|
550
|
-
return 0;
|
|
551
|
-
}
|
|
552
|
-
catch (error) {
|
|
553
|
-
const message = error.message;
|
|
554
|
-
streams.stderr.write(`h2a ${mailbox} put: ${message}\n`);
|
|
555
|
-
// Envelope-shape failures are user/validation errors (exit 1); only
|
|
556
|
-
// state-level conflicts escalate to exit 2 (none currently emitted here,
|
|
557
|
-
// but the classifier keeps the door open).
|
|
558
|
-
return classifyStoreError(message);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
if (sub === "read") {
|
|
562
|
-
const messages = mailbox === "inbox" ? store.readInbox(flags.instance) : store.readOutbox(flags.instance);
|
|
563
|
-
streams.stdout.write(`${JSON.stringify(messages, null, 2)}\n`);
|
|
564
|
-
return 0;
|
|
565
|
-
}
|
|
566
|
-
if (sub === "pop" && mailbox === "inbox") {
|
|
567
|
-
if (!flags.envelope) {
|
|
568
|
-
streams.stderr.write("h2a inbox pop: --envelope <id> required\n");
|
|
569
|
-
return 1;
|
|
570
|
-
}
|
|
571
|
-
const popped = store.popInboxMessage(flags.instance, flags.envelope);
|
|
572
|
-
if (!popped) {
|
|
573
|
-
streams.stderr.write(`h2a inbox pop: no such envelope ${flags.envelope}\n`);
|
|
574
|
-
// State conflict against the local store (the envelope is not where the
|
|
575
|
-
// caller expected it). Exit code 2 per DEC-034.
|
|
576
|
-
return 2;
|
|
577
|
-
}
|
|
578
|
-
streams.stdout.write(`${JSON.stringify(popped, null, 2)}\n`);
|
|
579
|
-
return 0;
|
|
580
|
-
}
|
|
581
|
-
streams.stderr.write(`Unknown ${mailbox} subcommand: ${sub ?? "<none>"}\n`);
|
|
582
|
-
streams.stderr.write(mailbox === "inbox" ? "Use one of: put, read, pop\n" : "Use one of: put, read\n");
|
|
583
|
-
return 1;
|
|
584
|
-
}
|
|
585
|
-
function cmdNegotiate(argv, streams) {
|
|
586
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
587
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
588
|
-
const root = resolveRoot(flags, cwd);
|
|
589
|
-
const store = createLocalStore({ root });
|
|
590
|
-
if (sub === "open") {
|
|
591
|
-
if (!flags.json) {
|
|
592
|
-
streams.stderr.write("h2a negotiate open: --json <record-json> required\n");
|
|
593
|
-
return 1;
|
|
594
|
-
}
|
|
595
|
-
let record;
|
|
596
|
-
try {
|
|
597
|
-
record = JSON.parse(flags.json);
|
|
598
|
-
}
|
|
599
|
-
catch (error) {
|
|
600
|
-
streams.stderr.write(`h2a negotiate open: invalid JSON (${error.message})\n`);
|
|
601
|
-
return 1;
|
|
602
|
-
}
|
|
603
|
-
try {
|
|
604
|
-
const opened = store.openNegotiation(record);
|
|
605
|
-
streams.stdout.write(`${JSON.stringify(opened, null, 2)}\n`);
|
|
606
|
-
return 0;
|
|
607
|
-
}
|
|
608
|
-
catch (error) {
|
|
609
|
-
const message = error.message;
|
|
610
|
-
streams.stderr.write(`h2a negotiate open: ${message}\n`);
|
|
611
|
-
return classifyStoreError(message);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
if (sub === "status") {
|
|
615
|
-
if (!flags.id || !flags.status) {
|
|
616
|
-
streams.stderr.write("h2a negotiate status: --id <id> and --status <status> required\n");
|
|
617
|
-
return 1;
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
const updated = store.updateNegotiationStatus(flags.id, flags.status);
|
|
621
|
-
streams.stdout.write(`${JSON.stringify(updated, null, 2)}\n`);
|
|
622
|
-
return 0;
|
|
623
|
-
}
|
|
624
|
-
catch (error) {
|
|
625
|
-
const message = error.message;
|
|
626
|
-
streams.stderr.write(`h2a negotiate status: ${message}\n`);
|
|
627
|
-
return classifyStoreError(message);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
if (sub === "offer" || sub === "counter") {
|
|
631
|
-
if (!flags.id || !flags.instance || !flags.artifact) {
|
|
632
|
-
streams.stderr.write(`h2a negotiate ${sub}: --id <id> --instance <id> --artifact <json> required\n`);
|
|
633
|
-
return 1;
|
|
634
|
-
}
|
|
635
|
-
const record = store.readNegotiation(flags.id);
|
|
636
|
-
if (!record) {
|
|
637
|
-
streams.stderr.write(`h2a negotiate ${sub}: negotiation ${flags.id} not found\n`);
|
|
638
|
-
return 2;
|
|
639
|
-
}
|
|
640
|
-
let artifact;
|
|
641
|
-
try {
|
|
642
|
-
artifact = JSON.parse(flags.artifact);
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
streams.stderr.write(`h2a negotiate ${sub}: invalid --artifact JSON (${error.message})\n`);
|
|
646
|
-
return 1;
|
|
647
|
-
}
|
|
648
|
-
const existing = store.readNegotiationJournal(flags.id);
|
|
649
|
-
const previous = existing[existing.length - 1];
|
|
650
|
-
const chain = resolveCausationCorrelation(flags, previous);
|
|
651
|
-
const payload = {
|
|
652
|
-
id: flags["event-id"] ?? `evt-${Date.now().toString(36)}`,
|
|
653
|
-
type: sub === "offer" ? "propose" : "counter",
|
|
654
|
-
actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
|
|
655
|
-
body: { artifact },
|
|
656
|
-
createdAt: new Date().toISOString(),
|
|
657
|
-
...chain
|
|
658
|
-
};
|
|
659
|
-
try {
|
|
660
|
-
const entry = store.appendNegotiationEvent(flags.id, payload);
|
|
661
|
-
streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
662
|
-
return 0;
|
|
663
|
-
}
|
|
664
|
-
catch (error) {
|
|
665
|
-
const message = error.message;
|
|
666
|
-
streams.stderr.write(`h2a negotiate ${sub}: ${message}\n`);
|
|
667
|
-
return classifyStoreError(message);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
if (sub === "sign") {
|
|
671
|
-
if (!flags.id || !flags.instance || !flags.artifact || !flags["private-key"]) {
|
|
672
|
-
streams.stderr.write("h2a negotiate sign: --id <id> --instance <id> --artifact <json> --private-key <pem-path> required\n");
|
|
673
|
-
return 1;
|
|
674
|
-
}
|
|
675
|
-
const record = store.readNegotiation(flags.id);
|
|
676
|
-
if (!record) {
|
|
677
|
-
streams.stderr.write(`h2a negotiate sign: negotiation ${flags.id} not found\n`);
|
|
678
|
-
return 2;
|
|
679
|
-
}
|
|
680
|
-
let artifact;
|
|
681
|
-
try {
|
|
682
|
-
artifact = JSON.parse(flags.artifact);
|
|
683
|
-
}
|
|
684
|
-
catch (error) {
|
|
685
|
-
streams.stderr.write(`h2a negotiate sign: invalid --artifact JSON (${error.message})\n`);
|
|
686
|
-
return 1;
|
|
687
|
-
}
|
|
688
|
-
let privateKeyPem;
|
|
689
|
-
try {
|
|
690
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
691
|
-
}
|
|
692
|
-
catch (error) {
|
|
693
|
-
streams.stderr.write(`h2a negotiate sign: cannot read private key at ${flags["private-key"]} (${error.message})\n`);
|
|
694
|
-
// File/OS error — exit code 3 per DEC-034.
|
|
695
|
-
return 3;
|
|
696
|
-
}
|
|
697
|
-
const artifactHash = computeHash(artifact);
|
|
698
|
-
const signature = signCanonical({ artifactHash }, { by: flags.instance, privateKeyPem });
|
|
699
|
-
const existingForSign = store.readNegotiationJournal(flags.id);
|
|
700
|
-
const previousForSign = existingForSign[existingForSign.length - 1];
|
|
701
|
-
const signChain = resolveCausationCorrelation(flags, previousForSign);
|
|
702
|
-
const payload = {
|
|
703
|
-
id: flags["event-id"] ?? `evt-sign-${Date.now().toString(36)}`,
|
|
704
|
-
type: "event",
|
|
705
|
-
actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
|
|
706
|
-
body: { kind: "signature", artifactHash, signature },
|
|
707
|
-
createdAt: new Date().toISOString(),
|
|
708
|
-
...signChain
|
|
709
|
-
};
|
|
710
|
-
try {
|
|
711
|
-
const entry = store.appendNegotiationEvent(flags.id, payload);
|
|
712
|
-
streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
713
|
-
return 0;
|
|
714
|
-
}
|
|
715
|
-
catch (error) {
|
|
716
|
-
const message = error.message;
|
|
717
|
-
streams.stderr.write(`h2a negotiate sign: ${message}\n`);
|
|
718
|
-
return classifyStoreError(message);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (sub === "stabilize") {
|
|
722
|
-
if (!flags.id) {
|
|
723
|
-
streams.stderr.write("h2a negotiate stabilize: --id <id> required\n");
|
|
724
|
-
return 1;
|
|
725
|
-
}
|
|
726
|
-
try {
|
|
727
|
-
const result = store.stabilizeNegotiation(flags.id, { eventId: flags["event-id"] });
|
|
728
|
-
streams.stdout.write(`${JSON.stringify({
|
|
729
|
-
ok: true,
|
|
730
|
-
record: result.record,
|
|
731
|
-
artifactHash: result.artifactHash,
|
|
732
|
-
signers: result.signers,
|
|
733
|
-
artifactPath: result.artifactPath,
|
|
734
|
-
advisoryEvents: result.advisoryEvents.map((entry) => ({
|
|
735
|
-
id: entry.id,
|
|
736
|
-
sequence: entry.sequence
|
|
737
|
-
})),
|
|
738
|
-
finalEvent: { id: result.finalEvent.id, sequence: result.finalEvent.sequence }
|
|
739
|
-
}, null, 2)}\n`);
|
|
740
|
-
return 0;
|
|
741
|
-
}
|
|
742
|
-
catch (error) {
|
|
743
|
-
const message = error.message;
|
|
744
|
-
streams.stderr.write(`h2a negotiate stabilize: ${message}\n`);
|
|
745
|
-
return classifyStoreError(message);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
if (sub === "event") {
|
|
749
|
-
if (!flags.id || !flags.json) {
|
|
750
|
-
streams.stderr.write("h2a negotiate event: --id <id> and --json <payload-json> required\n");
|
|
751
|
-
return 1;
|
|
752
|
-
}
|
|
753
|
-
let payload;
|
|
754
|
-
try {
|
|
755
|
-
payload = JSON.parse(flags.json);
|
|
756
|
-
}
|
|
757
|
-
catch (error) {
|
|
758
|
-
streams.stderr.write(`h2a negotiate event: invalid JSON (${error.message})\n`);
|
|
759
|
-
return 1;
|
|
760
|
-
}
|
|
761
|
-
const existingForEvent = store.readNegotiationJournal(flags.id);
|
|
762
|
-
const previousForEvent = existingForEvent[existingForEvent.length - 1];
|
|
763
|
-
const eventChain = resolveCausationCorrelation(flags, previousForEvent);
|
|
764
|
-
// Explicit fields inside the user-supplied payload always take precedence
|
|
765
|
-
// over the CLI-resolved defaults: this preserves the existing "just append
|
|
766
|
-
// whatever JSON I gave you" contract while still adding the chain when the
|
|
767
|
-
// user did not opt in.
|
|
768
|
-
payload = { ...eventChain, ...payload };
|
|
769
|
-
try {
|
|
770
|
-
const entry = store.appendNegotiationEvent(flags.id, payload);
|
|
771
|
-
streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
772
|
-
return 0;
|
|
773
|
-
}
|
|
774
|
-
catch (error) {
|
|
775
|
-
const message = error.message;
|
|
776
|
-
streams.stderr.write(`h2a negotiate event: ${message}\n`);
|
|
777
|
-
return classifyStoreError(message);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
if (sub === "journal") {
|
|
781
|
-
if (!flags.id) {
|
|
782
|
-
streams.stderr.write("h2a negotiate journal: --id <id> required\n");
|
|
783
|
-
return 1;
|
|
784
|
-
}
|
|
785
|
-
try {
|
|
786
|
-
const entries = store.readNegotiationJournal(flags.id);
|
|
787
|
-
streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
|
|
788
|
-
return 0;
|
|
789
|
-
}
|
|
790
|
-
catch (error) {
|
|
791
|
-
const message = error.message;
|
|
792
|
-
streams.stderr.write(`h2a negotiate journal: ${message}\n`);
|
|
793
|
-
return classifyStoreError(message);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
streams.stderr.write(`Unknown negotiate subcommand: ${sub ?? "<none>"}\n`);
|
|
797
|
-
streams.stderr.write("Use one of: open, status, event, offer, counter, sign, stabilize, journal\n");
|
|
798
|
-
return 1;
|
|
799
|
-
}
|
|
800
|
-
function splitCsvFlag(value) {
|
|
801
|
-
if (!value)
|
|
802
|
-
return [];
|
|
803
|
-
return value
|
|
804
|
-
.split(",")
|
|
805
|
-
.map((item) => item.trim())
|
|
806
|
-
.filter((item) => item.length > 0);
|
|
807
|
-
}
|
|
808
|
-
const SHA256_HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
809
|
-
function findRegisteredInstance(store, instance) {
|
|
810
|
-
return store.findInstance(instance) ?? store.listInstances().find((entry) => entry.instance === instance);
|
|
811
|
-
}
|
|
812
|
-
function parseRoleFlag(value) {
|
|
813
|
-
if (!value)
|
|
814
|
-
return undefined;
|
|
815
|
-
return H2A_ROLES.includes(value) ? value : undefined;
|
|
816
|
-
}
|
|
817
|
-
function resolveDossierHash(dossier) {
|
|
818
|
-
if (SHA256_HASH_PATTERN.test(dossier)) {
|
|
819
|
-
return dossier;
|
|
820
|
-
}
|
|
821
|
-
const content = readFileSync(dossier, "utf8");
|
|
822
|
-
try {
|
|
823
|
-
return computeHash(JSON.parse(content));
|
|
824
|
-
}
|
|
825
|
-
catch {
|
|
826
|
-
return computeHash(content);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
function comprehensionActorFor(registration, flags) {
|
|
830
|
-
const role = parseRoleFlag(flags.role) ?? registration.roles[0];
|
|
831
|
-
if (!role) {
|
|
832
|
-
throw new Error(`${registration.instance} has no role and --role was not provided`);
|
|
833
|
-
}
|
|
834
|
-
if (flags.role && !parseRoleFlag(flags.role)) {
|
|
835
|
-
throw new Error(`unknown role ${flags.role}`);
|
|
836
|
-
}
|
|
837
|
-
const scope = flags.scope ?? registration.scopes[0];
|
|
838
|
-
if (!scope) {
|
|
839
|
-
throw new Error(`${registration.instance} has no scope and --scope was not provided`);
|
|
840
|
-
}
|
|
841
|
-
return { instance: registration.instance, role, scope };
|
|
842
|
-
}
|
|
843
|
-
function cmdDeclareInteret(flags, streams) {
|
|
844
|
-
if (!flags.negotiation || !flags.instance || !flags.interets) {
|
|
845
|
-
streams.stderr.write("h2a declare-interest: --negotiation <id> --instance <id> --interets <a,b> required\n");
|
|
846
|
-
return 1;
|
|
847
|
-
}
|
|
848
|
-
const interets = splitCsvFlag(flags.interets);
|
|
849
|
-
if (interets.length === 0) {
|
|
850
|
-
streams.stderr.write("h2a declare-interest: --interets must contain at least one value\n");
|
|
851
|
-
return 1;
|
|
852
|
-
}
|
|
853
|
-
const bindings = splitCsvFlag(flags.bindings);
|
|
854
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
855
|
-
const root = resolveRoot(flags, cwd);
|
|
856
|
-
const store = createLocalStore({ root });
|
|
857
|
-
const declaration = {
|
|
858
|
-
kind: H2A_DECLARATION_INTERET_BODY_KIND,
|
|
859
|
-
subject: flags.instance,
|
|
860
|
-
interets,
|
|
861
|
-
at: flags.at ?? new Date().toISOString()
|
|
862
|
-
};
|
|
863
|
-
if (bindings.length > 0)
|
|
864
|
-
declaration.bindings = bindings;
|
|
865
|
-
if (flags["masque-impact-collectif"] !== undefined) {
|
|
866
|
-
declaration.masqueImpactCollectif = true;
|
|
867
|
-
}
|
|
868
|
-
try {
|
|
869
|
-
const entry = store.declareConflitInteret(flags.negotiation, declaration, {
|
|
870
|
-
eventId: flags["event-id"]
|
|
871
|
-
});
|
|
872
|
-
streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
873
|
-
return 0;
|
|
874
|
-
}
|
|
875
|
-
catch (error) {
|
|
876
|
-
const message = error.message;
|
|
877
|
-
streams.stderr.write(`h2a declare-interest: ${message}\n`);
|
|
878
|
-
return classifyStoreError(message);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
function cmdConflictPosture(flags, streams) {
|
|
882
|
-
if (!flags.negotiation) {
|
|
883
|
-
streams.stderr.write("h2a conflict-posture: --negotiation <id> required\n");
|
|
884
|
-
return 1;
|
|
885
|
-
}
|
|
886
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
887
|
-
const root = resolveRoot(flags, cwd);
|
|
888
|
-
const store = createLocalStore({ root });
|
|
889
|
-
try {
|
|
890
|
-
streams.stdout.write(`${JSON.stringify({
|
|
891
|
-
negotiationId: flags.negotiation,
|
|
892
|
-
postures: store.derivePosturesConflit(flags.negotiation)
|
|
893
|
-
}, null, 2)}\n`);
|
|
894
|
-
return 0;
|
|
895
|
-
}
|
|
896
|
-
catch (error) {
|
|
897
|
-
const message = error.message;
|
|
898
|
-
streams.stderr.write(`h2a conflict-posture: ${message}\n`);
|
|
899
|
-
return classifyStoreError(message);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
function cmdDossier(flags, streams) {
|
|
903
|
-
if (!flags.negotiation) {
|
|
904
|
-
streams.stderr.write("h2a dossier: --negotiation <id> required\n");
|
|
905
|
-
return 1;
|
|
906
|
-
}
|
|
907
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
908
|
-
const root = resolveRoot(flags, cwd);
|
|
909
|
-
const store = createLocalStore({ root });
|
|
910
|
-
try {
|
|
911
|
-
const result = store.deriveDecisionDossier(flags.negotiation, {
|
|
912
|
-
presenter: flags.presenter,
|
|
913
|
-
advisoryGate: flags["advisory-gate"] === "true",
|
|
914
|
-
eventId: flags["event-id"]
|
|
915
|
-
});
|
|
916
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
917
|
-
return 0;
|
|
918
|
-
}
|
|
919
|
-
catch (error) {
|
|
920
|
-
const message = error.message;
|
|
921
|
-
streams.stderr.write(`h2a dossier: ${message}\n`);
|
|
922
|
-
return classifyStoreError(message);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
function cmdConfiance(flags, streams) {
|
|
926
|
-
if (!flags.negotiation) {
|
|
927
|
-
streams.stderr.write("h2a confiance: --negotiation <id> required\n");
|
|
928
|
-
return 1;
|
|
929
|
-
}
|
|
930
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
931
|
-
const root = resolveRoot(flags, cwd);
|
|
932
|
-
const store = createLocalStore({ root });
|
|
933
|
-
try {
|
|
934
|
-
const posture = store.derivePostureConfiance(flags.negotiation);
|
|
935
|
-
streams.stdout.write(`${JSON.stringify({ negotiationId: flags.negotiation, posture }, null, 2)}\n`);
|
|
936
|
-
return 0;
|
|
937
|
-
}
|
|
938
|
-
catch (error) {
|
|
939
|
-
const message = error.message;
|
|
940
|
-
streams.stderr.write(`h2a confiance: ${message}\n`);
|
|
941
|
-
return classifyStoreError(message);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
function cmdAttestComprehension(flags, streams) {
|
|
945
|
-
if (!flags.instance || !flags.dossier || !flags["private-key"]) {
|
|
946
|
-
streams.stderr.write("h2a attest-comprehension: --instance <id> --dossier <file|sha256:...> --private-key <pem-path> required\n");
|
|
947
|
-
return 1;
|
|
948
|
-
}
|
|
949
|
-
if (flags.negotiation && flags.to) {
|
|
950
|
-
streams.stderr.write("h2a attest-comprehension: use either --negotiation or --to, not both\n");
|
|
951
|
-
return 1;
|
|
952
|
-
}
|
|
953
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
954
|
-
const root = resolveRoot(flags, cwd);
|
|
955
|
-
const store = createLocalStore({ root });
|
|
956
|
-
const registration = findRegisteredInstance(store, flags.instance);
|
|
957
|
-
if (!registration) {
|
|
958
|
-
streams.stderr.write(`h2a attest-comprehension: instance ${flags.instance} not registered\n`);
|
|
959
|
-
return 2;
|
|
960
|
-
}
|
|
961
|
-
let actor;
|
|
962
|
-
try {
|
|
963
|
-
actor = comprehensionActorFor(registration, flags);
|
|
964
|
-
}
|
|
965
|
-
catch (error) {
|
|
966
|
-
streams.stderr.write(`h2a attest-comprehension: ${error.message}\n`);
|
|
967
|
-
return 1;
|
|
968
|
-
}
|
|
969
|
-
if (!canAttestComprehension(actor.role, registration.capabilities)) {
|
|
970
|
-
streams.stderr.write(`h2a attest-comprehension: role ${actor.role} for ${flags.instance} cannot attest comprehension; AGENTS require ${H2A_ATTESTER_COMPREHENSION_RIGHT}\n`);
|
|
971
|
-
return 2;
|
|
972
|
-
}
|
|
973
|
-
let privateKeyPem;
|
|
974
|
-
try {
|
|
975
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
976
|
-
}
|
|
977
|
-
catch (error) {
|
|
978
|
-
streams.stderr.write(`h2a attest-comprehension: cannot read private key at ${flags["private-key"]} (${error.message})\n`);
|
|
979
|
-
return 3;
|
|
980
|
-
}
|
|
981
|
-
let dossierHash;
|
|
982
|
-
try {
|
|
983
|
-
dossierHash = resolveDossierHash(flags.dossier);
|
|
984
|
-
}
|
|
985
|
-
catch (error) {
|
|
986
|
-
streams.stderr.write(`h2a attest-comprehension: cannot read dossier at ${flags.dossier} (${error.message})\n`);
|
|
987
|
-
return 3;
|
|
988
|
-
}
|
|
989
|
-
let body;
|
|
990
|
-
try {
|
|
991
|
-
body = buildComprehensionAttestation({
|
|
992
|
-
subject: flags.instance,
|
|
993
|
-
dossierHash,
|
|
994
|
-
...(flags.at ? { at: flags.at } : {})
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
catch (error) {
|
|
998
|
-
streams.stderr.write(`h2a attest-comprehension: ${error.message}\n`);
|
|
999
|
-
return 1;
|
|
1000
|
-
}
|
|
1001
|
-
const signature = signCanonical(body, { by: flags.instance, privateKeyPem });
|
|
1002
|
-
if (flags.negotiation) {
|
|
1003
|
-
const record = store.readNegotiation(flags.negotiation);
|
|
1004
|
-
if (!record) {
|
|
1005
|
-
streams.stderr.write(`h2a attest-comprehension: negotiation ${flags.negotiation} not found\n`);
|
|
1006
|
-
return 2;
|
|
1007
|
-
}
|
|
1008
|
-
const existing = store.readNegotiationJournal(flags.negotiation);
|
|
1009
|
-
const previous = existing[existing.length - 1];
|
|
1010
|
-
const chain = resolveCausationCorrelation(flags, previous);
|
|
1011
|
-
const payload = {
|
|
1012
|
-
id: flags["event-id"] ?? `evt-comprehension-${Date.now().toString(36)}`,
|
|
1013
|
-
type: "event",
|
|
1014
|
-
actor: { ...actor, scope: flags.scope ?? record.scope },
|
|
1015
|
-
body,
|
|
1016
|
-
signatures: [signature],
|
|
1017
|
-
createdAt: body.at,
|
|
1018
|
-
...chain
|
|
1019
|
-
};
|
|
1020
|
-
try {
|
|
1021
|
-
const entry = store.appendNegotiationEvent(flags.negotiation, payload);
|
|
1022
|
-
streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
|
|
1023
|
-
return 0;
|
|
1024
|
-
}
|
|
1025
|
-
catch (error) {
|
|
1026
|
-
const message = error.message;
|
|
1027
|
-
streams.stderr.write(`h2a attest-comprehension: ${message}\n`);
|
|
1028
|
-
return classifyStoreError(message);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
const envelope = createEnvelope({
|
|
1032
|
-
id: flags["event-id"] ?? `env-comprehension-${Date.now().toString(36)}`,
|
|
1033
|
-
type: "event",
|
|
1034
|
-
actor,
|
|
1035
|
-
...(flags.to ? { target: { instance: flags.to } } : {}),
|
|
1036
|
-
body,
|
|
1037
|
-
signatures: [signature],
|
|
1038
|
-
createdAt: body.at
|
|
1039
|
-
});
|
|
1040
|
-
if (flags.to) {
|
|
1041
|
-
try {
|
|
1042
|
-
assertHostQualifiedAddress(flags.to, "recipient");
|
|
1043
|
-
}
|
|
1044
|
-
catch (error) {
|
|
1045
|
-
streams.stderr.write(`\n${error.message}\n`);
|
|
1046
|
-
return 1;
|
|
1047
|
-
}
|
|
1048
|
-
try {
|
|
1049
|
-
store.putInboxMessage(flags.to, envelope);
|
|
1050
|
-
}
|
|
1051
|
-
catch (error) {
|
|
1052
|
-
const message = error.message;
|
|
1053
|
-
streams.stderr.write(`h2a attest-comprehension: ${message}\n`);
|
|
1054
|
-
return classifyStoreError(message);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
streams.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
1058
|
-
return 0;
|
|
1059
|
-
}
|
|
1060
|
-
function cmdComprehension(argv, streams) {
|
|
1061
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1062
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1063
|
-
const root = resolveRoot(flags, cwd);
|
|
1064
|
-
if (sub === "list") {
|
|
1065
|
-
if (!flags.negotiation) {
|
|
1066
|
-
streams.stderr.write("h2a comprehension list: --negotiation <id> required\n");
|
|
1067
|
-
return 1;
|
|
1068
|
-
}
|
|
1069
|
-
const store = createLocalStore({ root });
|
|
1070
|
-
if (!store.readNegotiation(flags.negotiation)) {
|
|
1071
|
-
streams.stderr.write(`h2a comprehension list: negotiation ${flags.negotiation} not found\n`);
|
|
1072
|
-
return 2;
|
|
1073
|
-
}
|
|
1074
|
-
try {
|
|
1075
|
-
const entries = store.readNegotiationJournal(flags.negotiation).filter((entry) => isComprehensionAttestation(entry.body));
|
|
1076
|
-
streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
|
|
1077
|
-
return 0;
|
|
1078
|
-
}
|
|
1079
|
-
catch (error) {
|
|
1080
|
-
const message = error.message;
|
|
1081
|
-
streams.stderr.write(`h2a comprehension list: ${message}\n`);
|
|
1082
|
-
return classifyStoreError(message);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
if (sub === "verify") {
|
|
1086
|
-
if (!flags.json || !flags["public-key"]) {
|
|
1087
|
-
streams.stderr.write("h2a comprehension verify: --json <event-or-envelope-json> --public-key <pem-file> required\n");
|
|
1088
|
-
return 1;
|
|
1089
|
-
}
|
|
1090
|
-
let envelopeOrEntry;
|
|
1091
|
-
try {
|
|
1092
|
-
envelopeOrEntry = JSON.parse(flags.json);
|
|
1093
|
-
}
|
|
1094
|
-
catch (error) {
|
|
1095
|
-
streams.stderr.write(`h2a comprehension verify: invalid JSON (${error.message})\n`);
|
|
1096
|
-
return 1;
|
|
1097
|
-
}
|
|
1098
|
-
let publicKeyPem;
|
|
1099
|
-
try {
|
|
1100
|
-
publicKeyPem = readFileSync(flags["public-key"], "utf8");
|
|
1101
|
-
}
|
|
1102
|
-
catch (error) {
|
|
1103
|
-
streams.stderr.write(`h2a comprehension verify: cannot read public key at ${flags["public-key"]} (${error.message})\n`);
|
|
1104
|
-
return 3;
|
|
1105
|
-
}
|
|
1106
|
-
const ok = verifyComprehensionAttestation(envelopeOrEntry, [publicKeyPem]);
|
|
1107
|
-
const body = isComprehensionAttestation(envelopeOrEntry?.body)
|
|
1108
|
-
? envelopeOrEntry.body
|
|
1109
|
-
: undefined;
|
|
1110
|
-
streams.stdout.write(`${JSON.stringify({
|
|
1111
|
-
ok,
|
|
1112
|
-
...(body
|
|
1113
|
-
? {
|
|
1114
|
-
kind: H2A_COMPREHENSION_ATTESTATION_BODY_KIND,
|
|
1115
|
-
subject: body.subject,
|
|
1116
|
-
dossierHash: body.dossierHash
|
|
1117
|
-
}
|
|
1118
|
-
: {})
|
|
1119
|
-
}, null, 2)}\n`);
|
|
1120
|
-
return ok ? 0 : 2;
|
|
1121
|
-
}
|
|
1122
|
-
streams.stderr.write(`Unknown comprehension subcommand: ${sub ?? "<none>"}\n`);
|
|
1123
|
-
streams.stderr.write("Use one of: list, verify\n");
|
|
1124
|
-
return 1;
|
|
1125
|
-
}
|
|
1126
|
-
/**
|
|
1127
|
-
* `h2a mcp-serve` binds directly to the real process std streams because it
|
|
1128
|
-
* is a long-running JSON-RPC loop. The test-friendly `streams` interface
|
|
1129
|
-
* (write-only) cannot express a readable stdin; tests cover `runMcpStdio`
|
|
1130
|
-
* with `PassThrough` streams instead of going through this verb.
|
|
1131
|
-
*/
|
|
1132
|
-
/**
|
|
1133
|
-
* DEC-105 (EVO-6): resolve the auto-open session config from `mcp-serve` flags.
|
|
1134
|
-
* `--auto-open` enables it; the instance is `--instance <id>` or, if absent,
|
|
1135
|
-
* `<host>:<cwd-leaf>` (host = `--host` or "agent"). Pure + total so it can be
|
|
1136
|
-
* unit-tested without spawning the server. Returns undefined when not enabled.
|
|
1137
|
-
*/
|
|
1138
|
-
export function resolveAutoOpen(flags, cwd) {
|
|
1139
|
-
if (flags["auto-open"] === undefined)
|
|
1140
|
-
return undefined;
|
|
1141
|
-
const host = flags.host;
|
|
1142
|
-
const identity = resolveLiveIdentity({
|
|
1143
|
-
root: resolveRoot(flags, cwd),
|
|
1144
|
-
host: host ?? "agent",
|
|
1145
|
-
cwd: cwd(),
|
|
1146
|
-
...(flags.instance !== undefined ? { explicitInstance: flags.instance } : {}),
|
|
1147
|
-
...(flags.name !== undefined ? { name: flags.name } : {}),
|
|
1148
|
-
...(flags.scope !== undefined ? { scopes: [flags.scope] } : {})
|
|
1149
|
-
});
|
|
1150
|
-
return {
|
|
1151
|
-
instance: identity.instance,
|
|
1152
|
-
...(host ? { host } : {}),
|
|
1153
|
-
...(identity.workspace !== undefined ? { workspace: identity.workspace } : {}),
|
|
1154
|
-
...(identity.name !== undefined ? { name: identity.name } : {}),
|
|
1155
|
-
...(flags.scope ? { scopes: [flags.scope] } : {}),
|
|
1156
|
-
...(identity.migrationNotice !== undefined
|
|
1157
|
-
? { migrationNotice: identity.migrationNotice }
|
|
1158
|
-
: {}),
|
|
1159
|
-
...(identity.privateKeyPath !== undefined
|
|
1160
|
-
? { privateKeyPath: identity.privateKeyPath }
|
|
1161
|
-
: {})
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
|
-
/**
|
|
1165
|
-
* `h2a upgrade [--check]` (DEC-107, EVO-8 level 1): explicit self-upgrade.
|
|
1166
|
-
* `--check` reports current vs latest (no install); bare runs the global
|
|
1167
|
-
* install of `@latest`. Sync (spawnSync). `runtime` is injectable for tests.
|
|
1168
|
-
*/
|
|
1169
|
-
export function cmdUpgrade(flags, streams, runtime) {
|
|
1170
|
-
const current = currentCliVersion();
|
|
1171
|
-
if (flags.check !== undefined) {
|
|
1172
|
-
const result = checkUpgrade(current, { ...(runtime ? { runtime } : {}), force: true });
|
|
1173
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1174
|
-
return 0;
|
|
1175
|
-
}
|
|
1176
|
-
const result = checkUpgrade(current, { ...(runtime ? { runtime } : {}), force: true });
|
|
1177
|
-
if (!result.upgradeAvailable) {
|
|
1178
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, current, latest: result.latest ?? current, upgraded: false, reason: "already current or registry unreachable" }, null, 2)}\n`);
|
|
1179
|
-
return 0;
|
|
1180
|
-
}
|
|
1181
|
-
const ok = performUpgrade(runtime);
|
|
1182
|
-
streams.stdout.write(`${JSON.stringify({ ok, current, latest: result.latest, upgraded: ok, package: "@sentropic/h2a-cli" }, null, 2)}\n`);
|
|
1183
|
-
if (!ok) {
|
|
1184
|
-
streams.stderr.write("h2a upgrade: global install failed — run `npm i -g @sentropic/h2a-cli@latest` manually (or check your install method).\n");
|
|
1185
|
-
return 1;
|
|
1186
|
-
}
|
|
1187
|
-
return 0;
|
|
1188
|
-
}
|
|
1189
|
-
/**
|
|
1190
|
-
* `h2a presence-reap` — false-live janitor. Deletes presence files whose owning
|
|
1191
|
-
* process is provably dead: the stale "live" presence a host (Claude Code /
|
|
1192
|
-
* Codex) leaves when it drops the MCP stdio connection WITHOUT killing the
|
|
1193
|
-
* child, so the lingering process's blind heartbeat kept the presence fresh and
|
|
1194
|
-
* peers kept routing to an unreachable agent. Safe by construction — a dead
|
|
1195
|
-
* process owns no live session — and it never mints identity, so it is safe to
|
|
1196
|
-
* call from a SessionStart hook. Default reaps EVERY dead-pid presence
|
|
1197
|
-
* (host-wide; assumes a single-machine bus); `--instance <id>` scopes it to one
|
|
1198
|
-
* instance (cross-machine-safe). Prints the reaped set as JSON for audit.
|
|
1199
|
-
*/
|
|
1200
|
-
export function cmdPresenceReap(flags, streams) {
|
|
1201
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1202
|
-
const root = resolveRoot(flags, cwd);
|
|
1203
|
-
const instance = flags.instance;
|
|
1204
|
-
const reaped = instance
|
|
1205
|
-
? reapDeadInstancePresence(root, instance, "").map((sessionId) => ({
|
|
1206
|
-
sessionId,
|
|
1207
|
-
instance
|
|
1208
|
-
}))
|
|
1209
|
-
: reapAllDeadPresence(root);
|
|
1210
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, root, scope: instance ?? "all-dead-pid", count: reaped.length, reaped }, null, 2)}\n`);
|
|
1211
|
-
return 0;
|
|
1212
|
-
}
|
|
1213
|
-
export async function runMcpServe(flags, io = {
|
|
1214
|
-
stdin: process.stdin,
|
|
1215
|
-
stdout: process.stdout,
|
|
1216
|
-
stderr: process.stderr
|
|
1217
|
-
}) {
|
|
1218
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
1219
|
-
warnIfCwdRootFallback(flags, cwd, { stderr: io.stderr, stdout: io.stdout, cwd });
|
|
1220
|
-
const root = resolveRoot(flags, cwd);
|
|
1221
|
-
const autoOpen = resolveAutoOpen(flags, cwd);
|
|
1222
|
-
// DEC-107/108 (EVO-8 levels 2/3): version handling at boot. **Opt-in** — no
|
|
1223
|
-
// network on a default boot. `--auto-upgrade` self-installs @latest then
|
|
1224
|
-
// re-execs in place via process.execve (same PID/stdio, host stays connected;
|
|
1225
|
-
// falls back to next-launch where execve is unavailable or `--no-restart`).
|
|
1226
|
-
// `--upgrade-check` (notice only) prints a cached, bounded availability hint.
|
|
1227
|
-
// The H2A_REEXEC_GUARD_ENV flag (set across the re-exec) prevents a re-loop.
|
|
1228
|
-
const wantAutoUpgrade = flags["auto-upgrade"] !== undefined;
|
|
1229
|
-
const wantCheckOnly = flags["upgrade-check"] !== undefined;
|
|
1230
|
-
if ((wantAutoUpgrade || wantCheckOnly) &&
|
|
1231
|
-
process.env[H2A_REEXEC_GUARD_ENV] === undefined) {
|
|
1232
|
-
try {
|
|
1233
|
-
const current = currentCliVersion();
|
|
1234
|
-
// `--auto-upgrade` is opt-in to stay current → short (1h) cache so a
|
|
1235
|
-
// same-day release isn't masked by the 24h notice cache; the passive
|
|
1236
|
-
// `--upgrade-check` notice keeps the 24h throttle.
|
|
1237
|
-
const ttlMs = wantAutoUpgrade ? H2A_AUTO_UPGRADE_CHECK_TTL_MS : H2A_UPGRADE_CHECK_TTL_MS;
|
|
1238
|
-
const result = checkUpgrade(current, { cachePath: upgradeCachePath(root), ttlMs });
|
|
1239
|
-
if (result.upgradeAvailable) {
|
|
1240
|
-
if (wantAutoUpgrade) {
|
|
1241
|
-
const ok = performUpgrade();
|
|
1242
|
-
if (ok && flags["no-restart"] === undefined && canReexec()) {
|
|
1243
|
-
io.stderr.write(`h2a mcp-serve: auto-upgraded ${current} → ${result.latest}; restarting into the new version…\n`);
|
|
1244
|
-
reexecSelf(); // on success the image is replaced (never returns)
|
|
1245
|
-
}
|
|
1246
|
-
io.stderr.write(ok
|
|
1247
|
-
? `h2a mcp-serve: auto-upgraded ${current} → ${result.latest} (applies on next launch)\n`
|
|
1248
|
-
: `h2a mcp-serve: auto-upgrade to ${result.latest} failed; staying on ${current}\n`);
|
|
1249
|
-
}
|
|
1250
|
-
else {
|
|
1251
|
-
io.stderr.write(`h2a mcp-serve: h2a ${result.latest} available (current ${current}) — run \`h2a upgrade\`\n`);
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
catch {
|
|
1256
|
-
// best-effort: a check/upgrade failure must never block serving.
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
// EVO-1 inbox wake (bug #3): --wake <driver-kind> injects a signed wake on
|
|
1260
|
-
// inbox arrival (requires --auto-open + a resolvable private key).
|
|
1261
|
-
const WAKE_KINDS = ["logging", "native", "local-tmux", "headless", "auto"];
|
|
1262
|
-
let wake;
|
|
1263
|
-
if (flags.wake !== undefined && !WAKE_KINDS.includes(flags.wake)) {
|
|
1264
|
-
io.stderr.write("h2a mcp-serve: --wake must be one of logging|native|local-tmux|headless|auto; ignored\n");
|
|
1265
|
-
}
|
|
1266
|
-
else if (flags.wake === "headless") {
|
|
1267
|
-
// A self-wake must NEVER spawn a new agent. headless does exactly that.
|
|
1268
|
-
io.stderr.write("h2a mcp-serve: --wake headless is unsafe (it would spawn a NEW agent on inbox arrival, not wake this one); use local-tmux. ignored\n");
|
|
1269
|
-
}
|
|
1270
|
-
else if (flags.wake !== undefined && autoOpen?.privateKeyPath) {
|
|
1271
|
-
try {
|
|
1272
|
-
const privateKeyPem = readFileSync(autoOpen.privateKeyPath, "utf8");
|
|
1273
|
-
const log = (line) => io.stderr.write(`${line}\n`);
|
|
1274
|
-
// `auto` for a self-wake is native→local-tmux ONLY (no headless leg — its
|
|
1275
|
-
// fallback spawns a new agent, wrong for waking yourself).
|
|
1276
|
-
const driver = flags.wake === "auto"
|
|
1277
|
-
? chainDriver(nativeBackchannelDriver(), localTmuxDriver({ log }))
|
|
1278
|
-
: buildDriveDriver(flags.wake, log);
|
|
1279
|
-
wake = { driver, privateKeyPem };
|
|
1280
|
-
}
|
|
1281
|
-
catch (err) {
|
|
1282
|
-
io.stderr.write(`h2a mcp-serve: --wake disabled (cannot read key): ${err.message}\n`);
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
else if (flags.wake !== undefined && !autoOpen) {
|
|
1286
|
-
io.stderr.write("h2a mcp-serve: --wake requires --auto-open; ignored\n");
|
|
1287
|
-
}
|
|
1288
|
-
try {
|
|
1289
|
-
if (autoOpen?.migrationNotice) {
|
|
1290
|
-
io.stderr.write(`h2a mcp-serve: ${autoOpen.migrationNotice}\n`);
|
|
1291
|
-
}
|
|
1292
|
-
await runMcpStdio({
|
|
1293
|
-
root,
|
|
1294
|
-
stdin: io.stdin,
|
|
1295
|
-
stdout: io.stdout,
|
|
1296
|
-
stderr: io.stderr,
|
|
1297
|
-
...(autoOpen ? { autoOpen } : {}),
|
|
1298
|
-
...(wake ? { wake } : {}),
|
|
1299
|
-
...(io.signal ? { signal: io.signal } : {})
|
|
1300
|
-
});
|
|
1301
|
-
return 0;
|
|
1302
|
-
}
|
|
1303
|
-
catch (err) {
|
|
1304
|
-
io.stderr.write(`h2a mcp-serve: ${err.message}\n`);
|
|
1305
|
-
return 1;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
/**
|
|
1309
|
-
* `h2a remote serve` (DEC-077): long-running HTTP listener that authenticates
|
|
1310
|
-
* POSTed envelopes against the store registry and delivers them to local
|
|
1311
|
-
* inboxes. Async + blocking, so it is dispatched from bin.ts (like mcp-serve),
|
|
1312
|
-
* not the synchronous runCli. Binds 127.0.0.1 by default — never expose to all
|
|
1313
|
-
* interfaces implicitly; pass `--host 0.0.0.0` to opt in.
|
|
1314
|
-
*/
|
|
1315
|
-
export async function runRemoteServe(flags, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
1316
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
1317
|
-
const root = resolveRoot(flags, cwd);
|
|
1318
|
-
const host = flags.host ?? "127.0.0.1";
|
|
1319
|
-
const port = flags.port ? Number.parseInt(flags.port, 10) : 8787;
|
|
1320
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1321
|
-
io.stderr.write(`h2a remote serve: invalid --port "${flags.port}"\n`);
|
|
1322
|
-
return 1;
|
|
1323
|
-
}
|
|
1324
|
-
let store;
|
|
1325
|
-
try {
|
|
1326
|
-
store = createLocalStore({ root });
|
|
1327
|
-
}
|
|
1328
|
-
catch (error) {
|
|
1329
|
-
io.stderr.write(`h2a remote serve: ${error.message}\n`);
|
|
1330
|
-
return 1;
|
|
1331
|
-
}
|
|
1332
|
-
const server = remoteServerForStore(store, { path: flags.path });
|
|
1333
|
-
return await new Promise((resolve) => {
|
|
1334
|
-
server.on("error", (err) => {
|
|
1335
|
-
io.stderr.write(`h2a remote serve: ${err.message}\n`);
|
|
1336
|
-
resolve(1);
|
|
1337
|
-
});
|
|
1338
|
-
server.listen(port, host, () => {
|
|
1339
|
-
const addr = server.address();
|
|
1340
|
-
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1341
|
-
io.stdout.write(`h2a remote serve: listening on http://${host}:${boundPort}${flags.path ?? "/h2a/envelopes"} (root ${root})\n`);
|
|
1342
|
-
io.onListening?.(server);
|
|
1343
|
-
});
|
|
1344
|
-
server.on("close", () => resolve(0));
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1347
|
-
function commandDriveInjector(command, io) {
|
|
1348
|
-
return (payload, signedLine) => {
|
|
1349
|
-
const result = spawnSync(command, {
|
|
1350
|
-
shell: true,
|
|
1351
|
-
input: `${signedLine}\n`,
|
|
1352
|
-
encoding: "utf8",
|
|
1353
|
-
env: {
|
|
1354
|
-
...process.env,
|
|
1355
|
-
H2A_DRIVE_LINE: signedLine,
|
|
1356
|
-
H2A_DRIVE_FROM: payload.from,
|
|
1357
|
-
H2A_DRIVE_TO: payload.to,
|
|
1358
|
-
H2A_DRIVE_INSTRUCTION: payload.instruction
|
|
1359
|
-
},
|
|
1360
|
-
timeout: 30_000
|
|
1361
|
-
});
|
|
1362
|
-
if (result.error) {
|
|
1363
|
-
io.stderr.write(`h2a drive serve: injector command failed: ${result.error.message}\n`);
|
|
1364
|
-
return false;
|
|
1365
|
-
}
|
|
1366
|
-
if (result.status !== 0) {
|
|
1367
|
-
io.stderr.write(`h2a drive serve: injector command exited ${result.status ?? "unknown"}\n`);
|
|
1368
|
-
return false;
|
|
1369
|
-
}
|
|
1370
|
-
return true;
|
|
1371
|
-
};
|
|
1372
|
-
}
|
|
1373
|
-
/**
|
|
1374
|
-
* `h2a drive serve` (EVO-1 E1d): long-running HTTP endpoint for remote/sidecar
|
|
1375
|
-
* injection. It verifies signature, target, authority, freshness, and replay
|
|
1376
|
-
* before crossing the remote trust boundary into the caller-provided injector.
|
|
1377
|
-
*/
|
|
1378
|
-
export async function runDriveServe(flags, io = { stdout: process.stdout, stderr: process.stderr }, options = {}) {
|
|
1379
|
-
if (!flags.to) {
|
|
1380
|
-
io.stderr.write("h2a drive serve: --to is required\n");
|
|
1381
|
-
return 1;
|
|
1382
|
-
}
|
|
1383
|
-
const inject = options.inject ?? (flags["inject-command"] ? commandDriveInjector(flags["inject-command"], io) : undefined);
|
|
1384
|
-
if (!inject) {
|
|
1385
|
-
io.stderr.write("h2a drive serve: --inject-command is required\n");
|
|
1386
|
-
return 1;
|
|
1387
|
-
}
|
|
1388
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
1389
|
-
const root = resolveRoot(flags, cwd);
|
|
1390
|
-
const host = flags.host ?? "127.0.0.1";
|
|
1391
|
-
const port = flags.port ? Number.parseInt(flags.port, 10) : 8788;
|
|
1392
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1393
|
-
io.stderr.write(`h2a drive serve: invalid --port "${flags.port}"\n`);
|
|
1394
|
-
return 1;
|
|
1395
|
-
}
|
|
1396
|
-
let store;
|
|
1397
|
-
try {
|
|
1398
|
-
store = createLocalStore({ root });
|
|
1399
|
-
}
|
|
1400
|
-
catch (error) {
|
|
1401
|
-
io.stderr.write(`h2a drive serve: ${error.message}\n`);
|
|
1402
|
-
return 1;
|
|
1403
|
-
}
|
|
1404
|
-
const server = remoteDriveServerForStore(store, {
|
|
1405
|
-
to: flags.to,
|
|
1406
|
-
path: flags.path,
|
|
1407
|
-
inject
|
|
1408
|
-
});
|
|
1409
|
-
return await new Promise((resolve) => {
|
|
1410
|
-
server.on("error", (err) => {
|
|
1411
|
-
io.stderr.write(`h2a drive serve: ${err.message}\n`);
|
|
1412
|
-
resolve(1);
|
|
1413
|
-
});
|
|
1414
|
-
server.listen(port, host, () => {
|
|
1415
|
-
const addr = server.address();
|
|
1416
|
-
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1417
|
-
io.stdout.write(`h2a drive serve: listening on http://${host}:${boundPort}${flags.path ?? "/h2a/drive"} (to ${flags.to}, root ${root})\n`);
|
|
1418
|
-
io.onListening?.(server);
|
|
1419
|
-
});
|
|
1420
|
-
server.on("close", () => resolve(0));
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
/**
|
|
1424
|
-
* `h2a remote send` (DEC-077): sign an envelope and POST it to a remote h2a
|
|
1425
|
-
* endpoint. Async (network), so dispatched from bin.ts. Exit 0 on a 2xx,
|
|
1426
|
-
* 1 otherwise; prints `{ status, body }`.
|
|
1427
|
-
*/
|
|
1428
|
-
export async function runRemoteSend(flags, streams = { stdout: process.stdout, stderr: process.stderr }) {
|
|
1429
|
-
if (!flags.url || !flags.instance || !flags["private-key"] || !flags.json) {
|
|
1430
|
-
streams.stderr.write("h2a remote send: --url, --instance, --private-key and --json are required\n");
|
|
1431
|
-
return 1;
|
|
1432
|
-
}
|
|
1433
|
-
let envelope;
|
|
1434
|
-
try {
|
|
1435
|
-
envelope = JSON.parse(flags.json);
|
|
1436
|
-
}
|
|
1437
|
-
catch (error) {
|
|
1438
|
-
streams.stderr.write(`h2a remote send: invalid --json (${error.message})\n`);
|
|
1439
|
-
return 1;
|
|
1440
|
-
}
|
|
1441
|
-
let privateKeyPem;
|
|
1442
|
-
try {
|
|
1443
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
1444
|
-
}
|
|
1445
|
-
catch (error) {
|
|
1446
|
-
streams.stderr.write(`h2a remote send: cannot read --private-key (${error.message})\n`);
|
|
1447
|
-
return 1;
|
|
1448
|
-
}
|
|
1449
|
-
let result;
|
|
1450
|
-
try {
|
|
1451
|
-
result = await sendRemoteEnvelope(flags.url, envelope, {
|
|
1452
|
-
by: flags.instance,
|
|
1453
|
-
privateKeyPem
|
|
1454
|
-
});
|
|
1455
|
-
}
|
|
1456
|
-
catch (error) {
|
|
1457
|
-
streams.stderr.write(`h2a remote send: ${error.message}\n`);
|
|
1458
|
-
return 1;
|
|
1459
|
-
}
|
|
1460
|
-
streams.stdout.write(`${JSON.stringify({ status: result.status, body: result.body }, null, 2)}\n`);
|
|
1461
|
-
return result.status >= 200 && result.status < 300 ? 0 : 1;
|
|
1462
|
-
}
|
|
1463
|
-
/**
|
|
1464
|
-
* Operator-enrolled key PEMs the mirror ingester trusts (EVO-13). Source order:
|
|
1465
|
-
* `--enrolled-keys-file` (JSON array of PEM strings), else `H2A_MIRROR_ENROLLED_KEYS`
|
|
1466
|
-
* (base64 of that JSON array — newline-safe for a k8s Secret env). Empty = refuse
|
|
1467
|
-
* every not-yet-registered signer (no TOFU on the wire).
|
|
1468
|
-
*/
|
|
1469
|
-
function loadEnrolledKeys(flags, env) {
|
|
1470
|
-
const raw = flags["enrolled-keys-file"]
|
|
1471
|
-
? readFileSync(flags["enrolled-keys-file"], "utf8")
|
|
1472
|
-
: env.H2A_MIRROR_ENROLLED_KEYS
|
|
1473
|
-
? Buffer.from(env.H2A_MIRROR_ENROLLED_KEYS, "base64").toString("utf8")
|
|
1474
|
-
: "[]";
|
|
1475
|
-
const parsed = JSON.parse(raw);
|
|
1476
|
-
if (!Array.isArray(parsed) || parsed.some((k) => typeof k !== "string")) {
|
|
1477
|
-
throw new Error("enrolled keys must be a JSON array of PEM strings");
|
|
1478
|
-
}
|
|
1479
|
-
return parsed;
|
|
1480
|
-
}
|
|
1481
|
-
/**
|
|
1482
|
-
* `h2a remote mirror-serve` (EVO-13 P1): long-running ingester that applies
|
|
1483
|
-
* signed instance mirrors to the store registry. Authority = an operator-enrolled
|
|
1484
|
-
* key or an already-registered key (never a self-declared id). Binds 127.0.0.1
|
|
1485
|
-
* unless `--host 0.0.0.0`. Async + blocking → dispatched from bin.ts.
|
|
1486
|
-
*/
|
|
1487
|
-
export async function runMirrorServe(flags, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
1488
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
1489
|
-
const root = resolveRoot(flags, cwd);
|
|
1490
|
-
const host = flags.host ?? "127.0.0.1";
|
|
1491
|
-
const port = flags.port ? Number.parseInt(flags.port, 10) : 8788;
|
|
1492
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1493
|
-
io.stderr.write(`h2a remote mirror-serve: invalid --port "${flags.port}"\n`);
|
|
1494
|
-
return 1;
|
|
1495
|
-
}
|
|
1496
|
-
let enrolledKeys;
|
|
1497
|
-
try {
|
|
1498
|
-
enrolledKeys = loadEnrolledKeys(flags, process.env);
|
|
1499
|
-
}
|
|
1500
|
-
catch (error) {
|
|
1501
|
-
io.stderr.write(`h2a remote mirror-serve: ${error.message}\n`);
|
|
1502
|
-
return 1;
|
|
1503
|
-
}
|
|
1504
|
-
let store;
|
|
1505
|
-
try {
|
|
1506
|
-
store = createLocalStore({ root });
|
|
1507
|
-
}
|
|
1508
|
-
catch (error) {
|
|
1509
|
-
io.stderr.write(`h2a remote mirror-serve: ${error.message}\n`);
|
|
1510
|
-
return 1;
|
|
1511
|
-
}
|
|
1512
|
-
const server = mirrorServerForStore(store, { enrolledKeys, ...(flags.path ? { path: flags.path } : {}) });
|
|
1513
|
-
return await new Promise((resolve) => {
|
|
1514
|
-
server.on("error", (err) => {
|
|
1515
|
-
io.stderr.write(`h2a remote mirror-serve: ${err.message}\n`);
|
|
1516
|
-
resolve(1);
|
|
1517
|
-
});
|
|
1518
|
-
server.listen(port, host, () => {
|
|
1519
|
-
io.stdout.write(`h2a remote mirror-serve: listening on http://${host}:${port}${flags.path ?? "/h2a/mirror"} (root ${root}, ${enrolledKeys.length} enrolled keys)\n`);
|
|
1520
|
-
io.onListening?.(server);
|
|
1521
|
-
});
|
|
1522
|
-
server.on("close", () => resolve(0));
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
/**
|
|
1526
|
-
* `h2a remote mirror` (EVO-13 P1): build the local instance's own registration
|
|
1527
|
-
* mirror, sign it with `--private-key`, and POST it to a remote ingester `--url`.
|
|
1528
|
-
* Exit 0 on a 2xx. Async (network) → dispatched from bin.ts.
|
|
1529
|
-
*/
|
|
1530
|
-
export async function runMirrorPush(flags, streams = { stdout: process.stdout, stderr: process.stderr }) {
|
|
1531
|
-
if (!flags.url || !flags.instance || !flags["private-key"]) {
|
|
1532
|
-
streams.stderr.write("h2a remote mirror: --url, --instance and --private-key are required\n");
|
|
1533
|
-
return 1;
|
|
1534
|
-
}
|
|
1535
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1536
|
-
const root = resolveRoot(flags, cwd);
|
|
1537
|
-
let envelope;
|
|
1538
|
-
try {
|
|
1539
|
-
envelope = buildInstanceMirror(createLocalStore({ root }), flags.instance, Date.now());
|
|
1540
|
-
}
|
|
1541
|
-
catch (error) {
|
|
1542
|
-
streams.stderr.write(`h2a remote mirror: ${error.message}\n`);
|
|
1543
|
-
return 1;
|
|
1544
|
-
}
|
|
1545
|
-
let privateKeyPem;
|
|
1546
|
-
try {
|
|
1547
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
1548
|
-
}
|
|
1549
|
-
catch (error) {
|
|
1550
|
-
streams.stderr.write(`h2a remote mirror: cannot read --private-key (${error.message})\n`);
|
|
1551
|
-
return 1;
|
|
1552
|
-
}
|
|
1553
|
-
let result;
|
|
1554
|
-
try {
|
|
1555
|
-
result = await sendRemoteEnvelope(flags.url, envelope, { by: flags.instance, privateKeyPem });
|
|
1556
|
-
}
|
|
1557
|
-
catch (error) {
|
|
1558
|
-
streams.stderr.write(`h2a remote mirror: ${error.message}\n`);
|
|
1559
|
-
return 1;
|
|
1560
|
-
}
|
|
1561
|
-
streams.stdout.write(`${JSON.stringify({ status: result.status, body: result.body }, null, 2)}\n`);
|
|
1562
|
-
return result.status >= 200 && result.status < 300 ? 0 : 1;
|
|
1563
|
-
}
|
|
1564
|
-
function parseLoopRepo(value) {
|
|
1565
|
-
const [path, role] = value.split(":");
|
|
1566
|
-
if (!path)
|
|
1567
|
-
throw new Error("--repo must be <path[:role]>");
|
|
1568
|
-
return { path, ...(role ? { role } : {}) };
|
|
1569
|
-
}
|
|
1570
|
-
function parseLoopTrack(value) {
|
|
1571
|
-
const ref = JSON.parse(value);
|
|
1572
|
-
if (ref.system !== "track" || !ref.repoKey || !ref.workspace || !ref.aggregateKind || !ref.aggregateId || !ref.role) {
|
|
1573
|
-
throw new Error("--track must be a JSON TrackRef with system, repoKey, workspace, aggregateKind, aggregateId and role");
|
|
1574
|
-
}
|
|
1575
|
-
return ref;
|
|
1576
|
-
}
|
|
1577
|
-
function parseLoopAgent(value, index) {
|
|
1578
|
-
const [host, role, placement] = value.split(":");
|
|
1579
|
-
if (!host || !role || !placement)
|
|
1580
|
-
throw new Error("--agent must be <host:role:placement>");
|
|
1581
|
-
if (!["claude", "codex", "agy", "gemini", "mistral", "opencode", "shell"].includes(host)) {
|
|
1582
|
-
throw new Error(`--agent host is unsupported: ${host}`);
|
|
1583
|
-
}
|
|
1584
|
-
if (!["local", "remote", "auto", "headless-local", "headless-remote", "interactive-local", "interactive-remote"].includes(placement)) {
|
|
1585
|
-
throw new Error(`--agent placement is unsupported: ${placement}`);
|
|
1586
|
-
}
|
|
1587
|
-
return {
|
|
1588
|
-
id: `agent-${index + 1}`,
|
|
1589
|
-
host: host,
|
|
1590
|
-
role,
|
|
1591
|
-
placement: placement,
|
|
1592
|
-
status: "planned"
|
|
1593
|
-
};
|
|
1594
|
-
}
|
|
1595
|
-
function collectRepeatedFlag(argv, flag) {
|
|
1596
|
-
const out = [];
|
|
1597
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1598
|
-
if (argv[i] !== flag)
|
|
1599
|
-
continue;
|
|
1600
|
-
const value = argv[i + 1];
|
|
1601
|
-
if (value === undefined || value.startsWith("--"))
|
|
1602
|
-
throw new Error(`${flag} requires a value`);
|
|
1603
|
-
out.push(value);
|
|
1604
|
-
i++;
|
|
1605
|
-
}
|
|
1606
|
-
return out;
|
|
1607
|
-
}
|
|
1608
|
-
function selectLoopAgent(loop, selector) {
|
|
1609
|
-
if (!selector)
|
|
1610
|
-
return undefined;
|
|
1611
|
-
return loop.agents.find((agent) => agent.id === selector || agent.role === selector || agent.host === selector || agent.remoteAgentId === selector || agent.h2aInstance === selector);
|
|
1612
|
-
}
|
|
1613
|
-
function cmdLoop(argv, streams) {
|
|
1614
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1615
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1616
|
-
const root = resolveRoot(flags, cwd);
|
|
1617
|
-
try {
|
|
1618
|
-
if (sub === "create") {
|
|
1619
|
-
const repos = collectRepeatedFlag(argv, "--repo").map(parseLoopRepo);
|
|
1620
|
-
const refs = collectRepeatedFlag(argv, "--track").map(parseLoopTrack);
|
|
1621
|
-
const agents = collectRepeatedFlag(argv, "--agent").map(parseLoopAgent);
|
|
1622
|
-
const loop = createObjectiveLoop(root, {
|
|
1623
|
-
...(flags.id ? { id: flags.id } : {}),
|
|
1624
|
-
name: flags.name,
|
|
1625
|
-
goal: flags.goal,
|
|
1626
|
-
repos,
|
|
1627
|
-
refs,
|
|
1628
|
-
agents
|
|
1629
|
-
});
|
|
1630
|
-
streams.stdout.write(`${JSON.stringify(loop, null, 2)}\n`);
|
|
1631
|
-
return 0;
|
|
1632
|
-
}
|
|
1633
|
-
if (sub === "list") {
|
|
1634
|
-
streams.stdout.write(`${JSON.stringify(listObjectiveLoops(root), null, 2)}\n`);
|
|
1635
|
-
return 0;
|
|
1636
|
-
}
|
|
1637
|
-
if (sub === "status") {
|
|
1638
|
-
const loopId = argv[1];
|
|
1639
|
-
if (!loopId || loopId.startsWith("--")) {
|
|
1640
|
-
streams.stderr.write("h2a loop status: <loopId> is required\n");
|
|
1641
|
-
return 1;
|
|
1642
|
-
}
|
|
1643
|
-
streams.stdout.write(`${JSON.stringify(readObjectiveLoop(root, loopId), null, 2)}\n`);
|
|
1644
|
-
return 0;
|
|
1645
|
-
}
|
|
1646
|
-
if (sub === "agents") {
|
|
1647
|
-
const loopId = argv[1];
|
|
1648
|
-
if (!loopId || loopId.startsWith("--")) {
|
|
1649
|
-
streams.stderr.write("h2a loop agents: <loopId> is required\n");
|
|
1650
|
-
return 1;
|
|
1651
|
-
}
|
|
1652
|
-
streams.stdout.write(`${JSON.stringify(readObjectiveLoop(root, loopId).agents, null, 2)}\n`);
|
|
1653
|
-
return 0;
|
|
1654
|
-
}
|
|
1655
|
-
if (sub === "attach") {
|
|
1656
|
-
const loopId = argv[1];
|
|
1657
|
-
if (!loopId || loopId.startsWith("--") || !flags.agent) {
|
|
1658
|
-
streams.stderr.write("h2a loop attach: <loopId> and --agent <selector> are required\n");
|
|
1659
|
-
return 1;
|
|
1660
|
-
}
|
|
1661
|
-
const loop = readObjectiveLoop(root, loopId);
|
|
1662
|
-
const agent = selectLoopAgent(loop, flags.agent);
|
|
1663
|
-
if (!agent)
|
|
1664
|
-
throw new Error(`agent not found: ${flags.agent}`);
|
|
1665
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, loopId, agent, action: "attach", supported: false, reason: "remote attach delegation is not implemented in h2a loop MVP" }, null, 2)}\n`);
|
|
1666
|
-
return 0;
|
|
1667
|
-
}
|
|
1668
|
-
if (sub === "logs") {
|
|
1669
|
-
const loopId = argv[1];
|
|
1670
|
-
if (!loopId || loopId.startsWith("--")) {
|
|
1671
|
-
streams.stderr.write("h2a loop logs: <loopId> is required\n");
|
|
1672
|
-
return 1;
|
|
1673
|
-
}
|
|
1674
|
-
const loop = readObjectiveLoop(root, loopId);
|
|
1675
|
-
const agent = selectLoopAgent(loop, flags.agent);
|
|
1676
|
-
streams.stdout.write(`${JSON.stringify({ loopId, ...(agent ? { agent } : {}), events: listLoopEvents(root, loopId) }, null, 2)}\n`);
|
|
1677
|
-
return 0;
|
|
1678
|
-
}
|
|
1679
|
-
if (sub === "tick" || sub === "watch") {
|
|
1680
|
-
const loopId = argv[1];
|
|
1681
|
-
if (!loopId || loopId.startsWith("--")) {
|
|
1682
|
-
streams.stderr.write(`h2a loop ${sub}: <loopId> is required\n`);
|
|
1683
|
-
return 1;
|
|
1684
|
-
}
|
|
1685
|
-
readObjectiveLoop(root, loopId);
|
|
1686
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, loopId, action: sub, supported: false, reason: "loop orchestration is not implemented in h2a loop MVP" }, null, 2)}\n`);
|
|
1687
|
-
return 0;
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
catch (error) {
|
|
1691
|
-
streams.stderr.write(`h2a loop ${sub ?? ""}: ${error.message}\n`);
|
|
1692
|
-
return classifyStoreError(error.message);
|
|
1693
|
-
}
|
|
1694
|
-
streams.stderr.write("h2a loop: subcommand required (create, list, status, agents, attach, logs, tick, watch)\n");
|
|
1695
|
-
return 1;
|
|
1696
|
-
}
|
|
1697
|
-
function cmdDrumbeat(argv, streams) {
|
|
1698
|
-
// DEC-086 (D2): durable anti-stall registry — record stops, scan candidates,
|
|
1699
|
-
// clear. The long-running `watch` daemon is async (dispatched from bin.ts).
|
|
1700
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1701
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1702
|
-
const root = resolveRoot(flags, cwd);
|
|
1703
|
-
if (sub === "record") {
|
|
1704
|
-
if (!flags.instance || !flags.status) {
|
|
1705
|
-
streams.stderr.write("h2a drumbeat record: --instance <id> and --status <work-status> are required\n");
|
|
1706
|
-
return 1;
|
|
1707
|
-
}
|
|
1708
|
-
if (!H2A_WORK_STATUSES.includes(flags.status)) {
|
|
1709
|
-
streams.stderr.write(`h2a drumbeat record: --status must be one of ${H2A_WORK_STATUSES.join("|")} (got "${flags.status}")\n`);
|
|
1710
|
-
return 1;
|
|
1711
|
-
}
|
|
1712
|
-
const launchContext = flags.command
|
|
1713
|
-
? {
|
|
1714
|
-
cwd: flags.cwd ?? cwd(),
|
|
1715
|
-
command: flags.command,
|
|
1716
|
-
...(flags["resume-command"] ? { resumeCommand: flags["resume-command"] } : {}),
|
|
1717
|
-
...(flags.tty ? { tty: flags.tty } : {}),
|
|
1718
|
-
...(flags["tmux-session"] && flags["tmux-pane"]
|
|
1719
|
-
? { tmux: { session: flags["tmux-session"], pane: flags["tmux-pane"], ...(flags["tmux-window"] ? { window: flags["tmux-window"] } : {}) } }
|
|
1720
|
-
: {})
|
|
1721
|
-
}
|
|
1722
|
-
: undefined;
|
|
1723
|
-
const entry = recordStop(root, {
|
|
1724
|
-
instance: flags.instance,
|
|
1725
|
-
workStatus: flags.status,
|
|
1726
|
-
...(launchContext ? { launchContext } : {})
|
|
1727
|
-
});
|
|
1728
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, instance: entry.instance, workStatus: entry.workStatus }, null, 2)}\n`);
|
|
1729
|
-
return 0;
|
|
1730
|
-
}
|
|
1731
|
-
if (sub === "scan") {
|
|
1732
|
-
const maxRelances = flags["max-relances"] ? Number.parseInt(flags["max-relances"], 10) : undefined;
|
|
1733
|
-
const result = scanDrumbeat(root, maxRelances !== undefined ? { maxRelances } : {});
|
|
1734
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1735
|
-
return 0;
|
|
1736
|
-
}
|
|
1737
|
-
if (sub === "clear") {
|
|
1738
|
-
if (!flags.instance) {
|
|
1739
|
-
streams.stderr.write("h2a drumbeat clear: --instance <id> is required\n");
|
|
1740
|
-
return 1;
|
|
1741
|
-
}
|
|
1742
|
-
clearDrumbeatEntry(root, flags.instance);
|
|
1743
|
-
// DEC-095 (D7): a clean resume/finish also clears any open escalation.
|
|
1744
|
-
clearEscalation(root, flags.instance);
|
|
1745
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, cleared: true }, null, 2)}\n`);
|
|
1746
|
-
return 0;
|
|
1747
|
-
}
|
|
1748
|
-
if (sub === "escalations") {
|
|
1749
|
-
// DEC-095 (D7): the open escalations the daemon raised on relance-exhaustion.
|
|
1750
|
-
streams.stdout.write(`${JSON.stringify(listEscalations(root), null, 2)}\n`);
|
|
1751
|
-
return 0;
|
|
1752
|
-
}
|
|
1753
|
-
streams.stderr.write("h2a drumbeat: subcommand required (record, scan, clear, escalations, watch)\n");
|
|
1754
|
-
return 1;
|
|
1755
|
-
}
|
|
1756
|
-
/**
|
|
1757
|
-
* `h2a blockage` (DEC-092, EVO-3): the peer-facing blockage feedback loop —
|
|
1758
|
-
* distinct from the drumbeat (silent stall) and escalation (→ authority). Raise
|
|
1759
|
-
* a blockage so peers in scope are notified (`peer.blocked` push); resolve it
|
|
1760
|
-
* when unblocked (`peer.unblocked`).
|
|
1761
|
-
*/
|
|
1762
|
-
function cmdBlockage(argv, streams) {
|
|
1763
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1764
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1765
|
-
const root = resolveRoot(flags, cwd);
|
|
1766
|
-
if (sub === "raise") {
|
|
1767
|
-
if (!flags.instance || !flags.reason) {
|
|
1768
|
-
streams.stderr.write("h2a blockage raise: --instance <id> and --reason <text> are required\n");
|
|
1769
|
-
return 1;
|
|
1770
|
-
}
|
|
1771
|
-
const blockage = raiseBlockage(root, {
|
|
1772
|
-
instance: flags.instance,
|
|
1773
|
-
scope: flags.scope ?? "",
|
|
1774
|
-
reason: flags.reason,
|
|
1775
|
-
...(flags.needs ? { needs: flags.needs } : {})
|
|
1776
|
-
});
|
|
1777
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, ...blockage }, null, 2)}\n`);
|
|
1778
|
-
return 0;
|
|
1779
|
-
}
|
|
1780
|
-
if (sub === "list") {
|
|
1781
|
-
let blockages = listBlockages(root);
|
|
1782
|
-
// EVO-3 (DEC-110): one-shot poll digest for daemonless hosts (e.g. agy) —
|
|
1783
|
-
// `--instance <id>` returns only blockages in scopes that instance is a
|
|
1784
|
-
// member of, per the effective org view (registration ∪ provisioned grants).
|
|
1785
|
-
// A daemonless agent can poll a single command instead of one per scope.
|
|
1786
|
-
if (flags.instance) {
|
|
1787
|
-
const store = createLocalStore({ root });
|
|
1788
|
-
const effective = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
|
|
1789
|
-
const mine = effective.find((e) => e.instance === flags.instance);
|
|
1790
|
-
const scopes = new Set(mine?.scopes ?? []);
|
|
1791
|
-
blockages = blockages.filter((b) => scopes.has(b.scope));
|
|
1792
|
-
}
|
|
1793
|
-
if (flags.scope)
|
|
1794
|
-
blockages = blockages.filter((b) => b.scope === flags.scope);
|
|
1795
|
-
if (flags.active !== undefined)
|
|
1796
|
-
blockages = blockages.filter((b) => b.resolvedAt === undefined);
|
|
1797
|
-
streams.stdout.write(`${JSON.stringify(blockages, null, 2)}\n`);
|
|
1798
|
-
return 0;
|
|
1799
|
-
}
|
|
1800
|
-
if (sub === "resolve") {
|
|
1801
|
-
if (!flags.instance) {
|
|
1802
|
-
streams.stderr.write("h2a blockage resolve: --instance <id> is required\n");
|
|
1803
|
-
return 1;
|
|
1804
|
-
}
|
|
1805
|
-
const resolved = resolveBlockage(root, flags.instance, flags.by ? { by: flags.by } : {});
|
|
1806
|
-
if (!resolved) {
|
|
1807
|
-
streams.stderr.write(`h2a blockage resolve: no blockage recorded for "${flags.instance}"\n`);
|
|
1808
|
-
return 2;
|
|
1809
|
-
}
|
|
1810
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, ...resolved }, null, 2)}\n`);
|
|
1811
|
-
return 0;
|
|
1812
|
-
}
|
|
1813
|
-
streams.stderr.write(`h2a blockage: unknown subcommand "${sub ?? ""}" (raise, list, resolve)\n`);
|
|
1814
|
-
return 1;
|
|
1815
|
-
}
|
|
1816
|
-
/** Resolve `--file` (default `org.h2a.yaml` in cwd) and read its text, or report an I/O error. */
|
|
1817
|
-
function readOrgSource(flags, cwd, streams, verb) {
|
|
1818
|
-
const file = flags.file
|
|
1819
|
-
? resolvePath(cwd(), flags.file)
|
|
1820
|
-
: join(cwd(), H2A_ORG_MANIFEST_FILENAME);
|
|
1821
|
-
try {
|
|
1822
|
-
return readFileSync(file, "utf8");
|
|
1823
|
-
}
|
|
1824
|
-
catch (error) {
|
|
1825
|
-
const code = error.code;
|
|
1826
|
-
streams.stderr.write(`${verb}: cannot read ${file}: ${code ?? error.message}\n`);
|
|
1827
|
-
return undefined;
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
/**
|
|
1831
|
-
* `--deliver` for `coach propose`/`ratify`: drop the (un)signed org envelope into
|
|
1832
|
-
* each declared instance's inbox so affected agents actually receive it and can
|
|
1833
|
-
* have their say (counter) — closing the propose→counter→ratify loop over the
|
|
1834
|
-
* existing mailbox primitives. The envelope itself stays the stdout payload;
|
|
1835
|
-
* delivery is reported on stderr so the `resource` contract is preserved.
|
|
1836
|
-
*/
|
|
1837
|
-
function deliverOrgEnvelope(flags, cwd, streams, manifest, envelope, verb) {
|
|
1838
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
1839
|
-
const delivered = [];
|
|
1840
|
-
for (const inst of manifest.instances) {
|
|
1841
|
-
try {
|
|
1842
|
-
store.putInboxMessage(inst.instance, envelope);
|
|
1843
|
-
delivered.push(inst.instance);
|
|
1844
|
-
}
|
|
1845
|
-
catch (error) {
|
|
1846
|
-
streams.stderr.write(`${verb}: deliver to ${inst.instance} failed: ${error.message}\n`);
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
streams.stderr.write(`${verb}: delivered to ${delivered.length} inbox(es)${delivered.length ? `: ${delivered.join(", ")}` : ""}\n`);
|
|
1850
|
-
}
|
|
1851
|
-
/**
|
|
1852
|
-
* `h2a org` (EVO-7 slice 2, DEC-109): read-only tooling over the committed org
|
|
1853
|
-
* manifest (`org.h2a.yaml`). `validate` parses + checks the h2a invariants;
|
|
1854
|
-
* `show` prints the normalized manifest with its validation result. The coach's
|
|
1855
|
-
* propose/ratify lifecycle is `h2a coach`; live provisioning is a later slice.
|
|
1856
|
-
*/
|
|
1857
|
-
export function cmdOrg(argv, streams) {
|
|
1858
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1859
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1860
|
-
if (sub === "validate" || sub === "show") {
|
|
1861
|
-
const source = readOrgSource(flags, cwd, streams, `h2a org ${sub}`);
|
|
1862
|
-
if (source === undefined)
|
|
1863
|
-
return 3;
|
|
1864
|
-
const parsed = parseOrgManifest(source);
|
|
1865
|
-
if (!parsed.manifest) {
|
|
1866
|
-
streams.stdout.write(`${JSON.stringify({ ok: false, errors: parsed.errors }, null, 2)}\n`);
|
|
1867
|
-
return 1;
|
|
1868
|
-
}
|
|
1869
|
-
const validation = validateOrgManifest(parsed.manifest);
|
|
1870
|
-
if (sub === "validate") {
|
|
1871
|
-
streams.stdout.write(`${JSON.stringify({ ok: validation.ok, errors: validation.errors }, null, 2)}\n`);
|
|
1872
|
-
return validation.ok ? 0 : 1;
|
|
1873
|
-
}
|
|
1874
|
-
const m = parsed.manifest;
|
|
1875
|
-
streams.stdout.write(`${JSON.stringify({
|
|
1876
|
-
scope: m.scope,
|
|
1877
|
-
...(m.version !== undefined ? { version: m.version } : {}),
|
|
1878
|
-
instances: m.instances,
|
|
1879
|
-
...(m.commEdges !== undefined ? { commEdges: m.commEdges } : {}),
|
|
1880
|
-
validation
|
|
1881
|
-
}, null, 2)}\n`);
|
|
1882
|
-
return 0;
|
|
1883
|
-
}
|
|
1884
|
-
if (sub === "diff") {
|
|
1885
|
-
const source = readOrgSource(flags, cwd, streams, "h2a org diff");
|
|
1886
|
-
if (source === undefined)
|
|
1887
|
-
return 3;
|
|
1888
|
-
const parsed = parseOrgManifest(source);
|
|
1889
|
-
if (!parsed.manifest) {
|
|
1890
|
-
streams.stderr.write(`h2a org diff: ${parsed.errors.join("; ")}\n`);
|
|
1891
|
-
return 1;
|
|
1892
|
-
}
|
|
1893
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
1894
|
-
const effective = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
|
|
1895
|
-
const diff = diffOrgManifest(parsed.manifest, effective);
|
|
1896
|
-
streams.stdout.write(`${JSON.stringify(diff, null, 2)}\n`);
|
|
1897
|
-
return 0;
|
|
1898
|
-
}
|
|
1899
|
-
if (sub === "provision") {
|
|
1900
|
-
const source = readOrgSource(flags, cwd, streams, "h2a org provision");
|
|
1901
|
-
if (source === undefined)
|
|
1902
|
-
return 3;
|
|
1903
|
-
const parsed = parseOrgManifest(source);
|
|
1904
|
-
if (!parsed.manifest) {
|
|
1905
|
-
streams.stderr.write(`h2a org provision: ${parsed.errors.join("; ")}\n`);
|
|
1906
|
-
return 1;
|
|
1907
|
-
}
|
|
1908
|
-
const validation = validateOrgManifest(parsed.manifest);
|
|
1909
|
-
if (!validation.ok) {
|
|
1910
|
-
streams.stderr.write(`h2a org provision: refusing to provision an invalid org (${validation.errors.join(", ")})\n`);
|
|
1911
|
-
return 1;
|
|
1912
|
-
}
|
|
1913
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
1914
|
-
const at = new Date().toISOString();
|
|
1915
|
-
const applied = [];
|
|
1916
|
-
const unchanged = [];
|
|
1917
|
-
const pending = [];
|
|
1918
|
-
for (const inst of parsed.manifest.instances) {
|
|
1919
|
-
// Re-derive the effective view each iteration so repeated declared
|
|
1920
|
-
// instances and prior grants this run are reflected (idempotent).
|
|
1921
|
-
const eff = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
|
|
1922
|
-
const reg = eff.find((e) => e.instance === inst.instance);
|
|
1923
|
-
if (!reg) {
|
|
1924
|
-
pending.push({ instance: inst.instance, reason: "not registered (needs key + register)" });
|
|
1925
|
-
continue;
|
|
1926
|
-
}
|
|
1927
|
-
const roleMissing = !reg.roles.includes(inst.role);
|
|
1928
|
-
const missingScopes = inst.scopes.filter((s) => !reg.scopes.includes(s));
|
|
1929
|
-
const scopesToGrant = roleMissing ? [...new Set(inst.scopes)] : missingScopes;
|
|
1930
|
-
if (scopesToGrant.length === 0) {
|
|
1931
|
-
unchanged.push(inst.instance);
|
|
1932
|
-
continue;
|
|
1933
|
-
}
|
|
1934
|
-
for (const scope of scopesToGrant) {
|
|
1935
|
-
store.grantOrgMembership({
|
|
1936
|
-
instance: inst.instance,
|
|
1937
|
-
role: inst.role,
|
|
1938
|
-
scope,
|
|
1939
|
-
...(flags.by ? { by: flags.by } : {}),
|
|
1940
|
-
at
|
|
1941
|
-
});
|
|
1942
|
-
}
|
|
1943
|
-
applied.push({ instance: inst.instance, role: inst.role, grantedScopes: scopesToGrant });
|
|
1944
|
-
}
|
|
1945
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, scope: parsed.manifest.scope, applied, unchanged, pending }, null, 2)}\n`);
|
|
1946
|
-
return 0;
|
|
1947
|
-
}
|
|
1948
|
-
streams.stderr.write(`h2a org: unknown subcommand "${sub ?? ""}" (validate, show, diff, provision)\n`);
|
|
1949
|
-
return 1;
|
|
1950
|
-
}
|
|
1951
|
-
/**
|
|
1952
|
-
* `h2a coach` (EVO-7 slice 2, DEC-109): the coach **proposes, does not impose**.
|
|
1953
|
-
* `propose` emits the *unsigned* `org-proposal` envelope for a validated
|
|
1954
|
-
* manifest, signed (later) by the coach (a CONDUCTOR); affected agents may
|
|
1955
|
-
* counter and the owning PRINCIPAL then ratifies. Read-only here — signing,
|
|
1956
|
-
* persistence and provisioning are later slices.
|
|
1957
|
-
*/
|
|
1958
|
-
export function cmdCoach(argv, streams) {
|
|
1959
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
1960
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
1961
|
-
if (sub === "propose") {
|
|
1962
|
-
if (!flags.as) {
|
|
1963
|
-
streams.stderr.write("h2a coach propose: --as <coach-instance> is required\n");
|
|
1964
|
-
return 1;
|
|
1965
|
-
}
|
|
1966
|
-
const roleStr = flags.role ?? "CONDUCTOR";
|
|
1967
|
-
if (!H2A_ROLES.includes(roleStr)) {
|
|
1968
|
-
streams.stderr.write(`h2a coach propose: --role must be one of ${H2A_ROLES.join(", ")} (got "${flags.role}")\n`);
|
|
1969
|
-
return 1;
|
|
1970
|
-
}
|
|
1971
|
-
const source = readOrgSource(flags, cwd, streams, "h2a coach propose");
|
|
1972
|
-
if (source === undefined)
|
|
1973
|
-
return 3;
|
|
1974
|
-
const parsed = parseOrgManifest(source);
|
|
1975
|
-
if (!parsed.manifest) {
|
|
1976
|
-
streams.stderr.write(`h2a coach propose: ${parsed.errors.join("; ")}\n`);
|
|
1977
|
-
return 1;
|
|
1978
|
-
}
|
|
1979
|
-
const validation = validateOrgManifest(parsed.manifest);
|
|
1980
|
-
if (!validation.ok) {
|
|
1981
|
-
streams.stderr.write(`h2a coach propose: refusing to propose an invalid org (${validation.errors.join(", ")})\n`);
|
|
1982
|
-
return 1;
|
|
1983
|
-
}
|
|
1984
|
-
const envelope = orgAssignmentEnvelope({
|
|
1985
|
-
manifest: parsed.manifest,
|
|
1986
|
-
actor: {
|
|
1987
|
-
instance: flags.as,
|
|
1988
|
-
role: roleStr,
|
|
1989
|
-
scope: flags.scope ?? parsed.manifest.scope
|
|
1990
|
-
},
|
|
1991
|
-
kind: H2A_ORG_PROPOSAL_BODY_KIND
|
|
1992
|
-
});
|
|
1993
|
-
if (flags.deliver !== undefined) {
|
|
1994
|
-
deliverOrgEnvelope(flags, cwd, streams, parsed.manifest, envelope, "h2a coach propose");
|
|
1995
|
-
}
|
|
1996
|
-
streams.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
1997
|
-
return 0;
|
|
1998
|
-
}
|
|
1999
|
-
if (sub === "ratify") {
|
|
2000
|
-
if (!flags.as || !flags["private-key"]) {
|
|
2001
|
-
streams.stderr.write("h2a coach ratify: --as <principal-instance> and --private-key <pem-file> are required\n");
|
|
2002
|
-
return 1;
|
|
2003
|
-
}
|
|
2004
|
-
const roleStr = flags.role ?? "PRINCIPAL";
|
|
2005
|
-
if (!H2A_ROLES.includes(roleStr)) {
|
|
2006
|
-
streams.stderr.write(`h2a coach ratify: --role must be one of ${H2A_ROLES.join(", ")} (got "${flags.role}")\n`);
|
|
2007
|
-
return 1;
|
|
2008
|
-
}
|
|
2009
|
-
let privateKeyPem;
|
|
2010
|
-
try {
|
|
2011
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
2012
|
-
}
|
|
2013
|
-
catch (error) {
|
|
2014
|
-
streams.stderr.write(`h2a coach ratify: cannot read --private-key (${error.message})\n`);
|
|
2015
|
-
return 1;
|
|
2016
|
-
}
|
|
2017
|
-
const source = readOrgSource(flags, cwd, streams, "h2a coach ratify");
|
|
2018
|
-
if (source === undefined)
|
|
2019
|
-
return 3;
|
|
2020
|
-
const parsed = parseOrgManifest(source);
|
|
2021
|
-
if (!parsed.manifest) {
|
|
2022
|
-
streams.stderr.write(`h2a coach ratify: ${parsed.errors.join("; ")}\n`);
|
|
2023
|
-
return 1;
|
|
2024
|
-
}
|
|
2025
|
-
const validation = validateOrgManifest(parsed.manifest);
|
|
2026
|
-
if (!validation.ok) {
|
|
2027
|
-
streams.stderr.write(`h2a coach ratify: refusing to ratify an invalid org (${validation.errors.join(", ")})\n`);
|
|
2028
|
-
return 1;
|
|
2029
|
-
}
|
|
2030
|
-
const envelope = orgAssignmentEnvelope({
|
|
2031
|
-
manifest: parsed.manifest,
|
|
2032
|
-
actor: {
|
|
2033
|
-
instance: flags.as,
|
|
2034
|
-
role: roleStr,
|
|
2035
|
-
scope: flags.scope ?? parsed.manifest.scope
|
|
2036
|
-
},
|
|
2037
|
-
kind: H2A_ORG_RATIFIED_BODY_KIND
|
|
2038
|
-
});
|
|
2039
|
-
const signed = signEnvelope(envelope, { by: flags.as, privateKeyPem });
|
|
2040
|
-
if (flags.deliver !== undefined) {
|
|
2041
|
-
deliverOrgEnvelope(flags, cwd, streams, parsed.manifest, signed, "h2a coach ratify");
|
|
2042
|
-
}
|
|
2043
|
-
streams.stdout.write(`${JSON.stringify(signed, null, 2)}\n`);
|
|
2044
|
-
return 0;
|
|
2045
|
-
}
|
|
2046
|
-
streams.stderr.write(`h2a coach: unknown subcommand "${sub ?? ""}" (propose, ratify)\n`);
|
|
2047
|
-
return 1;
|
|
2048
|
-
}
|
|
2049
|
-
/**
|
|
2050
|
-
* `h2a nhi` (DEC-087): Non-Human-Identity posture over the local registry. P1
|
|
2051
|
-
* ships `report` — derive the OWASP NHI Top 10 / NIST CSF posture from
|
|
2052
|
-
* instances, subagent bindings and the keyring. See evaluations/nhi.md.
|
|
2053
|
-
*/
|
|
2054
|
-
function cmdNhi(argv, streams) {
|
|
2055
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
2056
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
2057
|
-
if (sub === "report") {
|
|
2058
|
-
let longLivedKeyMaxDays;
|
|
2059
|
-
if (flags["long-lived-days"] !== undefined) {
|
|
2060
|
-
longLivedKeyMaxDays = Number.parseInt(flags["long-lived-days"], 10);
|
|
2061
|
-
if (!Number.isInteger(longLivedKeyMaxDays) || longLivedKeyMaxDays < 1) {
|
|
2062
|
-
streams.stderr.write(`h2a nhi report: --long-lived-days must be a positive integer (got "${flags["long-lived-days"]}")\n`);
|
|
2063
|
-
return 1;
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
2067
|
-
const { instances, subagents, keyEvents } = gatherNhiSnapshot(store);
|
|
2068
|
-
const report = auditNhiPosture({
|
|
2069
|
-
instances,
|
|
2070
|
-
subagents,
|
|
2071
|
-
keyEvents,
|
|
2072
|
-
...(longLivedKeyMaxDays !== undefined ? { longLivedKeyMaxDays } : {})
|
|
2073
|
-
});
|
|
2074
|
-
streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
2075
|
-
return 0;
|
|
2076
|
-
}
|
|
2077
|
-
if (sub === "inventory") {
|
|
2078
|
-
let longLivedKeyMaxDays;
|
|
2079
|
-
if (flags["long-lived-days"] !== undefined) {
|
|
2080
|
-
longLivedKeyMaxDays = Number.parseInt(flags["long-lived-days"], 10);
|
|
2081
|
-
if (!Number.isInteger(longLivedKeyMaxDays) || longLivedKeyMaxDays < 1) {
|
|
2082
|
-
streams.stderr.write(`h2a nhi inventory: --long-lived-days must be a positive integer (got "${flags["long-lived-days"]}")\n`);
|
|
2083
|
-
return 1;
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
2087
|
-
const { instances, subagents, keyEvents, offboards } = gatherNhiSnapshot(store);
|
|
2088
|
-
const inventory = nhiInventory({
|
|
2089
|
-
instances,
|
|
2090
|
-
subagents,
|
|
2091
|
-
keyEvents,
|
|
2092
|
-
offboards,
|
|
2093
|
-
...(longLivedKeyMaxDays !== undefined ? { longLivedKeyMaxDays } : {})
|
|
2094
|
-
});
|
|
2095
|
-
streams.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`);
|
|
2096
|
-
return 0;
|
|
2097
|
-
}
|
|
2098
|
-
if (sub === "export") {
|
|
2099
|
-
if (!flags.instance || !flags["trust-domain"]) {
|
|
2100
|
-
streams.stderr.write("h2a nhi export: --instance <id> and --trust-domain <domain> are required\n");
|
|
2101
|
-
return 1;
|
|
2102
|
-
}
|
|
2103
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
2104
|
-
const activeKeys = store.listInstanceKeys(flags.instance);
|
|
2105
|
-
try {
|
|
2106
|
-
const bundle = nhiTrustBundle({
|
|
2107
|
-
instance: flags.instance,
|
|
2108
|
-
trustDomain: flags["trust-domain"],
|
|
2109
|
-
activeKeys
|
|
2110
|
-
});
|
|
2111
|
-
streams.stdout.write(`${JSON.stringify(bundle, null, 2)}\n`);
|
|
2112
|
-
return 0;
|
|
2113
|
-
}
|
|
2114
|
-
catch (error) {
|
|
2115
|
-
streams.stderr.write(`h2a nhi export: ${error.message}\n`);
|
|
2116
|
-
return 1;
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
if (sub === "attest") {
|
|
2120
|
-
if (!flags.instance || !flags["private-key"]) {
|
|
2121
|
-
streams.stderr.write("h2a nhi attest: --instance <id> and --private-key <pem-file> are required\n");
|
|
2122
|
-
return 1;
|
|
2123
|
-
}
|
|
2124
|
-
let privateKeyPem;
|
|
2125
|
-
try {
|
|
2126
|
-
privateKeyPem = readFileSync(flags["private-key"], "utf8");
|
|
2127
|
-
}
|
|
2128
|
-
catch (error) {
|
|
2129
|
-
streams.stderr.write(`h2a nhi attest: cannot read --private-key (${error.message})\n`);
|
|
2130
|
-
return 1;
|
|
2131
|
-
}
|
|
2132
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
2133
|
-
const registration = store.findInstance(flags.instance);
|
|
2134
|
-
const role = flags.role ?? registration?.roles?.[0];
|
|
2135
|
-
const scope = flags.scope ?? registration?.scopes?.[0];
|
|
2136
|
-
if (!role || !scope) {
|
|
2137
|
-
streams.stderr.write(`h2a nhi attest: cannot resolve actor for "${flags.instance}" — register it first, or pass --role and --scope\n`);
|
|
2138
|
-
return 2;
|
|
2139
|
-
}
|
|
2140
|
-
if (!H2A_ROLES.includes(role)) {
|
|
2141
|
-
streams.stderr.write(`h2a nhi attest: --role must be one of ${H2A_ROLES.join("|")} (got "${role}")\n`);
|
|
2142
|
-
return 1;
|
|
2143
|
-
}
|
|
2144
|
-
const { instances, subagents, keyEvents } = gatherNhiSnapshot(store);
|
|
2145
|
-
const report = auditNhiPosture({ instances, subagents, keyEvents });
|
|
2146
|
-
const envelope = nhiAttestationEnvelope({
|
|
2147
|
-
report,
|
|
2148
|
-
actor: { instance: flags.instance, role: role, scope }
|
|
2149
|
-
});
|
|
2150
|
-
const signed = signEnvelope(envelope, { by: flags.instance, privateKeyPem });
|
|
2151
|
-
streams.stdout.write(`${JSON.stringify(signed, null, 2)}\n`);
|
|
2152
|
-
return 0;
|
|
2153
|
-
}
|
|
2154
|
-
if (sub === "offboard") {
|
|
2155
|
-
if (!flags.instance) {
|
|
2156
|
-
streams.stderr.write("h2a nhi offboard: --instance <id> is required\n");
|
|
2157
|
-
return 1;
|
|
2158
|
-
}
|
|
2159
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
2160
|
-
let tombstone;
|
|
2161
|
-
try {
|
|
2162
|
-
tombstone = store.offboardInstance(flags.instance, flags.reason);
|
|
2163
|
-
}
|
|
2164
|
-
catch (error) {
|
|
2165
|
-
const message = error.message;
|
|
2166
|
-
streams.stderr.write(`h2a nhi offboard: ${message}\n`);
|
|
2167
|
-
return classifyStoreError(message);
|
|
2168
|
-
}
|
|
2169
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, ...tombstone }, null, 2)}\n`);
|
|
2170
|
-
return 0;
|
|
2171
|
-
}
|
|
2172
|
-
streams.stderr.write(`h2a nhi: unknown subcommand "${sub ?? ""}" (report, inventory, attest, offboard, export)\n`);
|
|
2173
|
-
return 1;
|
|
2174
|
-
}
|
|
2175
|
-
/**
|
|
2176
|
-
* Build the local relauncher used when consuming D4 resume envelopes on the
|
|
2177
|
-
* receiving host. `auto` here is intentionally local-only: resume envelopes
|
|
2178
|
-
* must not be re-relayed.
|
|
2179
|
-
*/
|
|
2180
|
-
function buildLocalInboxRelauncher(kind, log) {
|
|
2181
|
-
switch (kind) {
|
|
2182
|
-
case "local-tmux":
|
|
2183
|
-
return localTmuxRelauncher({ log });
|
|
2184
|
-
case "headless":
|
|
2185
|
-
return headlessRelauncher({ log });
|
|
2186
|
-
case "logging":
|
|
2187
|
-
return loggingRelauncher(log);
|
|
2188
|
-
case "remote":
|
|
2189
|
-
case "auto":
|
|
2190
|
-
case undefined:
|
|
2191
|
-
return chainRelauncher(localTmuxRelauncher({ log }), headlessRelauncher({ log }));
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
function resolveRemoteActor(root, flags) {
|
|
2195
|
-
if (!flags.instance || !flags["private-key"]) {
|
|
2196
|
-
return "--instance and --private-key are required for --relauncher remote|auto";
|
|
2197
|
-
}
|
|
2198
|
-
const store = createLocalStore({ root });
|
|
2199
|
-
const registration = store.findInstance(flags.instance);
|
|
2200
|
-
// The actor role here is only metadata stamped on the signed resume envelope.
|
|
2201
|
-
// The receiving host re-derives its own `launchContext` and never grants the
|
|
2202
|
-
// sender authority from this role, so the `AGENTS` default cannot widen any
|
|
2203
|
-
// privilege — it is the safe least-authority fallback (DEC-117).
|
|
2204
|
-
const role = flags.role ?? registration?.roles?.[0] ?? "AGENTS";
|
|
2205
|
-
const scope = flags.scope ?? registration?.scopes?.[0] ?? "scope:default";
|
|
2206
|
-
if (!H2A_ROLES.includes(role)) {
|
|
2207
|
-
return `--role must be one of ${H2A_ROLES.join("|")} (got "${role}")`;
|
|
2208
|
-
}
|
|
2209
|
-
return { instance: flags.instance, role: role, scope };
|
|
2210
|
-
}
|
|
2211
|
-
function readPrivateKeyFlag(label, flags) {
|
|
2212
|
-
try {
|
|
2213
|
-
return readFileSync(flags["private-key"], "utf8");
|
|
2214
|
-
}
|
|
2215
|
-
catch (error) {
|
|
2216
|
-
return new Error(`${label}: cannot read --private-key (${error.message})`);
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
function latestLaunchContext(root, instance) {
|
|
2220
|
-
return listPresence(root)
|
|
2221
|
-
.filter((session) => session.instance === instance && session.launchContext)
|
|
2222
|
-
.sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))[0]?.launchContext;
|
|
2223
|
-
}
|
|
2224
|
-
function buildDriveDriver(kind, log) {
|
|
2225
|
-
switch (kind) {
|
|
2226
|
-
case "logging":
|
|
2227
|
-
return loggingDriver(log);
|
|
2228
|
-
case "native":
|
|
2229
|
-
return nativeBackchannelDriver();
|
|
2230
|
-
case "local-tmux":
|
|
2231
|
-
return localTmuxDriver({ log });
|
|
2232
|
-
case "headless":
|
|
2233
|
-
return headlessDriver({ log });
|
|
2234
|
-
case "auto":
|
|
2235
|
-
return {
|
|
2236
|
-
drive(request) {
|
|
2237
|
-
for (const driver of [
|
|
2238
|
-
nativeBackchannelDriver(),
|
|
2239
|
-
localTmuxDriver({ log }),
|
|
2240
|
-
headlessDriver({ log })
|
|
2241
|
-
]) {
|
|
2242
|
-
const result = driver.drive(request);
|
|
2243
|
-
if (result === true)
|
|
2244
|
-
return true;
|
|
2245
|
-
}
|
|
2246
|
-
return false;
|
|
2247
|
-
}
|
|
2248
|
-
};
|
|
2249
|
-
}
|
|
2250
|
-
}
|
|
2251
|
-
function driveReplayGuard(root) {
|
|
2252
|
-
const dir = join(root, "drive-replay");
|
|
2253
|
-
return {
|
|
2254
|
-
accept(envelope, now = Date.now()) {
|
|
2255
|
-
const freshness = checkEnvelopeFreshness(envelope, { now });
|
|
2256
|
-
if (!freshness.ok)
|
|
2257
|
-
return freshness;
|
|
2258
|
-
mkdirSync(dir, { recursive: true });
|
|
2259
|
-
const file = join(dir, `${safePathSegment(envelope.id)}.json`);
|
|
2260
|
-
try {
|
|
2261
|
-
writeFileSync(file, `${JSON.stringify({ id: envelope.id, createdAt: envelope.createdAt })}\n`, { encoding: "utf8", flag: "wx" });
|
|
2262
|
-
}
|
|
2263
|
-
catch (error) {
|
|
2264
|
-
if (error.code === "EEXIST") {
|
|
2265
|
-
return { ok: false, reason: "replayed" };
|
|
2266
|
-
}
|
|
2267
|
-
throw error;
|
|
2268
|
-
}
|
|
2269
|
-
return { ok: true };
|
|
2270
|
-
},
|
|
2271
|
-
size() {
|
|
2272
|
-
try {
|
|
2273
|
-
return readdirSync(dir).filter((entry) => entry.endsWith(".json")).length;
|
|
2274
|
-
}
|
|
2275
|
-
catch (error) {
|
|
2276
|
-
if (error.code === "ENOENT")
|
|
2277
|
-
return 0;
|
|
2278
|
-
throw error;
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
function cmdDrive(flags, streams) {
|
|
2284
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
2285
|
-
if (!flags.from || !flags.to || !flags.instruction || !flags["private-key"]) {
|
|
2286
|
-
streams.stderr.write("h2a drive: --from, --to, --instruction and --private-key are required\n");
|
|
2287
|
-
return 1;
|
|
2288
|
-
}
|
|
2289
|
-
const kind = (flags.driver ?? "auto");
|
|
2290
|
-
if (!["logging", "native", "local-tmux", "headless", "auto"].includes(kind)) {
|
|
2291
|
-
streams.stderr.write(`h2a drive: --driver must be one of logging|native|local-tmux|headless|auto (got "${flags.driver}")\n`);
|
|
2292
|
-
return 1;
|
|
2293
|
-
}
|
|
2294
|
-
const privateKeyPem = readPrivateKeyFlag("h2a drive", flags);
|
|
2295
|
-
if (privateKeyPem instanceof Error) {
|
|
2296
|
-
streams.stderr.write(`${privateKeyPem.message}\n`);
|
|
2297
|
-
return 1;
|
|
2298
|
-
}
|
|
2299
|
-
const root = resolveRoot(flags, cwd);
|
|
2300
|
-
const store = createLocalStore({ root });
|
|
2301
|
-
const auth = authorizeDrive(store, { from: flags.from, to: flags.to });
|
|
2302
|
-
if (!auth.ok) {
|
|
2303
|
-
streams.stderr.write(`h2a drive: ${auth.reason} (${flags.from} -> ${flags.to})\n`);
|
|
2304
|
-
return 2;
|
|
2305
|
-
}
|
|
2306
|
-
const instructionLine = formatSignedDriveInstruction({
|
|
2307
|
-
from: flags.from,
|
|
2308
|
-
to: flags.to,
|
|
2309
|
-
instruction: flags.instruction,
|
|
2310
|
-
privateKeyPem,
|
|
2311
|
-
nonce: flags.nonce,
|
|
2312
|
-
at: flags.at
|
|
2313
|
-
});
|
|
2314
|
-
const log = (line) => void streams.stderr.write(`${line}\n`);
|
|
2315
|
-
const driver = buildDriveDriver(kind, log);
|
|
2316
|
-
const result = driver.drive({
|
|
2317
|
-
to: flags.to,
|
|
2318
|
-
host: flags.host ?? flags.to.split(":", 1)[0],
|
|
2319
|
-
instructionLine,
|
|
2320
|
-
launchContext: latestLaunchContext(root, flags.to)
|
|
2321
|
-
});
|
|
2322
|
-
if (typeof result?.then === "function") {
|
|
2323
|
-
streams.stderr.write("h2a drive: async driver unavailable in synchronous CLI path\n");
|
|
2324
|
-
return 1;
|
|
2325
|
-
}
|
|
2326
|
-
const driven = result === true;
|
|
2327
|
-
streams.stdout.write(`${JSON.stringify({
|
|
2328
|
-
ok: true,
|
|
2329
|
-
from: flags.from,
|
|
2330
|
-
to: flags.to,
|
|
2331
|
-
driver: kind,
|
|
2332
|
-
driven,
|
|
2333
|
-
instructionLine
|
|
2334
|
-
}, null, 2)}\n`);
|
|
2335
|
-
return driven ? 0 : 2;
|
|
2336
|
-
}
|
|
2337
|
-
function stdinText(streams) {
|
|
2338
|
-
if (typeof streams.stdinText === "function")
|
|
2339
|
-
return streams.stdinText();
|
|
2340
|
-
return streams.stdinText ?? readFileSync(0, "utf8");
|
|
2341
|
-
}
|
|
2342
|
-
function driveLineFromStdin(raw) {
|
|
2343
|
-
return driveLineFromHookInput(raw) ?? "";
|
|
2344
|
-
}
|
|
2345
|
-
function cmdDriveReceive(flags, streams) {
|
|
2346
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
2347
|
-
const line = flags.line ?? (flags.stdin ? driveLineFromStdin(stdinText(streams) ?? "") : undefined);
|
|
2348
|
-
if (!flags.to) {
|
|
2349
|
-
streams.stderr.write("h2a drive receive: --to is required\n");
|
|
2350
|
-
return 1;
|
|
2351
|
-
}
|
|
2352
|
-
if (!line) {
|
|
2353
|
-
if (flags["ignore-non-drive"]) {
|
|
2354
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, ignored: true, reason: "non-drive" }, null, 2)}\n`);
|
|
2355
|
-
return 0;
|
|
2356
|
-
}
|
|
2357
|
-
streams.stderr.write("h2a drive receive: --line or --stdin is required\n");
|
|
2358
|
-
return 1;
|
|
2359
|
-
}
|
|
2360
|
-
if (flags["ignore-non-drive"] && !line.trim().startsWith("[h2a ")) {
|
|
2361
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, ignored: true, reason: "non-drive" }, null, 2)}\n`);
|
|
2362
|
-
return 0;
|
|
2363
|
-
}
|
|
2364
|
-
const root = resolveRoot(flags, cwd);
|
|
2365
|
-
const store = createLocalStore({ root });
|
|
2366
|
-
const now = flags.now ? Number.parseInt(flags.now, 10) : undefined;
|
|
2367
|
-
if (flags.now && Number.isNaN(now)) {
|
|
2368
|
-
streams.stderr.write(`h2a drive receive: --now must be a millisecond timestamp (got "${flags.now}")\n`);
|
|
2369
|
-
return 1;
|
|
2370
|
-
}
|
|
2371
|
-
let result;
|
|
2372
|
-
try {
|
|
2373
|
-
result = verifyDriveOnReceive(store, line, {
|
|
2374
|
-
to: flags.to,
|
|
2375
|
-
guard: driveReplayGuard(root),
|
|
2376
|
-
...(now !== undefined ? { now } : {})
|
|
2377
|
-
});
|
|
2378
|
-
}
|
|
2379
|
-
catch (error) {
|
|
2380
|
-
streams.stderr.write(`h2a drive receive: ${error.message}\n`);
|
|
2381
|
-
return 3;
|
|
2382
|
-
}
|
|
2383
|
-
if (!result.ok) {
|
|
2384
|
-
streams.stderr.write(`h2a drive receive: ${result.reason}\n`);
|
|
2385
|
-
return 2;
|
|
2386
|
-
}
|
|
2387
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
2388
|
-
return 0;
|
|
2389
|
-
}
|
|
2390
|
-
export async function runDrumbeatRelanceInbox(flags, io = {
|
|
2391
|
-
stdout: process.stdout,
|
|
2392
|
-
stderr: process.stderr
|
|
2393
|
-
}) {
|
|
2394
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
2395
|
-
const root = resolveRoot(flags, cwd);
|
|
2396
|
-
const kind = (flags.relauncher ?? "auto");
|
|
2397
|
-
if (!["logging", "local-tmux", "headless", "auto"].includes(kind)) {
|
|
2398
|
-
io.stderr.write(`h2a drumbeat relance-inbox: --relauncher must be one of logging|local-tmux|headless|auto (got "${flags.relauncher}")\n`);
|
|
2399
|
-
return 1;
|
|
2400
|
-
}
|
|
2401
|
-
const log = (line) => void io.stdout.write(`${line}\n`);
|
|
2402
|
-
const result = await relanceFromInbox(root, {
|
|
2403
|
-
...(flags.instance ? { instances: [flags.instance] } : {}),
|
|
2404
|
-
relauncher: buildLocalInboxRelauncher(kind, log)
|
|
2405
|
-
});
|
|
2406
|
-
io.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`);
|
|
2407
|
-
return 0;
|
|
2408
|
-
}
|
|
2409
|
-
/**
|
|
2410
|
-
* `h2a drumbeat watch` (DEC-086): long-running anti-stall daemon. Async +
|
|
2411
|
-
* blocking, dispatched from bin.ts like mcp-serve. Uses the logging relauncher
|
|
2412
|
-
* by default; concrete relaunchers (local-tmux / remote) land in D3/D4.
|
|
2413
|
-
*/
|
|
2414
|
-
export async function runDrumbeatWatch(flags, io = {
|
|
2415
|
-
stdout: process.stdout,
|
|
2416
|
-
stderr: process.stderr
|
|
2417
|
-
}) {
|
|
2418
|
-
const cwd = io.cwd ?? (() => process.cwd());
|
|
2419
|
-
const root = resolveRoot(flags, cwd);
|
|
2420
|
-
const intervalMs = flags["interval-ms"] ? Number.parseInt(flags["interval-ms"], 10) : 30_000;
|
|
2421
|
-
const maxRelances = flags["max-relances"] ? Number.parseInt(flags["max-relances"], 10) : undefined;
|
|
2422
|
-
if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
|
|
2423
|
-
io.stderr.write(`h2a drumbeat watch: --interval-ms must be >= 1000 (got "${flags["interval-ms"]}")\n`);
|
|
2424
|
-
return 1;
|
|
2425
|
-
}
|
|
2426
|
-
const kind = (flags.relauncher ?? "logging");
|
|
2427
|
-
const log = (line) => void io.stdout.write(`${line}\n`);
|
|
2428
|
-
let relauncher;
|
|
2429
|
-
let remoteActor;
|
|
2430
|
-
let remotePrivateKeyPem;
|
|
2431
|
-
if (kind === "remote" || kind === "auto") {
|
|
2432
|
-
const actor = resolveRemoteActor(root, flags);
|
|
2433
|
-
if (typeof actor === "string") {
|
|
2434
|
-
io.stderr.write(`h2a drumbeat watch: ${actor}\n`);
|
|
2435
|
-
return 1;
|
|
2436
|
-
}
|
|
2437
|
-
const pem = readPrivateKeyFlag("h2a drumbeat watch", flags);
|
|
2438
|
-
if (pem instanceof Error) {
|
|
2439
|
-
io.stderr.write(`${pem.message}\n`);
|
|
2440
|
-
return 1;
|
|
2441
|
-
}
|
|
2442
|
-
remoteActor = actor;
|
|
2443
|
-
remotePrivateKeyPem = pem;
|
|
2444
|
-
}
|
|
2445
|
-
// DEC-091/117: select the relauncher adapter. `auto` = local-tmux, then the
|
|
2446
|
-
// D4 remote relay, then headless fallback; `logging` stays the dry-run default.
|
|
2447
|
-
switch (kind) {
|
|
2448
|
-
case "local-tmux":
|
|
2449
|
-
relauncher = localTmuxRelauncher({ log });
|
|
2450
|
-
break;
|
|
2451
|
-
case "headless":
|
|
2452
|
-
relauncher = headlessRelauncher({ log });
|
|
2453
|
-
break;
|
|
2454
|
-
case "remote":
|
|
2455
|
-
relauncher = remoteRelauncher({
|
|
2456
|
-
root,
|
|
2457
|
-
actor: remoteActor,
|
|
2458
|
-
privateKeyPem: remotePrivateKeyPem,
|
|
2459
|
-
log
|
|
2460
|
-
});
|
|
2461
|
-
break;
|
|
2462
|
-
case "auto":
|
|
2463
|
-
relauncher = chainRelauncher(localTmuxRelauncher({ log }), remoteRelauncher({
|
|
2464
|
-
root,
|
|
2465
|
-
actor: remoteActor,
|
|
2466
|
-
privateKeyPem: remotePrivateKeyPem,
|
|
2467
|
-
log
|
|
2468
|
-
}), headlessRelauncher({ log }));
|
|
2469
|
-
break;
|
|
2470
|
-
case "logging":
|
|
2471
|
-
relauncher = loggingRelauncher(log);
|
|
2472
|
-
break;
|
|
2473
|
-
default:
|
|
2474
|
-
io.stderr.write(`h2a drumbeat watch: --relauncher must be one of logging|local-tmux|remote|headless|auto (got "${flags.relauncher}")\n`);
|
|
2475
|
-
return 1;
|
|
2476
|
-
}
|
|
2477
|
-
// DEC-111 (D5): the reflexive watchdog. Opt-in via --decider; consulted only
|
|
2478
|
-
// after --decider-after relances; decisions applied only with --decider-enforce.
|
|
2479
|
-
const deciderAfter = flags["decider-after"] ? Number.parseInt(flags["decider-after"], 10) : 1;
|
|
2480
|
-
if (!Number.isInteger(deciderAfter) || deciderAfter < 1) {
|
|
2481
|
-
io.stderr.write(`h2a drumbeat watch: --decider-after must be a positive integer (got "${flags["decider-after"]}")\n`);
|
|
2482
|
-
return 1;
|
|
2483
|
-
}
|
|
2484
|
-
const enforce = flags["decider-enforce"] !== undefined;
|
|
2485
|
-
let decider;
|
|
2486
|
-
let deciderLabel = "logging";
|
|
2487
|
-
if (flags.decider !== undefined && flags.decider !== "logging") {
|
|
2488
|
-
decider = subagentDecider({ command: flags.decider });
|
|
2489
|
-
deciderLabel = "subagent";
|
|
2490
|
-
}
|
|
2491
|
-
else if (flags.decider === "logging") {
|
|
2492
|
-
decider = loggingDecider();
|
|
2493
|
-
}
|
|
2494
|
-
const effectiveMax = maxRelances ?? H2A_DEFAULT_MAX_RELANCES;
|
|
2495
|
-
if (decider && deciderAfter >= effectiveMax) {
|
|
2496
|
-
io.stderr.write(`h2a drumbeat watch: --decider-after (${deciderAfter}) must be < --max-relances (${effectiveMax})\n`);
|
|
2497
|
-
return 1;
|
|
2498
|
-
}
|
|
2499
|
-
io.stdout.write(`h2a drumbeat watch: watching ${root} every ${intervalMs}ms (relauncher=${kind}, decider=${deciderLabel}${decider ? `, enforce=${enforce}` : ""})\n`);
|
|
2500
|
-
await runDrumbeatWatchLoop(root, relauncher, {
|
|
2501
|
-
intervalMs,
|
|
2502
|
-
...(maxRelances !== undefined ? { maxRelances } : {}),
|
|
2503
|
-
signal: io.signal,
|
|
2504
|
-
// Gov D2/D4: pass the drumbeat runner's own identity so conductor-ownership
|
|
2505
|
-
// and cross-workspace CoI checks are active. `--instance` is required for
|
|
2506
|
-
// the remote relauncher (the signer); when present it is also the self-id
|
|
2507
|
-
// for governance. Without it the checks are silently skipped (default-allow).
|
|
2508
|
-
root,
|
|
2509
|
-
...(flags.instance !== undefined ? { selfInstance: flags.instance } : {}),
|
|
2510
|
-
log,
|
|
2511
|
-
beforeScan: async () => {
|
|
2512
|
-
const result = await relanceFromInbox(root, {
|
|
2513
|
-
relauncher: buildLocalInboxRelauncher(kind, log)
|
|
2514
|
-
});
|
|
2515
|
-
if (result.relanced.length || result.skipped.length) {
|
|
2516
|
-
io.stdout.write(`drumbeat inbox: relanced=[${result.relanced.join(",")}] skipped=${result.skipped.length}\n`);
|
|
2517
|
-
}
|
|
2518
|
-
return result.relanced;
|
|
2519
|
-
},
|
|
2520
|
-
...(decider ? { decider, deciderAfter, enforce, deciderLabel } : {}),
|
|
2521
|
-
// DEC-095 (D7): an exhausted relance budget stops the loop for that agent
|
|
2522
|
-
// and escalates to the PRINCIPAL (the anti-loop cap is `maxRelances`).
|
|
2523
|
-
onExhausted: (entry) => {
|
|
2524
|
-
const record = recordEscalation(root, {
|
|
2525
|
-
instance: entry.instance,
|
|
2526
|
-
reason: "relance-exhausted",
|
|
2527
|
-
relanceCount: entry.relanceCount
|
|
2528
|
-
});
|
|
2529
|
-
io.stdout.write(`drumbeat: ESCALATE ${record.instance} → ${record.to} (${record.channel}, relances=${record.relanceCount})\n`);
|
|
2530
|
-
},
|
|
2531
|
-
// DEC-111 (D5): the decider's escalate/reroute verdicts route through the
|
|
2532
|
-
// same escalation registry (reroute = escalate-with-hint in v1).
|
|
2533
|
-
onEscalate: (finding, decision) => {
|
|
2534
|
-
recordEscalation(root, {
|
|
2535
|
-
instance: finding.instance,
|
|
2536
|
-
reason: "watchdog-escalate",
|
|
2537
|
-
relanceCount: finding.relanceCount
|
|
2538
|
-
});
|
|
2539
|
-
io.stdout.write(`drumbeat: WATCHDOG-ESCALATE ${finding.instance}${decision.reason ? ` — ${decision.reason}` : ""}\n`);
|
|
2540
|
-
},
|
|
2541
|
-
onReroute: (finding, decision) => {
|
|
2542
|
-
recordEscalation(root, {
|
|
2543
|
-
instance: finding.instance,
|
|
2544
|
-
reason: "reroute-suggested",
|
|
2545
|
-
relanceCount: finding.relanceCount
|
|
2546
|
-
});
|
|
2547
|
-
io.stdout.write(`drumbeat: REROUTE-SUGGESTED ${finding.instance}${decision.reason ? ` — ${decision.reason}` : ""}\n`);
|
|
2548
|
-
},
|
|
2549
|
-
onTick: (r) => {
|
|
2550
|
-
if (r.relanced.length || r.exhausted.length) {
|
|
2551
|
-
io.stdout.write(`drumbeat tick: relanced=[${r.relanced.join(",")}] exhausted=[${r.exhausted.join(",")}]\n`);
|
|
2552
|
-
}
|
|
2553
|
-
}
|
|
2554
|
-
});
|
|
2555
|
-
return 0;
|
|
2556
|
-
}
|
|
2557
|
-
/**
|
|
2558
|
-
* `h2a sysml verify` (DEC-099, S3): verify an envelope's embedded SysML ref —
|
|
2559
|
-
* commit-trust (signature) by default; add `--content-integrity` to re-fetch +
|
|
2560
|
-
* re-hash the element (network). Async → dispatched from bin.ts like remote.
|
|
2561
|
-
*/
|
|
2562
|
-
export async function runSysmlVerify(flags, io = {
|
|
2563
|
-
stdout: process.stdout,
|
|
2564
|
-
stderr: process.stderr
|
|
2565
|
-
}) {
|
|
2566
|
-
if (!flags.json || !flags["public-key"]) {
|
|
2567
|
-
io.stderr.write("h2a sysml verify: --json <envelope> and --public-key <pem-file> are required\n");
|
|
2568
|
-
return 1;
|
|
2569
|
-
}
|
|
2570
|
-
let envelope;
|
|
2571
|
-
try {
|
|
2572
|
-
envelope = JSON.parse(flags.json);
|
|
2573
|
-
}
|
|
2574
|
-
catch (error) {
|
|
2575
|
-
io.stderr.write(`h2a sysml verify: --json is not valid JSON (${error.message})\n`);
|
|
2576
|
-
return 1;
|
|
2577
|
-
}
|
|
2578
|
-
let publicKeyPem;
|
|
2579
|
-
try {
|
|
2580
|
-
publicKeyPem = readFileSync(flags["public-key"], "utf8");
|
|
2581
|
-
}
|
|
2582
|
-
catch (error) {
|
|
2583
|
-
io.stderr.write(`h2a sysml verify: cannot read --public-key (${error.message})\n`);
|
|
2584
|
-
return 1;
|
|
2585
|
-
}
|
|
2586
|
-
const result = await verifyEnvelopeSysmlRef(envelope, {
|
|
2587
|
-
publicKeyPem,
|
|
2588
|
-
...(flags.by ? { by: flags.by } : {}),
|
|
2589
|
-
...(flags["content-integrity"] ? { contentIntegrity: true } : {}),
|
|
2590
|
-
...(flags["api-base"] ? { apiBase: flags["api-base"] } : {}),
|
|
2591
|
-
...(flags.auth ? { auth: flags.auth } : {})
|
|
2592
|
-
});
|
|
2593
|
-
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
2594
|
-
// Verification failure is a state/business outcome → exit 2 (DEC-034).
|
|
2595
|
-
return result.ok ? 0 : 2;
|
|
2596
|
-
}
|
|
2597
|
-
function isPlainObject(value) {
|
|
2598
|
-
return (typeof value === "object" &&
|
|
2599
|
-
value !== null &&
|
|
2600
|
-
!Array.isArray(value));
|
|
2601
|
-
}
|
|
2602
|
-
function configsEqual(a, b) {
|
|
2603
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
2604
|
-
}
|
|
2605
|
-
function driveLineFromText(text) {
|
|
2606
|
-
const matchingLine = text
|
|
2607
|
-
.split(/\r?\n/)
|
|
2608
|
-
.map((line) => line.trim())
|
|
2609
|
-
.find((line) => line.startsWith("[h2a "));
|
|
2610
|
-
return matchingLine ?? (text.trim() ? text.trim() : undefined);
|
|
2611
|
-
}
|
|
2612
|
-
function hookPromptText(value) {
|
|
2613
|
-
if (typeof value === "string")
|
|
2614
|
-
return value;
|
|
2615
|
-
if (Array.isArray(value)) {
|
|
2616
|
-
for (const item of value) {
|
|
2617
|
-
const text = hookPromptText(item);
|
|
2618
|
-
if (text)
|
|
2619
|
-
return text;
|
|
2620
|
-
}
|
|
2621
|
-
return undefined;
|
|
2622
|
-
}
|
|
2623
|
-
if (!isPlainObject(value))
|
|
2624
|
-
return undefined;
|
|
2625
|
-
for (const key of ["prompt", "line", "message", "text", "input"]) {
|
|
2626
|
-
const text = hookPromptText(value[key]);
|
|
2627
|
-
if (text)
|
|
2628
|
-
return text;
|
|
2629
|
-
}
|
|
2630
|
-
if (isPlainObject(value.params)) {
|
|
2631
|
-
const text = hookPromptText(value.params);
|
|
2632
|
-
if (text)
|
|
2633
|
-
return text;
|
|
2634
|
-
}
|
|
2635
|
-
return undefined;
|
|
2636
|
-
}
|
|
2637
|
-
function driveLineFromHookInput(raw) {
|
|
2638
|
-
try {
|
|
2639
|
-
const parsed = JSON.parse(raw);
|
|
2640
|
-
const promptText = hookPromptText(parsed);
|
|
2641
|
-
if (promptText)
|
|
2642
|
-
return driveLineFromText(promptText);
|
|
2643
|
-
}
|
|
2644
|
-
catch {
|
|
2645
|
-
// Non-JSON hook payloads may already be the prompt text.
|
|
2646
|
-
}
|
|
2647
|
-
return driveLineFromText(raw);
|
|
2648
|
-
}
|
|
2649
|
-
function cmdHostSetup(flags, streams) {
|
|
2650
|
-
const host = flags.host;
|
|
2651
|
-
if (!host) {
|
|
2652
|
-
streams.stderr.write("h2a host setup: --host <codex|claude|gemini|agy> is required\n");
|
|
2653
|
-
return 1;
|
|
2654
|
-
}
|
|
2655
|
-
// Coordination-by-default (EVO-1 #3): the rendered config joins the bus
|
|
2656
|
-
// (--auto-open), stays current (--auto-upgrade), and is WAKEABLE on inbox
|
|
2657
|
-
// arrival (--wake local-tmux: inject the wake into the agent's tmux pane,
|
|
2658
|
-
// which mcp-serve self-detects). Wake is an essential coordination element,
|
|
2659
|
-
// so it is ON by default; opt out with --no-wake (a warning is printed).
|
|
2660
|
-
// local-tmux (NOT auto) is deliberate: in a tmux pane it wakes; OUTSIDE tmux
|
|
2661
|
-
// it cleanly no-ops. `auto` would fall through to the headless driver and
|
|
2662
|
-
// SPAWN a new agent when no pane is found — wrong for a self-wake.
|
|
2663
|
-
const wakeEnabled = flags["no-wake"] !== "true";
|
|
2664
|
-
const serveArgs = [
|
|
2665
|
-
"mcp-serve",
|
|
2666
|
-
"--auto-open",
|
|
2667
|
-
"--host",
|
|
2668
|
-
host,
|
|
2669
|
-
"--auto-upgrade",
|
|
2670
|
-
...(wakeEnabled ? ["--wake", "local-tmux"] : [])
|
|
2671
|
-
];
|
|
2672
|
-
if (!wakeEnabled) {
|
|
2673
|
-
streams.stderr.write("h2a host setup: WARNING --no-wake — this agent will NOT be woken on inbox arrival; coordination is degraded (peers' messages wait until you manually run /h2a receive).\n");
|
|
2674
|
-
}
|
|
2675
|
-
const renderOpts = { ...(flags.root ? { root: flags.root } : {}), args: serveArgs };
|
|
2676
|
-
let snippet;
|
|
2677
|
-
if (host === "codex") {
|
|
2678
|
-
snippet = H2A_CODEX_HOST.renderMcpConfig(renderOpts);
|
|
2679
|
-
}
|
|
2680
|
-
else if (host === "claude") {
|
|
2681
|
-
snippet = H2A_CLAUDE_HOST.renderMcpConfig(renderOpts);
|
|
2682
|
-
}
|
|
2683
|
-
else if (host === "gemini") {
|
|
2684
|
-
snippet = H2A_GEMINI_HOST.renderMcpConfig(renderOpts);
|
|
2685
|
-
}
|
|
2686
|
-
else if (host === "agy") {
|
|
2687
|
-
snippet = H2A_AGY_HOST.renderMcpConfig(renderOpts);
|
|
2688
|
-
}
|
|
2689
|
-
else {
|
|
2690
|
-
streams.stderr.write(`h2a host setup: unknown --host "${host}". Supported: codex, claude, gemini, agy.\n`);
|
|
2691
|
-
return 1;
|
|
2692
|
-
}
|
|
2693
|
-
const targetPath = flags.write;
|
|
2694
|
-
const printMode = flags.print === "true" || !targetPath;
|
|
2695
|
-
if (printMode && !targetPath) {
|
|
2696
|
-
streams.stdout.write(`${JSON.stringify(snippet.config, null, 2)}\n`);
|
|
2697
|
-
streams.stderr.write(`# ${host} — paste this snippet under \`mcpServers\` in:\n# ${snippet.path.hint}\n# example path: ${snippet.path.example}\n`);
|
|
2698
|
-
return 0;
|
|
2699
|
-
}
|
|
2700
|
-
// --write path: merge into the target file.
|
|
2701
|
-
let existing = {};
|
|
2702
|
-
if (existsSync(targetPath)) {
|
|
2703
|
-
let raw;
|
|
2704
|
-
try {
|
|
2705
|
-
raw = readFileSync(targetPath, "utf8");
|
|
2706
|
-
}
|
|
2707
|
-
catch (error) {
|
|
2708
|
-
streams.stderr.write(`h2a host setup: cannot read ${targetPath} (${error.message})\n`);
|
|
2709
|
-
// File/OS error — exit code 3 per DEC-034.
|
|
2710
|
-
return 3;
|
|
2711
|
-
}
|
|
2712
|
-
if (raw.trim().length > 0) {
|
|
2713
|
-
try {
|
|
2714
|
-
const parsed = JSON.parse(raw);
|
|
2715
|
-
if (!isPlainObject(parsed)) {
|
|
2716
|
-
streams.stderr.write(`h2a host setup: ${targetPath} is valid JSON but not a JSON object; refusing to merge.\n`);
|
|
2717
|
-
// Pre-existing on-disk state we refuse to overwrite — state conflict.
|
|
2718
|
-
return 2;
|
|
2719
|
-
}
|
|
2720
|
-
existing = parsed;
|
|
2721
|
-
}
|
|
2722
|
-
catch (error) {
|
|
2723
|
-
streams.stderr.write(`h2a host setup: ${targetPath} is not valid JSON (${error.message}). Use --force to overwrite intentionally.\n`);
|
|
2724
|
-
if (flags.force !== "true") {
|
|
2725
|
-
// Pre-existing malformed file refused without --force — state conflict.
|
|
2726
|
-
return 2;
|
|
2727
|
-
}
|
|
2728
|
-
existing = {};
|
|
2729
|
-
}
|
|
2730
|
-
}
|
|
2731
|
-
}
|
|
2732
|
-
const existingMcpServers = isPlainObject(existing.mcpServers)
|
|
2733
|
-
? existing.mcpServers
|
|
2734
|
-
: {};
|
|
2735
|
-
const previous = existingMcpServers.h2a;
|
|
2736
|
-
const incoming = snippet.config.mcpServers.h2a;
|
|
2737
|
-
if (previous !== undefined &&
|
|
2738
|
-
!configsEqual(previous, incoming) &&
|
|
2739
|
-
flags.force !== "true") {
|
|
2740
|
-
streams.stderr.write(`h2a host setup: ${targetPath} already has a different mcpServers.h2a entry. Re-run with --force to overwrite.\n`);
|
|
2741
|
-
// Divergent pre-existing entry — state conflict (exit 2).
|
|
2742
|
-
return 2;
|
|
2743
|
-
}
|
|
2744
|
-
const merged = {
|
|
2745
|
-
...existing,
|
|
2746
|
-
mcpServers: {
|
|
2747
|
-
...existingMcpServers,
|
|
2748
|
-
h2a: incoming
|
|
2749
|
-
}
|
|
2750
|
-
};
|
|
2751
|
-
try {
|
|
2752
|
-
const dir = dirname(targetPath);
|
|
2753
|
-
if (dir && !existsSync(dir)) {
|
|
2754
|
-
mkdirSync(dir, { recursive: true });
|
|
2755
|
-
}
|
|
2756
|
-
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
2757
|
-
}
|
|
2758
|
-
catch (error) {
|
|
2759
|
-
streams.stderr.write(`h2a host setup: cannot write ${targetPath} (${error.message})\n`);
|
|
2760
|
-
// File/OS error — exit code 3 per DEC-034.
|
|
2761
|
-
return 3;
|
|
2762
|
-
}
|
|
2763
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, host, path: targetPath, merged: true }, null, 2)}\n`);
|
|
2764
|
-
streams.stderr.write(`# wrote mcpServers.h2a for host=${host} to ${targetPath}\n# ${snippet.path.hint}\n`);
|
|
2765
|
-
return 0;
|
|
2766
|
-
}
|
|
2767
|
-
function cmdHostStatus(flags, streams) {
|
|
2768
|
-
const filter = flags.host;
|
|
2769
|
-
let selection = CLI_HOSTS;
|
|
2770
|
-
if (filter) {
|
|
2771
|
-
const match = CLI_HOSTS.find((h) => h.host === filter);
|
|
2772
|
-
if (!match) {
|
|
2773
|
-
streams.stderr.write(`h2a host status: unknown --host "${filter}". Supported: ${CLI_HOSTS.map((h) => h.host).join(", ")}.\n`);
|
|
2774
|
-
return 1;
|
|
2775
|
-
}
|
|
2776
|
-
selection = [match];
|
|
2777
|
-
}
|
|
2778
|
-
const hosts = selection.map((descriptor) => {
|
|
2779
|
-
const mcpAdapterShipped = true; // both stdio + in-process MCP back every host
|
|
2780
|
-
const hostSetupShipped = typeof descriptor.renderMcpConfig === "function";
|
|
2781
|
-
const hostScenarioShipped = descriptor.hostScenarioShipped === true;
|
|
2782
|
-
const summary = descriptor.wave === 1 && hostSetupShipped && hostScenarioShipped
|
|
2783
|
-
? `wave 1 — host setup + MCP scenario shipped; MCP adapter (stdio + local) wired`
|
|
2784
|
-
: descriptor.wave === 1 && hostSetupShipped
|
|
2785
|
-
? `wave 1 — host setup snippet shipped; MCP adapter (stdio + local) wired`
|
|
2786
|
-
: descriptor.wave === 2
|
|
2787
|
-
? `wave 2 — descriptor only (DEC-028 defers full enablement)`
|
|
2788
|
-
: `wave 1 — MCP adapter wired but no setup snippet`;
|
|
2789
|
-
return {
|
|
2790
|
-
host: descriptor.host,
|
|
2791
|
-
wave: descriptor.wave,
|
|
2792
|
-
mcpAdapterShipped,
|
|
2793
|
-
hostSetupShipped,
|
|
2794
|
-
hostScenarioShipped,
|
|
2795
|
-
summary
|
|
2796
|
-
};
|
|
2797
|
-
});
|
|
2798
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, hosts }, null, 2)}\n`);
|
|
2799
|
-
return 0;
|
|
2800
|
-
}
|
|
2801
|
-
/**
|
|
2802
|
-
* `h2a host plugin` (DEC-093, D6): render the per-host stop-hook command + where
|
|
2803
|
-
* it goes, so a stop is recorded with a launch context the drumbeat/D3 can
|
|
2804
|
-
* relance. Mirrors `host setup` (h2a renders, the host runtime places it).
|
|
2805
|
-
*/
|
|
2806
|
-
function cmdHostPlugin(flags, streams) {
|
|
2807
|
-
if (!flags.host) {
|
|
2808
|
-
streams.stderr.write(`h2a host plugin: --host <${H2A_HOST_PLUGIN_HOSTS.join("|")}> is required\n`);
|
|
2809
|
-
return 1;
|
|
2810
|
-
}
|
|
2811
|
-
if (!flags.instance) {
|
|
2812
|
-
streams.stderr.write("h2a host plugin: --instance <id> is required\n");
|
|
2813
|
-
return 1;
|
|
2814
|
-
}
|
|
2815
|
-
const render = renderStopHook(flags.host, {
|
|
2816
|
-
instance: flags.instance,
|
|
2817
|
-
...(flags.root ? { root: flags.root } : {}),
|
|
2818
|
-
...(flags.status ? { status: flags.status } : {})
|
|
2819
|
-
});
|
|
2820
|
-
if (!render) {
|
|
2821
|
-
streams.stderr.write(`h2a host plugin: unknown --host "${flags.host}". Supported: ${H2A_HOST_PLUGIN_HOSTS.join(", ")}.\n`);
|
|
2822
|
-
return 1;
|
|
2823
|
-
}
|
|
2824
|
-
// DEC-113 (D6 done): --scaffold <dir> writes codex's FULL local marketplace —
|
|
2825
|
-
// verified live, codex installs a plugin only from a marketplace dir, NOT a
|
|
2826
|
-
// bare plugin dir. Layout mirrors codex's own openai-curated marketplace:
|
|
2827
|
-
// <dir>/.agents/plugins/marketplace.json (marketplace manifest)
|
|
2828
|
-
// <dir>/plugins/<name>/.codex-plugin/plugin.json (plugin manifest)
|
|
2829
|
-
// <dir>/plugins/<name>/hooks/hooks.json (Claude-format Stop hook)
|
|
2830
|
-
// then emits the trust step (codex plugin marketplace add → plugin add).
|
|
2831
|
-
// codex-only: the manifests are codex-specific (claude/gemini merge a single
|
|
2832
|
-
// settings.json via --write; agy polls).
|
|
2833
|
-
if (flags.scaffold) {
|
|
2834
|
-
if (flags.host !== "codex") {
|
|
2835
|
-
streams.stderr.write(`h2a host plugin: --scaffold is codex-only (the marketplace/plugin manifests are codex-specific). ` +
|
|
2836
|
-
`For ${flags.host}, use --write <settings.json> (claude/gemini) or the poll path (agy).\n`);
|
|
2837
|
-
return 1;
|
|
2838
|
-
}
|
|
2839
|
-
const marketplaceDir = flags.scaffold;
|
|
2840
|
-
const pluginDir = join(marketplaceDir, "plugins", H2A_CODEX_PLUGIN_NAME);
|
|
2841
|
-
const hooksPath = join(pluginDir, "hooks", "hooks.json");
|
|
2842
|
-
const manifestPath = join(pluginDir, ".codex-plugin", "plugin.json");
|
|
2843
|
-
const marketplacePath = join(marketplaceDir, ".agents", "plugins", "marketplace.json");
|
|
2844
|
-
// The hooks.json is the same idempotent Claude-format merge as --write.
|
|
2845
|
-
const hooksMerge = mergeStopHooksFile(hooksPath, render.record, render.receive, flags.force === "true", streams);
|
|
2846
|
-
if (typeof hooksMerge === "number")
|
|
2847
|
-
return hooksMerge;
|
|
2848
|
-
// Manifests are stable identities — rewriting them is idempotent (same bytes).
|
|
2849
|
-
const writes = [
|
|
2850
|
-
[manifestPath, codexPluginManifest(H2A_CODEX_PLUGIN_NAME)],
|
|
2851
|
-
[marketplacePath, codexMarketplaceManifest(H2A_CODEX_PLUGIN_NAME)]
|
|
2852
|
-
];
|
|
2853
|
-
for (const [path, body] of writes) {
|
|
2854
|
-
try {
|
|
2855
|
-
const d = dirname(path);
|
|
2856
|
-
if (!existsSync(d))
|
|
2857
|
-
mkdirSync(d, { recursive: true });
|
|
2858
|
-
writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`);
|
|
2859
|
-
}
|
|
2860
|
-
catch (error) {
|
|
2861
|
-
streams.stderr.write(`h2a host plugin: cannot write ${path} (${error.message})\n`);
|
|
2862
|
-
return 3;
|
|
2863
|
-
}
|
|
2864
|
-
}
|
|
2865
|
-
streams.stdout.write(`${JSON.stringify({
|
|
2866
|
-
ok: true,
|
|
2867
|
-
host: "codex",
|
|
2868
|
-
scaffolded: marketplaceDir,
|
|
2869
|
-
marketplace: marketplacePath,
|
|
2870
|
-
manifest: manifestPath,
|
|
2871
|
-
hooks: hooksPath,
|
|
2872
|
-
mechanism: render.mechanism,
|
|
2873
|
-
hook: "hooks.Stop + hooks.UserPromptSubmit",
|
|
2874
|
-
// The trust step is surfaced, not silently dropped: codex cannot be
|
|
2875
|
-
// auto-trusted from outside, so run these to load the plugin.
|
|
2876
|
-
trust: codexPluginTrustCommands(marketplaceDir, H2A_CODEX_PLUGIN_NAME)
|
|
2877
|
-
}, null, 2)}\n`);
|
|
2878
|
-
return 0;
|
|
2879
|
-
}
|
|
2880
|
-
// DEC-102/103/104 (D6 slice b): --write installs the Claude-format `hooks.Stop`
|
|
2881
|
-
// entry for the three hosts that accept it — **claude** + **gemini** (single
|
|
2882
|
-
// settings.json; gemini via `gemini hooks migrate --from-claude`) and **codex**
|
|
2883
|
-
// (its plugin `hooks.json` uses the identical Claude-format hooks object;
|
|
2884
|
-
// verified against `~/.codex/.../hooks/hooks.json`). agy is poll-only (no
|
|
2885
|
-
// daemon), so --write is refused for it and the rendered hook + hint surface.
|
|
2886
|
-
if (flags.write) {
|
|
2887
|
-
if (flags.host === "agy") {
|
|
2888
|
-
streams.stderr.write(`h2a host plugin: --write is not available for agy (poll-only, no daemon). ` +
|
|
2889
|
-
`Use the poll path: ${render.poll}\n`);
|
|
2890
|
-
return 1;
|
|
2891
|
-
}
|
|
2892
|
-
const targetPath = flags.write;
|
|
2893
|
-
const merge = mergeStopHooksFile(targetPath, render.record, render.receive, flags.force === "true", streams);
|
|
2894
|
-
if (typeof merge === "number")
|
|
2895
|
-
return merge;
|
|
2896
|
-
streams.stdout.write(`${JSON.stringify({
|
|
2897
|
-
ok: true,
|
|
2898
|
-
host: flags.host,
|
|
2899
|
-
written: targetPath,
|
|
2900
|
-
mechanism: render.mechanism,
|
|
2901
|
-
hook: "hooks.Stop + hooks.UserPromptSubmit",
|
|
2902
|
-
// codex loads hooks via a plugin (manifest → ./hooks/hooks.json); the
|
|
2903
|
-
// file written above is a valid codex hooks.json, but codex still
|
|
2904
|
-
// needs the manifest + trust. `--scaffold <dir>` does the full job;
|
|
2905
|
-
// this hint covers the manual wrap when only the hooks.json is wanted.
|
|
2906
|
-
...(flags.host === "codex"
|
|
2907
|
-
? {
|
|
2908
|
-
pluginHint: "this is a codex hooks.json — run `h2a host plugin --host codex --scaffold <dir>` " +
|
|
2909
|
-
"to also write the .codex-plugin/plugin.json manifest + emit the trust step " +
|
|
2910
|
-
"(codex plugin marketplace add <dir> → codex plugin add <name>@<marketplace>)."
|
|
2911
|
-
}
|
|
2912
|
-
: {})
|
|
2913
|
-
}, null, 2)}\n`);
|
|
2914
|
-
return 0;
|
|
2915
|
-
}
|
|
2916
|
-
streams.stdout.write(`${JSON.stringify(render, null, 2)}\n`);
|
|
2917
|
-
return 0;
|
|
2918
|
-
}
|
|
2919
|
-
/**
|
|
2920
|
-
* Idempotently merge the h2a drumbeat-record `Stop` hook and the drive receive
|
|
2921
|
-
* `UserPromptSubmit` gate into a Claude-format hooks file (claude/gemini
|
|
2922
|
-
* `settings.json` or a codex `hooks.json`). Drops prior h2a hooks, appends the
|
|
2923
|
-
* freshly-rendered ones, and preserves unrelated keys/hooks. Returns an exit
|
|
2924
|
-
* code on failure, or `undefined` on success.
|
|
2925
|
-
* DEC-102/103/104; reused by `--scaffold` (DEC-113).
|
|
2926
|
-
*/
|
|
2927
|
-
function mergeStopHooksFile(targetPath, record, receive, force, streams) {
|
|
2928
|
-
let existing = {};
|
|
2929
|
-
if (existsSync(targetPath)) {
|
|
2930
|
-
let raw;
|
|
2931
|
-
try {
|
|
2932
|
-
raw = readFileSync(targetPath, "utf8");
|
|
2933
|
-
}
|
|
2934
|
-
catch (error) {
|
|
2935
|
-
streams.stderr.write(`h2a host plugin: cannot read ${targetPath} (${error.message})\n`);
|
|
2936
|
-
return 3;
|
|
2937
|
-
}
|
|
2938
|
-
if (raw.trim().length > 0) {
|
|
2939
|
-
try {
|
|
2940
|
-
const parsed = JSON.parse(raw);
|
|
2941
|
-
if (!isPlainObject(parsed)) {
|
|
2942
|
-
streams.stderr.write(`h2a host plugin: ${targetPath} is valid JSON but not an object; refusing to merge.\n`);
|
|
2943
|
-
return 2;
|
|
2944
|
-
}
|
|
2945
|
-
existing = parsed;
|
|
2946
|
-
}
|
|
2947
|
-
catch (error) {
|
|
2948
|
-
streams.stderr.write(`h2a host plugin: ${targetPath} is not valid JSON (${error.message}). Use --force to overwrite.\n`);
|
|
2949
|
-
if (!force)
|
|
2950
|
-
return 2;
|
|
2951
|
-
existing = {};
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
}
|
|
2955
|
-
const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : {};
|
|
2956
|
-
const existingStop = Array.isArray(existingHooks.Stop) ? [...existingHooks.Stop] : [];
|
|
2957
|
-
const existingUserPromptSubmit = Array.isArray(existingHooks.UserPromptSubmit)
|
|
2958
|
-
? [...existingHooks.UserPromptSubmit]
|
|
2959
|
-
: [];
|
|
2960
|
-
// Idempotent: drop any prior h2a drumbeat-record Stop hook, then append ours.
|
|
2961
|
-
const withoutH2A = existingStop.filter((e) => !isH2ARecordHook(e));
|
|
2962
|
-
const mergedStop = [...withoutH2A, claudeStopHookEntry(record)];
|
|
2963
|
-
const withoutDriveReceive = existingUserPromptSubmit.filter((e) => !isH2ADriveReceiveHook(e));
|
|
2964
|
-
const mergedUserPromptSubmit = [
|
|
2965
|
-
...withoutDriveReceive,
|
|
2966
|
-
claudeDriveReceiveHookEntry(receive)
|
|
2967
|
-
];
|
|
2968
|
-
const merged = {
|
|
2969
|
-
...existing,
|
|
2970
|
-
hooks: {
|
|
2971
|
-
...existingHooks,
|
|
2972
|
-
Stop: mergedStop,
|
|
2973
|
-
UserPromptSubmit: mergedUserPromptSubmit
|
|
2974
|
-
}
|
|
2975
|
-
};
|
|
2976
|
-
try {
|
|
2977
|
-
const dir = dirname(targetPath);
|
|
2978
|
-
if (dir && !existsSync(dir))
|
|
2979
|
-
mkdirSync(dir, { recursive: true });
|
|
2980
|
-
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
2981
|
-
}
|
|
2982
|
-
catch (error) {
|
|
2983
|
-
streams.stderr.write(`h2a host plugin: cannot write ${targetPath} (${error.message})\n`);
|
|
2984
|
-
return 3;
|
|
2985
|
-
}
|
|
2986
|
-
return undefined;
|
|
2987
|
-
}
|
|
2988
|
-
function cmdHost(argv, streams) {
|
|
2989
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
2990
|
-
if (sub === "setup")
|
|
2991
|
-
return cmdHostSetup(flags, streams);
|
|
2992
|
-
if (sub === "status")
|
|
2993
|
-
return cmdHostStatus(flags, streams);
|
|
2994
|
-
if (sub === "plugin")
|
|
2995
|
-
return cmdHostPlugin(flags, streams);
|
|
2996
|
-
streams.stderr.write(`Unknown host subcommand: ${sub ?? "<none>"}\n`);
|
|
2997
|
-
streams.stderr.write("Use: h2a host setup --host <codex|claude|gemini|agy> ...\n" +
|
|
2998
|
-
" h2a host status [--host <name>]\n" +
|
|
2999
|
-
" h2a host plugin --host <codex|claude|gemini|agy> --instance <id>\n");
|
|
3000
|
-
return 1;
|
|
3001
|
-
}
|
|
3002
|
-
function cmdStoreMigrate(flags, streams) {
|
|
3003
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3004
|
-
const root = resolveRoot(flags, cwd);
|
|
3005
|
-
const from = flags.from ?? H2A_STORE_SCHEMA_VERSION;
|
|
3006
|
-
const to = flags.to ?? H2A_STORE_SCHEMA_VERSION;
|
|
3007
|
-
const dryRun = flags["dry-run"] === "true";
|
|
3008
|
-
// DEC-064: opt-in within-version maintenance pass. Renames pre-DEC-062
|
|
3009
|
-
// store entries with `:` (or other forbidden chars) in their names to the
|
|
3010
|
-
// safePathSegment form, so a store created by <=0.1.23 works on Windows and
|
|
3011
|
-
// is consistent on every OS. Does not touch the schema version.
|
|
3012
|
-
if (flags["sanitize-paths"] === "true") {
|
|
3013
|
-
const result = sanitizeStorePaths(root, { dryRun });
|
|
3014
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
3015
|
-
// Conflicts (target already exists) are a state issue → exit 2.
|
|
3016
|
-
return result.ok ? 0 : 2;
|
|
3017
|
-
}
|
|
3018
|
-
const KNOWN_VERSIONS = [H2A_STORE_SCHEMA_VERSION];
|
|
3019
|
-
if (!KNOWN_VERSIONS.includes(from)) {
|
|
3020
|
-
streams.stderr.write(`h2a store migrate: unknown --from version "${from}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
|
|
3021
|
-
return 1;
|
|
3022
|
-
}
|
|
3023
|
-
if (!KNOWN_VERSIONS.includes(to)) {
|
|
3024
|
-
streams.stderr.write(`h2a store migrate: unknown --to version "${to}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
|
|
3025
|
-
return 1;
|
|
3026
|
-
}
|
|
3027
|
-
// V1 → V1: no-op. Future bumps will branch here.
|
|
3028
|
-
if (from === H2A_STORE_SCHEMA_VERSION && to === H2A_STORE_SCHEMA_VERSION) {
|
|
3029
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3030
|
-
ok: true,
|
|
3031
|
-
fromVersion: from,
|
|
3032
|
-
toVersion: to,
|
|
3033
|
-
changed: false,
|
|
3034
|
-
dryRun,
|
|
3035
|
-
root
|
|
3036
|
-
}, null, 2)}\n`);
|
|
3037
|
-
return 0;
|
|
3038
|
-
}
|
|
3039
|
-
// Unreachable today (only one known version) — kept for future ramps.
|
|
3040
|
-
streams.stderr.write(`h2a store migrate: no migration registered for ${from} → ${to}\n`);
|
|
3041
|
-
return 1;
|
|
3042
|
-
}
|
|
3043
|
-
function cmdStore(argv, streams) {
|
|
3044
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
3045
|
-
if (sub === "migrate")
|
|
3046
|
-
return cmdStoreMigrate(flags, streams);
|
|
3047
|
-
streams.stderr.write(`Unknown store subcommand: ${sub ?? "<none>"}\n`);
|
|
3048
|
-
streams.stderr.write("Use: h2a store migrate [--from <v>] [--to <v>] [--dry-run] [--root <path>]\n");
|
|
3049
|
-
return 1;
|
|
3050
|
-
}
|
|
3051
|
-
function cmdDiscover(flags, streams) {
|
|
3052
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3053
|
-
const root = resolveRoot(flags, cwd);
|
|
3054
|
-
const store = createLocalStore({ root });
|
|
3055
|
-
let entries = store.listInstances();
|
|
3056
|
-
// DEC-110: filter by the EFFECTIVE org view (registration scopes/roles ∪
|
|
3057
|
-
// provisioned membership grants), so a `provision`-granted scope actually
|
|
3058
|
-
// gates discovery at runtime. Output stays the raw registration (grants only
|
|
3059
|
-
// widen the match set, never narrow it).
|
|
3060
|
-
const effective = effectiveOrgInstances(entries, store.listOrgMembership());
|
|
3061
|
-
const effByInstance = new Map(effective.map((e) => [e.instance, e]));
|
|
3062
|
-
if (flags.role) {
|
|
3063
|
-
const role = flags.role;
|
|
3064
|
-
entries = entries.filter((entry) => effByInstance.get(entry.instance)?.roles.includes(role));
|
|
3065
|
-
}
|
|
3066
|
-
if (flags.scope) {
|
|
3067
|
-
const scope = flags.scope;
|
|
3068
|
-
entries = entries.filter((entry) => effByInstance.get(entry.instance)?.scopes.includes(scope));
|
|
3069
|
-
}
|
|
3070
|
-
// `--live` (WP-C/WP-F): the answer to "which of these can I actually reach
|
|
3071
|
-
// RIGHT NOW?" — the full registry is the perennial ledger (every past
|
|
3072
|
-
// conversation mints an id, so it grows to hundreds and drowns the live ones).
|
|
3073
|
-
// Filter to instances with a fresh presence session and annotate each with
|
|
3074
|
-
// its connection-confidence (active = MCP channel carried traffic recently;
|
|
3075
|
-
// idle-uncertain = process alive but channel silent — possibly a false-live;
|
|
3076
|
-
// unknown = legacy/mirrored presence with no activity stamp). Compact output
|
|
3077
|
-
// (no public keys) so the reachable id is obvious. Heartbeat-fresh ≠ reachable.
|
|
3078
|
-
if (flags.live !== undefined) {
|
|
3079
|
-
const now = Date.now();
|
|
3080
|
-
// PRESENCE-FIRST: liveness lives in the presence files, not the registry.
|
|
3081
|
-
// An agent can hold a fresh presence on this bus without a registration here
|
|
3082
|
-
// (it registered on a forked repo-local bus — the split-brain that strands
|
|
3083
|
-
// mail). Enumerate live sessions and annotate from the registry where known,
|
|
3084
|
-
// so a presence-but-unregistered peer still surfaces as reachable.
|
|
3085
|
-
const sessions = listPresence(root);
|
|
3086
|
-
const regByInstance = new Map(entries.map((e) => [e.instance, e]));
|
|
3087
|
-
const byInstance = new Map();
|
|
3088
|
-
for (const s of sessions) {
|
|
3089
|
-
const arr = byInstance.get(s.instance) ?? [];
|
|
3090
|
-
arr.push(s);
|
|
3091
|
-
byInstance.set(s.instance, arr);
|
|
3092
|
-
}
|
|
3093
|
-
const rank = { active: 0, "idle-uncertain": 1, unknown: 2 };
|
|
3094
|
-
const live = Array.from(byInstance.entries())
|
|
3095
|
-
.map(([instance, ss]) => {
|
|
3096
|
-
const reg = regByInstance.get(instance);
|
|
3097
|
-
const eff = effByInstance.get(instance);
|
|
3098
|
-
// Best (most-reachable) confidence across this instance's sessions.
|
|
3099
|
-
let best = "unknown";
|
|
3100
|
-
let freshestActivity;
|
|
3101
|
-
let name = reg?.name;
|
|
3102
|
-
for (const s of ss) {
|
|
3103
|
-
const c = deriveConnectionConfidence(s, { now });
|
|
3104
|
-
if (rank[c] < rank[best])
|
|
3105
|
-
best = c;
|
|
3106
|
-
const a = s.lastMcpActivityAt;
|
|
3107
|
-
if (a && (!freshestActivity || a > freshestActivity))
|
|
3108
|
-
freshestActivity = a;
|
|
3109
|
-
const sn = s.name;
|
|
3110
|
-
if (!name && sn)
|
|
3111
|
-
name = sn;
|
|
3112
|
-
}
|
|
3113
|
-
return {
|
|
3114
|
-
instance,
|
|
3115
|
-
...(name !== undefined ? { name } : {}),
|
|
3116
|
-
...(reg?.workspace !== undefined ? { workspace: reg.workspace } : {}),
|
|
3117
|
-
registered: reg !== undefined,
|
|
3118
|
-
roles: eff?.roles ?? reg?.roles ?? [],
|
|
3119
|
-
scopes: eff?.scopes ?? reg?.scopes ?? [],
|
|
3120
|
-
sessions: ss.length,
|
|
3121
|
-
connectionConfidence: best,
|
|
3122
|
-
...(freshestActivity ? { lastMcpActivityAt: freshestActivity } : {})
|
|
3123
|
-
};
|
|
3124
|
-
})
|
|
3125
|
-
.filter((r) => {
|
|
3126
|
-
// honor --role / --scope filters against the annotated values
|
|
3127
|
-
if (flags.role && !r.roles.includes(flags.role))
|
|
3128
|
-
return false;
|
|
3129
|
-
if (flags.scope && !r.scopes.includes(flags.scope))
|
|
3130
|
-
return false;
|
|
3131
|
-
return true;
|
|
3132
|
-
})
|
|
3133
|
-
// Most-reachable first, so the addressable agent is at the top.
|
|
3134
|
-
.sort((a, b) => rank[a.connectionConfidence] - rank[b.connectionConfidence]);
|
|
3135
|
-
streams.stdout.write(`${JSON.stringify(live, null, 2)}\n`);
|
|
3136
|
-
return 0;
|
|
3137
|
-
}
|
|
3138
|
-
streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
|
|
3139
|
-
return 0;
|
|
3140
|
-
}
|
|
3141
|
-
/**
|
|
3142
|
-
* `h2a thread --id <threadId> --instance <self> [--root <path>]`
|
|
3143
|
-
*
|
|
3144
|
-
* Derived read-only view: union of inbox and outbox for the given instance,
|
|
3145
|
-
* filtered to `envelope.threadId === id`, deduped by envelope id, sorted
|
|
3146
|
-
* ascending by `createdAt`. No new store — pure in-memory derivation.
|
|
3147
|
-
*/
|
|
3148
|
-
function cmdThread(flags, streams) {
|
|
3149
|
-
if (!flags.id || !flags.instance) {
|
|
3150
|
-
streams.stderr.write("h2a thread: --id <threadId> and --instance <self> are required\n");
|
|
3151
|
-
return 1;
|
|
3152
|
-
}
|
|
3153
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3154
|
-
const root = resolveRoot(flags, cwd);
|
|
3155
|
-
let store;
|
|
3156
|
-
try {
|
|
3157
|
-
store = createLocalStore({ root });
|
|
3158
|
-
}
|
|
3159
|
-
catch (error) {
|
|
3160
|
-
streams.stderr.write(`h2a thread: ${error.message}\n`);
|
|
3161
|
-
return 3;
|
|
3162
|
-
}
|
|
3163
|
-
let inboxEnvelopes;
|
|
3164
|
-
let outboxEnvelopes;
|
|
3165
|
-
try {
|
|
3166
|
-
inboxEnvelopes = store.readInbox(flags.instance);
|
|
3167
|
-
outboxEnvelopes = store.readOutbox(flags.instance);
|
|
3168
|
-
}
|
|
3169
|
-
catch (error) {
|
|
3170
|
-
streams.stderr.write(`h2a thread: ${error.message}\n`);
|
|
3171
|
-
return 3;
|
|
3172
|
-
}
|
|
3173
|
-
const threadId = flags.id;
|
|
3174
|
-
const seen = new Set();
|
|
3175
|
-
const thread = [];
|
|
3176
|
-
for (const env of [...inboxEnvelopes, ...outboxEnvelopes]) {
|
|
3177
|
-
if (env.threadId === threadId && !seen.has(env.id)) {
|
|
3178
|
-
seen.add(env.id);
|
|
3179
|
-
thread.push(env);
|
|
3180
|
-
}
|
|
3181
|
-
}
|
|
3182
|
-
thread.sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0);
|
|
3183
|
-
streams.stdout.write(`${JSON.stringify(thread, null, 2)}\n`);
|
|
3184
|
-
return 0;
|
|
3185
|
-
}
|
|
3186
|
-
function cmdSessions(flags, streams) {
|
|
3187
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3188
|
-
const root = resolveRoot(flags, cwd);
|
|
3189
|
-
try {
|
|
3190
|
-
let sessions = listPresence(root);
|
|
3191
|
-
if (flags.scope) {
|
|
3192
|
-
const wanted = flags.scope;
|
|
3193
|
-
sessions = sessions.filter((s) => s.interests.scopes.includes(wanted));
|
|
3194
|
-
}
|
|
3195
|
-
if (flags.instance) {
|
|
3196
|
-
const wanted = flags.instance;
|
|
3197
|
-
sessions = sessions.filter((s) => s.instance === wanted);
|
|
3198
|
-
}
|
|
3199
|
-
if (flags.name) {
|
|
3200
|
-
const needle = flags.name.toLowerCase();
|
|
3201
|
-
sessions = sessions.filter((s) => typeof s.name === "string" && s.name.toLowerCase().includes(needle));
|
|
3202
|
-
}
|
|
3203
|
-
streams.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`);
|
|
3204
|
-
return 0;
|
|
3205
|
-
}
|
|
3206
|
-
catch (error) {
|
|
3207
|
-
streams.stderr.write(`h2a sessions: ${error.message}\n`);
|
|
3208
|
-
return 3;
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3211
|
-
function cmdStatus(flags, streams) {
|
|
3212
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3213
|
-
const root = resolveRoot(flags, cwd);
|
|
3214
|
-
try {
|
|
3215
|
-
let sessions = listPresence(root);
|
|
3216
|
-
if (flags.scope) {
|
|
3217
|
-
const wanted = flags.scope;
|
|
3218
|
-
sessions = sessions.filter((s) => s.interests.scopes.includes(wanted));
|
|
3219
|
-
}
|
|
3220
|
-
if (flags.instance) {
|
|
3221
|
-
const wanted = flags.instance;
|
|
3222
|
-
sessions = sessions.filter((s) => s.instance === wanted);
|
|
3223
|
-
}
|
|
3224
|
-
if (flags.name) {
|
|
3225
|
-
const needle = flags.name.toLowerCase();
|
|
3226
|
-
sessions = sessions.filter((s) => typeof s.name === "string" && s.name.toLowerCase().includes(needle));
|
|
3227
|
-
}
|
|
3228
|
-
const indirect = sessions.filter((s) => typeof s.mirroredAt === "string");
|
|
3229
|
-
const direct = sessions.filter((s) => typeof s.mirroredAt !== "string");
|
|
3230
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3231
|
-
ok: true,
|
|
3232
|
-
root,
|
|
3233
|
-
counts: { direct: direct.length, indirect: indirect.length, total: sessions.length },
|
|
3234
|
-
direct,
|
|
3235
|
-
indirect
|
|
3236
|
-
}, null, 2)}\n`);
|
|
3237
|
-
return 0;
|
|
3238
|
-
}
|
|
3239
|
-
catch (error) {
|
|
3240
|
-
streams.stderr.write(`h2a status: ${error.message}\n`);
|
|
3241
|
-
return 3;
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
/**
|
|
3245
|
-
* Read the stable machine-id for workspace derivation (mirrors live.ts).
|
|
3246
|
-
* Used only by `cmdConductor` when the caller passes a filesystem path.
|
|
3247
|
-
*/
|
|
3248
|
-
function cliReadMachineId() {
|
|
3249
|
-
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
3250
|
-
try {
|
|
3251
|
-
const id = readFileSync(p, "utf8").trim();
|
|
3252
|
-
if (id.length > 0)
|
|
3253
|
-
return id;
|
|
3254
|
-
}
|
|
3255
|
-
catch {
|
|
3256
|
-
// try next source
|
|
3257
|
-
}
|
|
3258
|
-
}
|
|
3259
|
-
return hostname() || "unknown-machine";
|
|
3260
|
-
}
|
|
3261
|
-
/**
|
|
3262
|
-
* Resolve a workspace id from CLI flags (workspace flag or cwd default).
|
|
3263
|
-
* Shared by `h2a conductor`, `h2a conductor claim`, and `h2a conductor release`.
|
|
3264
|
-
*/
|
|
3265
|
-
function resolveConductorWorkspaceId(flags, cwd) {
|
|
3266
|
-
const wsFlag = flags.workspace;
|
|
3267
|
-
if (!wsFlag) {
|
|
3268
|
-
// Default to cwd
|
|
3269
|
-
const cwdPath = cwd();
|
|
3270
|
-
let realPath = cwdPath;
|
|
3271
|
-
try {
|
|
3272
|
-
realPath = realpathSync(cwdPath);
|
|
3273
|
-
}
|
|
3274
|
-
catch { /* use cwd as-is */ }
|
|
3275
|
-
return deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
|
|
3276
|
-
}
|
|
3277
|
-
else if (wsFlag.startsWith("ws:")) {
|
|
3278
|
-
return wsFlag;
|
|
3279
|
-
}
|
|
3280
|
-
else {
|
|
3281
|
-
// Treat as a filesystem path
|
|
3282
|
-
let realPath = wsFlag;
|
|
3283
|
-
try {
|
|
3284
|
-
realPath = realpathSync(wsFlag);
|
|
3285
|
-
}
|
|
3286
|
-
catch { /* use as-is */ }
|
|
3287
|
-
return deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
|
|
3288
|
-
}
|
|
3289
|
-
}
|
|
3290
|
-
/**
|
|
3291
|
-
* `h2a conductor [claim|release] [--instance <self>] [--workspace <id|path>] [--root <path>]`
|
|
3292
|
-
*
|
|
3293
|
-
* - No subverb: resolve the live conductor/owner (WP-G1, read-only).
|
|
3294
|
-
* - `claim`: append a claim event and return the post-claim resolution.
|
|
3295
|
-
* - `release`: append a release event and return the post-release resolution.
|
|
3296
|
-
*
|
|
3297
|
-
* Output shape: resource (JSON of ConductorResolution). Exit 0 on success,
|
|
3298
|
-
* exit 1 on user error (missing --instance for claim/release).
|
|
3299
|
-
*/
|
|
3300
|
-
function cmdConductor(argv, streams) {
|
|
3301
|
-
// Only "claim"/"release" are subverbs. Otherwise the first token is a flag
|
|
3302
|
-
// (e.g. `conductor --workspace …`) — parseFlags would consume it as the
|
|
3303
|
-
// command and DROP --workspace, silently defaulting to cwd. Prepend a
|
|
3304
|
-
// placeholder so all flags parse from the full argv in the no-subverb case.
|
|
3305
|
-
const sub = argv[0] === "claim" || argv[0] === "release" ? argv[0] : undefined;
|
|
3306
|
-
const { flags } = parseFlags(sub ? argv : ["", ...argv]);
|
|
3307
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3308
|
-
const root = resolveRoot(flags, cwd);
|
|
3309
|
-
// Subverbs: claim / release
|
|
3310
|
-
if (sub === "claim" || sub === "release") {
|
|
3311
|
-
if (!flags.instance) {
|
|
3312
|
-
streams.stderr.write(`h2a conductor ${sub}: --instance <self> is required\n`);
|
|
3313
|
-
return 1;
|
|
3314
|
-
}
|
|
3315
|
-
const workspaceId = resolveConductorWorkspaceId(flags, cwd);
|
|
3316
|
-
try {
|
|
3317
|
-
appendConductorClaim(root, {
|
|
3318
|
-
type: sub,
|
|
3319
|
-
workspaceId,
|
|
3320
|
-
instance: flags.instance,
|
|
3321
|
-
at: new Date().toISOString()
|
|
3322
|
-
});
|
|
3323
|
-
const result = conductorFor({ root, workspaceId });
|
|
3324
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
3325
|
-
return 0;
|
|
3326
|
-
}
|
|
3327
|
-
catch (error) {
|
|
3328
|
-
streams.stderr.write(`h2a conductor ${sub}: ${error.message}\n`);
|
|
3329
|
-
return 1;
|
|
3330
|
-
}
|
|
3331
|
-
}
|
|
3332
|
-
// No subverb (or unrecognized subverb treated as workspace flag for back-compat):
|
|
3333
|
-
// resolve the conductor (read-only).
|
|
3334
|
-
const workspaceId = resolveConductorWorkspaceId(flags, cwd);
|
|
3335
|
-
try {
|
|
3336
|
-
const result = conductorFor({ root, workspaceId });
|
|
3337
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
3338
|
-
return 0;
|
|
3339
|
-
}
|
|
3340
|
-
catch (error) {
|
|
3341
|
-
streams.stderr.write(`h2a conductor: ${error.message}\n`);
|
|
3342
|
-
return 1;
|
|
3343
|
-
}
|
|
3344
|
-
}
|
|
3345
|
-
/**
|
|
3346
|
-
* `h2a conductor-launch-check [--workspace <id|path>] [--root <path>] [--idle-ms <ms>]`
|
|
3347
|
-
*
|
|
3348
|
-
* DRY-RUN: polls `track workspace-activity` and RECOMMENDS launching a
|
|
3349
|
-
* conductor if work is durably stalled and no conductor is live.
|
|
3350
|
-
*
|
|
3351
|
-
* h2a does NOT spawn anything. A `recommendation === "launch"` result is
|
|
3352
|
-
* purely advisory. The actual launch is parked pending a spawn policy and
|
|
3353
|
-
* remote-trigger support.
|
|
3354
|
-
*
|
|
3355
|
-
* Exit 0 on success. Exit 1 on bad flag value.
|
|
3356
|
-
* Output shape: resource (JSON of ConductorLaunchCheckResult).
|
|
3357
|
-
*/
|
|
3358
|
-
function cmdConductorLaunchCheck(argv, streams) {
|
|
3359
|
-
const { flags } = parseFlags(["", ...argv]);
|
|
3360
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3361
|
-
const root = resolveRoot(flags, cwd);
|
|
3362
|
-
const workspaceId = resolveConductorWorkspaceId(flags, cwd);
|
|
3363
|
-
let idleMs;
|
|
3364
|
-
if (flags["idle-ms"] !== undefined) {
|
|
3365
|
-
const parsed = Number(flags["idle-ms"]);
|
|
3366
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
3367
|
-
streams.stderr.write(`h2a conductor-launch-check: --idle-ms must be a positive number (got "${flags["idle-ms"]}")\n`);
|
|
3368
|
-
return 1;
|
|
3369
|
-
}
|
|
3370
|
-
idleMs = parsed;
|
|
3371
|
-
}
|
|
3372
|
-
try {
|
|
3373
|
-
const result = conductorLaunchCheck({ root, workspaceId, idleMs });
|
|
3374
|
-
if (result.recommendation === "launch") {
|
|
3375
|
-
streams.stderr.write("h2a conductor-launch-check: DRY-RUN — h2a does NOT spawn. " +
|
|
3376
|
-
"This is a recommendation only; the launch is parked pending a spawn policy + remote.\n");
|
|
3377
|
-
}
|
|
3378
|
-
streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
3379
|
-
return 0;
|
|
3380
|
-
}
|
|
3381
|
-
catch (error) {
|
|
3382
|
-
streams.stderr.write(`h2a conductor-launch-check: ${error.message}\n`);
|
|
3383
|
-
return 1;
|
|
3384
|
-
}
|
|
3385
|
-
}
|
|
3386
|
-
/**
|
|
3387
|
-
* `h2a conductor-launch --workspace <id|path> [--root] [--idle-ms <ms>] [--confirm] [--remote <instance>] [--instance <self>]`
|
|
3388
|
-
*
|
|
3389
|
-
* D3 EMISSION: when `conductorLaunchCheck` returns recommendation="launch",
|
|
3390
|
-
* h2a emits a launch-REQUEST envelope to a live remote agent.
|
|
3391
|
-
*
|
|
3392
|
-
* Gates:
|
|
3393
|
-
* 1. `conductorLaunchCheck` recommendation must be "launch".
|
|
3394
|
-
* 2. Cooldown: at most 1 request per 30 min per workspace (checked via spawns store).
|
|
3395
|
-
* 3. Human confirmation: WITHOUT `--confirm`, only PREVIEW the request (dry-run).
|
|
3396
|
-
* WITH `--confirm`, emit + record the marker.
|
|
3397
|
-
*
|
|
3398
|
-
* h2a NEVER spawns a process itself. It only puts a request envelope to remote.
|
|
3399
|
-
* The remote agent reads the envelope and executes the actual spawn.
|
|
3400
|
-
*
|
|
3401
|
-
* Exit 0 (success: none/cooldown/would-emit/no-remote/emitted).
|
|
3402
|
-
* Exit 1 (user error: missing --instance when --confirm given).
|
|
3403
|
-
*/
|
|
3404
|
-
/**
|
|
3405
|
-
* `h2a wake-request --to <instance>` (WP-F, Codex reachability): emit a signed
|
|
3406
|
-
* `wake-request` envelope to a live remote/launcher agent so IT wakes the TARGET
|
|
3407
|
-
* agent's tmux pane out-of-band.
|
|
3408
|
-
*
|
|
3409
|
-
* Why: a Codex agent tears down its h2a stdio MCP child on transport drop and
|
|
3410
|
-
* never reconnects, so h2a deletes its presence and the in-process EVO-1
|
|
3411
|
-
* self-wake can no longer fire — the agent is unreachable even though its pane
|
|
3412
|
-
* is alive. The launcher (remote) holds the target's DURABLE pane and performs
|
|
3413
|
-
* the actual wake; h2a never types into a pane here, so there is no stale-pane /
|
|
3414
|
-
* wrong-pane risk on the h2a side. Claude does NOT need this (its presence stays
|
|
3415
|
-
* live → the self-wake fires in-process).
|
|
3416
|
-
*
|
|
3417
|
-
* Flags: --to <target> (required), --instance <self> signer (required to emit),
|
|
3418
|
-
* --remote <r> (override; else first live remote, pane-preferred), --dry-run,
|
|
3419
|
-
* --reason <text>.
|
|
3420
|
-
*/
|
|
3421
|
-
export function cmdWakeRequest(argv, streams) {
|
|
3422
|
-
const { flags } = parseFlags(["", ...argv]);
|
|
3423
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3424
|
-
const root = resolveRoot(flags, cwd);
|
|
3425
|
-
const target = flags.to;
|
|
3426
|
-
if (!target) {
|
|
3427
|
-
streams.stderr.write("h2a wake-request: --to <instance> is required (the agent to wake)\n");
|
|
3428
|
-
return 1;
|
|
3429
|
-
}
|
|
3430
|
-
const request = {
|
|
3431
|
-
kind: "wake-request",
|
|
3432
|
-
target,
|
|
3433
|
-
reason: flags.reason ?? "peer not live on the bus; wake its pane out-of-band",
|
|
3434
|
-
// Self-describing wake: the EXACT line the launcher should type into the
|
|
3435
|
-
// target's pane. The target's MCP is likely dead (that's why it's being
|
|
3436
|
-
// woken), so it must read its inbox via the CLI, not the MCP — and the bare
|
|
3437
|
-
// `h2a inbox read` errors without --instance. h2a composes the precise
|
|
3438
|
-
// command since it knows the target + root. Launchers SHOULD type this
|
|
3439
|
-
// verbatim (literal send-keys) rather than hard-coding their own line.
|
|
3440
|
-
instructionLine: `h2a inbox read --instance ${target} --root ${root}`
|
|
3441
|
-
};
|
|
3442
|
-
// --dry-run → preview only; never emit.
|
|
3443
|
-
if (flags["dry-run"] !== undefined) {
|
|
3444
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3445
|
-
action: "would-emit",
|
|
3446
|
-
request,
|
|
3447
|
-
note: "DRY-RUN — pass --instance <self> (without --dry-run) to emit; remote performs the actual pane wake"
|
|
3448
|
-
}, null, 2)}\n`);
|
|
3449
|
-
return 0;
|
|
3450
|
-
}
|
|
3451
|
-
// Emitting requires the signer/sender identity.
|
|
3452
|
-
if (!flags.instance) {
|
|
3453
|
-
streams.stderr.write("h2a wake-request: --instance <self> is required to emit (the signer/sender identity); or use --dry-run\n");
|
|
3454
|
-
return 1;
|
|
3455
|
-
}
|
|
3456
|
-
const selfInstance = flags.instance;
|
|
3457
|
-
// Resolve the target remote/launcher instance.
|
|
3458
|
-
let remoteInstance;
|
|
3459
|
-
if (flags.remote) {
|
|
3460
|
-
remoteInstance = flags.remote;
|
|
3461
|
-
}
|
|
3462
|
-
else {
|
|
3463
|
-
const sessions = listPresence(root);
|
|
3464
|
-
const remoteSessions = sessions.filter((s) => s.host === "remote" || (s.instance && s.instance.startsWith("remote:")));
|
|
3465
|
-
if (remoteSessions.length === 0) {
|
|
3466
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3467
|
-
action: "no-remote",
|
|
3468
|
-
reason: "no live remote/launcher agent to perform the out-of-band wake"
|
|
3469
|
-
}, null, 2)}\n`);
|
|
3470
|
-
return 0;
|
|
3471
|
-
}
|
|
3472
|
-
const withPane = remoteSessions.filter((s) => s.launchContext?.tmux?.pane);
|
|
3473
|
-
remoteInstance = (withPane.length > 0 ? withPane[0] : remoteSessions[0]).instance;
|
|
3474
|
-
}
|
|
3475
|
-
const store = createLocalStore({ root });
|
|
3476
|
-
const envelope = createEnvelope({
|
|
3477
|
-
id: `env-wake-${Date.now().toString(36)}`,
|
|
3478
|
-
type: "event",
|
|
3479
|
-
actor: { instance: selfInstance, role: "AGENTS", scope: "scope:default" },
|
|
3480
|
-
target: { instance: remoteInstance },
|
|
3481
|
-
body: {
|
|
3482
|
-
kind: "message",
|
|
3483
|
-
topic: "wake-request",
|
|
3484
|
-
text: `Wake request for ${target}: ${request.reason}`,
|
|
3485
|
-
request
|
|
3486
|
-
},
|
|
3487
|
-
createdAt: new Date().toISOString()
|
|
3488
|
-
});
|
|
3489
|
-
try {
|
|
3490
|
-
store.putInboxMessage(remoteInstance, envelope);
|
|
3491
|
-
}
|
|
3492
|
-
catch (error) {
|
|
3493
|
-
streams.stderr.write(`h2a wake-request: failed to deliver to ${remoteInstance}: ${error.message}\n`);
|
|
3494
|
-
return 1;
|
|
3495
|
-
}
|
|
3496
|
-
streams.stdout.write(`${JSON.stringify({ action: "emitted", to: remoteInstance, request }, null, 2)}\n`);
|
|
3497
|
-
return 0;
|
|
3498
|
-
}
|
|
3499
|
-
export function cmdConductorLaunch(argv, streams, opts) {
|
|
3500
|
-
const { flags } = parseFlags(["", ...argv]);
|
|
3501
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3502
|
-
const root = resolveRoot(flags, cwd);
|
|
3503
|
-
const workspaceId = resolveConductorWorkspaceId(flags, cwd);
|
|
3504
|
-
let idleMs;
|
|
3505
|
-
if (flags["idle-ms"] !== undefined) {
|
|
3506
|
-
const parsed = Number(flags["idle-ms"]);
|
|
3507
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
3508
|
-
streams.stderr.write(`h2a conductor-launch: --idle-ms must be a positive number (got "${flags["idle-ms"]}")\n`);
|
|
3509
|
-
return 1;
|
|
3510
|
-
}
|
|
3511
|
-
idleMs = parsed;
|
|
3512
|
-
}
|
|
3513
|
-
// 1. Compute the launch recommendation (or use injected value for tests).
|
|
3514
|
-
let check;
|
|
3515
|
-
if (opts?.injectedCheck !== undefined) {
|
|
3516
|
-
check = opts.injectedCheck;
|
|
3517
|
-
}
|
|
3518
|
-
else {
|
|
3519
|
-
check = conductorLaunchCheck({ root, workspaceId, ...(idleMs !== undefined ? { idleMs } : {}) });
|
|
3520
|
-
}
|
|
3521
|
-
if (check.recommendation !== "launch") {
|
|
3522
|
-
streams.stdout.write(`${JSON.stringify({ action: "none", ...check }, null, 2)}\n`);
|
|
3523
|
-
return 0;
|
|
3524
|
-
}
|
|
3525
|
-
// 2. Cooldown gate.
|
|
3526
|
-
const last = lastSpawnRequestAt(root, workspaceId);
|
|
3527
|
-
if (!spawnAllowed({ lastSpawnAt: last, now: Date.now() })) {
|
|
3528
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3529
|
-
action: "cooldown",
|
|
3530
|
-
reason: "a launch request was emitted < 30min ago for this workspace",
|
|
3531
|
-
lastSpawnAt: last
|
|
3532
|
-
}, null, 2)}\n`);
|
|
3533
|
-
return 0;
|
|
3534
|
-
}
|
|
3535
|
-
// 3. Build the request object.
|
|
3536
|
-
const request = {
|
|
3537
|
-
kind: "conductor-launch-request",
|
|
3538
|
-
workspaceId,
|
|
3539
|
-
hostPref: ["claude", "codex", "agy"],
|
|
3540
|
-
stalled: check.stalled,
|
|
3541
|
-
reason: check.reason
|
|
3542
|
-
};
|
|
3543
|
-
// 4. Without --confirm → preview only (dry-run). Never emit, never record.
|
|
3544
|
-
if (!flags.confirm) {
|
|
3545
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3546
|
-
action: "would-emit",
|
|
3547
|
-
request,
|
|
3548
|
-
note: "DRY-RUN — pass --confirm to emit this launch request to remote (h2a never spawns; remote does)"
|
|
3549
|
-
}, null, 2)}\n`);
|
|
3550
|
-
return 0;
|
|
3551
|
-
}
|
|
3552
|
-
// 5. With --confirm: --instance is required (the signer/sender identity).
|
|
3553
|
-
if (!flags.instance) {
|
|
3554
|
-
streams.stderr.write("h2a conductor-launch: --instance <self> is required with --confirm (the signer/sender identity)\n");
|
|
3555
|
-
return 1;
|
|
3556
|
-
}
|
|
3557
|
-
const selfInstance = flags.instance;
|
|
3558
|
-
// 6. Resolve the target remote instance.
|
|
3559
|
-
let remoteInstance;
|
|
3560
|
-
if (flags.remote) {
|
|
3561
|
-
remoteInstance = flags.remote;
|
|
3562
|
-
}
|
|
3563
|
-
else {
|
|
3564
|
-
// Find a live session whose host is "remote".
|
|
3565
|
-
const sessions = listPresence(root);
|
|
3566
|
-
const remoteSessions = sessions.filter((s) => s.host === "remote" || (s.instance && s.instance.startsWith("remote:")));
|
|
3567
|
-
if (remoteSessions.length === 0) {
|
|
3568
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3569
|
-
action: "no-remote",
|
|
3570
|
-
reason: "no live remote agent to receive the launch request"
|
|
3571
|
-
}, null, 2)}\n`);
|
|
3572
|
-
return 0;
|
|
3573
|
-
}
|
|
3574
|
-
// Prefer one with a launchContext/pane (they can actually spawn); else take first.
|
|
3575
|
-
const withPaneCandidates = remoteSessions.filter((s) => s.launchContext?.tmux?.pane);
|
|
3576
|
-
const chosen = withPaneCandidates.length > 0 ? withPaneCandidates[0] : remoteSessions[0];
|
|
3577
|
-
remoteInstance = chosen.instance;
|
|
3578
|
-
}
|
|
3579
|
-
// 7. Compose the H2AEnvelope and deliver it to the remote's inbox.
|
|
3580
|
-
const store = createLocalStore({ root });
|
|
3581
|
-
const envelope = createEnvelope({
|
|
3582
|
-
id: `env-conductor-launch-${Date.now().toString(36)}`,
|
|
3583
|
-
type: "event",
|
|
3584
|
-
actor: { instance: selfInstance, role: "CONDUCTOR", scope: "scope:default" },
|
|
3585
|
-
target: { instance: remoteInstance },
|
|
3586
|
-
body: {
|
|
3587
|
-
kind: "message",
|
|
3588
|
-
topic: "conductor-launch-request",
|
|
3589
|
-
text: `Conductor-launch request for workspace ${workspaceId}: ${check.reason}`,
|
|
3590
|
-
request
|
|
3591
|
-
},
|
|
3592
|
-
createdAt: new Date().toISOString()
|
|
3593
|
-
});
|
|
3594
|
-
try {
|
|
3595
|
-
store.putInboxMessage(remoteInstance, envelope);
|
|
3596
|
-
}
|
|
3597
|
-
catch (error) {
|
|
3598
|
-
streams.stderr.write(`h2a conductor-launch: failed to deliver to ${remoteInstance}: ${error.message}\n`);
|
|
3599
|
-
return 1;
|
|
3600
|
-
}
|
|
3601
|
-
// 8. Record the spawn marker (after successful delivery).
|
|
3602
|
-
recordSpawnRequest(root, {
|
|
3603
|
-
workspaceId,
|
|
3604
|
-
at: new Date().toISOString(),
|
|
3605
|
-
to: remoteInstance
|
|
3606
|
-
});
|
|
3607
|
-
streams.stdout.write(`${JSON.stringify({ action: "emitted", to: remoteInstance, request }, null, 2)}\n`);
|
|
3608
|
-
return 0;
|
|
3609
|
-
}
|
|
3610
|
-
function cmdDoctor(flags, streams) {
|
|
3611
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3612
|
-
const root = resolveRoot(flags, cwd);
|
|
3613
|
-
const report = {
|
|
3614
|
-
ok: true,
|
|
3615
|
-
root,
|
|
3616
|
-
warnings: [],
|
|
3617
|
-
checks: {}
|
|
3618
|
-
};
|
|
3619
|
-
const checks = report.checks;
|
|
3620
|
-
const warnings = report.warnings;
|
|
3621
|
-
// 1. Root reachable
|
|
3622
|
-
if (!existsSync(root)) {
|
|
3623
|
-
checks.rootExists = { ok: false, message: `root does not exist: ${root}` };
|
|
3624
|
-
report.ok = false;
|
|
3625
|
-
}
|
|
3626
|
-
else {
|
|
3627
|
-
checks.rootExists = { ok: true };
|
|
3628
|
-
}
|
|
3629
|
-
// 2. Schema sentinel present
|
|
3630
|
-
const schemaPath = join(root, ".h2a-schema.json");
|
|
3631
|
-
if (!existsSync(schemaPath)) {
|
|
3632
|
-
checks.schemaSentinel = {
|
|
3633
|
-
ok: false,
|
|
3634
|
-
message: `missing ${schemaPath}; run \`h2a init --root ${root}\``
|
|
3635
|
-
};
|
|
3636
|
-
report.ok = false;
|
|
3637
|
-
}
|
|
3638
|
-
else {
|
|
3639
|
-
try {
|
|
3640
|
-
const parsed = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
3641
|
-
checks.schemaSentinel = { ok: true, version: parsed.version };
|
|
3642
|
-
if (parsed.version !== H2A_STORE_SCHEMA_VERSION) {
|
|
3643
|
-
report.ok = false;
|
|
3644
|
-
checks.schemaSentinel = {
|
|
3645
|
-
ok: false,
|
|
3646
|
-
version: parsed.version,
|
|
3647
|
-
message: `expected schema version ${H2A_STORE_SCHEMA_VERSION}`
|
|
3648
|
-
};
|
|
3649
|
-
}
|
|
3650
|
-
}
|
|
3651
|
-
catch (error) {
|
|
3652
|
-
report.ok = false;
|
|
3653
|
-
checks.schemaSentinel = {
|
|
3654
|
-
ok: false,
|
|
3655
|
-
message: `cannot read schema sentinel: ${error.message}`
|
|
3656
|
-
};
|
|
3657
|
-
}
|
|
3658
|
-
}
|
|
3659
|
-
// 3. Live sessions on the bus
|
|
3660
|
-
try {
|
|
3661
|
-
const sessions = listPresence(root);
|
|
3662
|
-
checks.liveSessions = { ok: true, count: sessions.length };
|
|
3663
|
-
}
|
|
3664
|
-
catch (error) {
|
|
3665
|
-
report.ok = false;
|
|
3666
|
-
checks.liveSessions = {
|
|
3667
|
-
ok: false,
|
|
3668
|
-
message: error.message
|
|
3669
|
-
};
|
|
3670
|
-
}
|
|
3671
|
-
// 4. h2a binary reachable (self-check via existing API)
|
|
3672
|
-
checks.cliBinary = { ok: true };
|
|
3673
|
-
// ── Warning checks (do NOT flip report.ok) ──────────────────────────────
|
|
3674
|
-
// (a) rootSource: record where the root came from
|
|
3675
|
-
const rootInfo = resolveRootInfo(flags, cwd);
|
|
3676
|
-
report.rootSource = rootInfo.source;
|
|
3677
|
-
if (rootInfo.source === "default") {
|
|
3678
|
-
warnings.push({
|
|
3679
|
-
check: "rootSource",
|
|
3680
|
-
message: `The bus root is the global shared default (${root}). ` +
|
|
3681
|
-
`Set H2A_ROOT or pass --root <path> to use a different bus. ` +
|
|
3682
|
-
`Agents on different roots cannot exchange messages.`
|
|
3683
|
-
});
|
|
3684
|
-
}
|
|
3685
|
-
// (b) splitBrain: a repo-local .h2a exists alongside a DIFFERENT active root
|
|
3686
|
-
const cwdLocal = join(cwd(), ".h2a");
|
|
3687
|
-
try {
|
|
3688
|
-
if (existsSync(cwdLocal)) {
|
|
3689
|
-
const resolvedLocal = realpathSync(cwdLocal);
|
|
3690
|
-
const resolvedRoot = (() => {
|
|
3691
|
-
try {
|
|
3692
|
-
return realpathSync(root);
|
|
3693
|
-
}
|
|
3694
|
-
catch {
|
|
3695
|
-
return root;
|
|
3696
|
-
}
|
|
3697
|
-
})();
|
|
3698
|
-
if (resolvedLocal !== resolvedRoot) {
|
|
3699
|
-
warnings.push({
|
|
3700
|
-
check: "splitBrain",
|
|
3701
|
-
message: `A repo-local .h2a (${cwdLocal}) exists alongside a DIFFERENT active root (${root}). ` +
|
|
3702
|
-
`Messages written to the repo-local bus are invisible to peers on ${root} — split-brain.`
|
|
3703
|
-
});
|
|
3704
|
-
}
|
|
3705
|
-
}
|
|
3706
|
-
}
|
|
3707
|
-
catch {
|
|
3708
|
-
// silently ignore (the cwd-local path may not be resolvable)
|
|
3709
|
-
}
|
|
3710
|
-
// (c) inboxHygiene: scan inbox for case/slug duplicates, host-less dirs, phantom 3-segment dirs
|
|
3711
|
-
const inboxDir = join(root, "inbox");
|
|
3712
|
-
// Collect dirs to prune (with --prune only).
|
|
3713
|
-
const pruned = [];
|
|
3714
|
-
if (existsSync(inboxDir)) {
|
|
3715
|
-
let inboxEntries = [];
|
|
3716
|
-
try {
|
|
3717
|
-
inboxEntries = readdirSync(inboxDir);
|
|
3718
|
-
}
|
|
3719
|
-
catch {
|
|
3720
|
-
// not readable — skip hygiene check
|
|
3721
|
-
}
|
|
3722
|
-
if (inboxEntries.length > 0) {
|
|
3723
|
-
const KNOWN_HOSTS = new Set(["claude", "codex", "gemini", "remote"]);
|
|
3724
|
-
// Reconstruct an address from a dir name: double-underscore → colon.
|
|
3725
|
-
function dirToAddress(name) {
|
|
3726
|
-
return name.replace(/__/g, ":");
|
|
3727
|
-
}
|
|
3728
|
-
// Case/slug duplicates: two dir names that map to the same canonicalAddress
|
|
3729
|
-
const byCanonical = new Map();
|
|
3730
|
-
for (const entry of inboxEntries) {
|
|
3731
|
-
const addr = dirToAddress(entry);
|
|
3732
|
-
let canonical;
|
|
3733
|
-
try {
|
|
3734
|
-
canonical = canonicalAddress(addr);
|
|
3735
|
-
}
|
|
3736
|
-
catch {
|
|
3737
|
-
canonical = addr.toLowerCase();
|
|
3738
|
-
}
|
|
3739
|
-
const group = byCanonical.get(canonical) ?? [];
|
|
3740
|
-
group.push(entry);
|
|
3741
|
-
byCanonical.set(canonical, group);
|
|
3742
|
-
}
|
|
3743
|
-
const dupGroups = [...byCanonical.values()].filter((g) => g.length > 1);
|
|
3744
|
-
if (dupGroups.length > 0) {
|
|
3745
|
-
const examples = dupGroups.slice(0, 5).map((g) => g.join(" | "));
|
|
3746
|
-
warnings.push({
|
|
3747
|
-
check: "inboxHygiene",
|
|
3748
|
-
kind: "caseDuplicates",
|
|
3749
|
-
count: dupGroups.length,
|
|
3750
|
-
examples,
|
|
3751
|
-
message: `${dupGroups.length} case/slug duplicate group(s) in inbox — ` +
|
|
3752
|
-
`different dir names map to the same canonical address. ` +
|
|
3753
|
-
`Examples: ${examples.join(", ")}. (Not pruned — too risky; resolve manually.)`
|
|
3754
|
-
});
|
|
3755
|
-
}
|
|
3756
|
-
// Host-less dirs: first segment (before first __) is not a known host
|
|
3757
|
-
const hostless = inboxEntries.filter((entry) => {
|
|
3758
|
-
const firstSeg = entry.split("__")[0];
|
|
3759
|
-
return !KNOWN_HOSTS.has(firstSeg);
|
|
3760
|
-
});
|
|
3761
|
-
if (hostless.length > 0) {
|
|
3762
|
-
const examples = hostless.slice(0, 5);
|
|
3763
|
-
warnings.push({
|
|
3764
|
-
check: "inboxHygiene",
|
|
3765
|
-
kind: "hostlessDirs",
|
|
3766
|
-
count: hostless.length,
|
|
3767
|
-
examples,
|
|
3768
|
-
message: `${hostless.length} host-less inbox dir(s) (first segment is not claude/codex/gemini/remote). ` +
|
|
3769
|
-
`Examples: ${examples.join(", ")}`
|
|
3770
|
-
});
|
|
3771
|
-
if (flags.prune !== undefined) {
|
|
3772
|
-
for (const name of hostless) {
|
|
3773
|
-
const dir = join(inboxDir, name);
|
|
3774
|
-
try {
|
|
3775
|
-
rmSync(dir, { recursive: true, force: true });
|
|
3776
|
-
pruned.push({ name, path: dir });
|
|
3777
|
-
}
|
|
3778
|
-
catch (error) {
|
|
3779
|
-
streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
|
|
3780
|
-
}
|
|
3781
|
-
}
|
|
3782
|
-
}
|
|
3783
|
-
}
|
|
3784
|
-
// Phantom 3-segment dirs: exactly 3 __ segments but 3rd is not a 12-char hex session id
|
|
3785
|
-
const VALID_TAIL_RE = /^[0-9a-f]{12}$/i;
|
|
3786
|
-
const phantom = inboxEntries.filter((entry) => {
|
|
3787
|
-
const segs = entry.split("__");
|
|
3788
|
-
if (segs.length !== 3)
|
|
3789
|
-
return false;
|
|
3790
|
-
return !VALID_TAIL_RE.test(segs[2]);
|
|
3791
|
-
});
|
|
3792
|
-
if (phantom.length > 0) {
|
|
3793
|
-
const examples = phantom.slice(0, 5);
|
|
3794
|
-
warnings.push({
|
|
3795
|
-
check: "inboxHygiene",
|
|
3796
|
-
kind: "phantomThreeSegment",
|
|
3797
|
-
count: phantom.length,
|
|
3798
|
-
examples,
|
|
3799
|
-
message: `${phantom.length} phantom 3-segment inbox dir(s) where the tail is not a 12-char hex session id. ` +
|
|
3800
|
-
`Examples: ${examples.join(", ")}`
|
|
3801
|
-
});
|
|
3802
|
-
if (flags.prune !== undefined) {
|
|
3803
|
-
for (const name of phantom) {
|
|
3804
|
-
const dir = join(inboxDir, name);
|
|
3805
|
-
try {
|
|
3806
|
-
rmSync(dir, { recursive: true, force: true });
|
|
3807
|
-
pruned.push({ name, path: dir });
|
|
3808
|
-
}
|
|
3809
|
-
catch (error) {
|
|
3810
|
-
streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
}
|
|
3814
|
-
}
|
|
3815
|
-
// Orphan 3-segment dirs: 3 segments, valid hex tail, but UUID is NOT in registry/instances.jsonl
|
|
3816
|
-
if (flags.prune !== undefined) {
|
|
3817
|
-
const instancesFile = join(root, "registry", "instances.jsonl");
|
|
3818
|
-
let registeredUuids = new Set();
|
|
3819
|
-
try {
|
|
3820
|
-
const content = readFileSync(instancesFile, "utf8");
|
|
3821
|
-
for (const line of content.split("\n")) {
|
|
3822
|
-
const trimmed = line.trim();
|
|
3823
|
-
if (!trimmed)
|
|
3824
|
-
continue;
|
|
3825
|
-
try {
|
|
3826
|
-
const obj = JSON.parse(trimmed);
|
|
3827
|
-
const id = typeof obj.instance === "string" ? obj.instance : (typeof obj.id === "string" ? obj.id : "");
|
|
3828
|
-
// Extract the hex tail segment from the instance id (e.g. host:label:abc123456789)
|
|
3829
|
-
const parts = id.split(":");
|
|
3830
|
-
if (parts.length >= 3) {
|
|
3831
|
-
const tail = parts[parts.length - 1];
|
|
3832
|
-
if (VALID_TAIL_RE.test(tail))
|
|
3833
|
-
registeredUuids.add(tail.toLowerCase());
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
|
-
catch {
|
|
3837
|
-
// skip malformed
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
}
|
|
3841
|
-
catch {
|
|
3842
|
-
// instances file absent — keep set empty (prune all orphans below)
|
|
3843
|
-
}
|
|
3844
|
-
const orphan3seg = inboxEntries.filter((entry) => {
|
|
3845
|
-
const segs = entry.split("__");
|
|
3846
|
-
if (segs.length !== 3)
|
|
3847
|
-
return false;
|
|
3848
|
-
if (!VALID_TAIL_RE.test(segs[2]))
|
|
3849
|
-
return false; // already handled as phantom
|
|
3850
|
-
return !registeredUuids.has(segs[2].toLowerCase());
|
|
3851
|
-
});
|
|
3852
|
-
for (const name of orphan3seg) {
|
|
3853
|
-
const dir = join(inboxDir, name);
|
|
3854
|
-
try {
|
|
3855
|
-
rmSync(dir, { recursive: true, force: true });
|
|
3856
|
-
pruned.push({ name, path: dir });
|
|
3857
|
-
}
|
|
3858
|
-
catch (error) {
|
|
3859
|
-
streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
|
|
3860
|
-
}
|
|
3861
|
-
}
|
|
3862
|
-
}
|
|
3863
|
-
}
|
|
3864
|
-
}
|
|
3865
|
-
// (d) --scan <dir>: find immediate child buses (ONE level deep)
|
|
3866
|
-
const strayBuses = [];
|
|
3867
|
-
if (flags.scan) {
|
|
3868
|
-
try {
|
|
3869
|
-
const scanChildren = readdirSync(flags.scan);
|
|
3870
|
-
for (const child of scanChildren) {
|
|
3871
|
-
const candidateBus = join(flags.scan, child, ".h2a");
|
|
3872
|
-
if (existsSync(candidateBus)) {
|
|
3873
|
-
const instancesFile = join(candidateBus, "registry", "instances.jsonl");
|
|
3874
|
-
let instances = 0;
|
|
3875
|
-
try {
|
|
3876
|
-
const content = readFileSync(instancesFile, "utf8");
|
|
3877
|
-
instances = content.split("\n").filter((l) => l.trim().length > 0).length;
|
|
3878
|
-
}
|
|
3879
|
-
catch {
|
|
3880
|
-
instances = 0;
|
|
3881
|
-
}
|
|
3882
|
-
strayBuses.push({ path: candidateBus, instances });
|
|
3883
|
-
}
|
|
3884
|
-
}
|
|
3885
|
-
}
|
|
3886
|
-
catch (error) {
|
|
3887
|
-
streams.stderr.write(`h2a doctor: --scan ${flags.scan}: ${error.message}\n`);
|
|
3888
|
-
}
|
|
3889
|
-
if (strayBuses.length > 0) {
|
|
3890
|
-
warnings.push({
|
|
3891
|
-
check: "strayBuses",
|
|
3892
|
-
count: strayBuses.length,
|
|
3893
|
-
buses: strayBuses,
|
|
3894
|
-
message: `${strayBuses.length} stray repo-local .h2a bus(es) found under ${flags.scan} — ` +
|
|
3895
|
-
`candidate split-brain forks. Each may have agents on a different root.`
|
|
3896
|
-
});
|
|
3897
|
-
if (flags.prune !== undefined) {
|
|
3898
|
-
for (const bus of strayBuses) {
|
|
3899
|
-
const busPath = bus.path;
|
|
3900
|
-
try {
|
|
3901
|
-
rmSync(busPath, { recursive: true, force: true });
|
|
3902
|
-
pruned.push({ name: busPath, path: busPath });
|
|
3903
|
-
}
|
|
3904
|
-
catch (error) {
|
|
3905
|
-
streams.stderr.write(`h2a doctor --prune: cannot remove ${busPath}: ${error.message}\n`);
|
|
3906
|
-
}
|
|
3907
|
-
}
|
|
3908
|
-
}
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
3911
|
-
if (flags.prune !== undefined) {
|
|
3912
|
-
report.pruned = pruned;
|
|
3913
|
-
}
|
|
3914
|
-
streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
3915
|
-
return report.ok ? 0 : 2;
|
|
3916
|
-
}
|
|
3917
|
-
function cmdKeysGenerate(flags, streams) {
|
|
3918
|
-
if (!flags.instance) {
|
|
3919
|
-
streams.stderr.write("h2a keys generate: --instance <id> is required\n");
|
|
3920
|
-
return 1;
|
|
3921
|
-
}
|
|
3922
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3923
|
-
const outDir = flags.out ?? join(resolveRoot(flags, cwd), "keys");
|
|
3924
|
-
try {
|
|
3925
|
-
mkdirSync(outDir, { recursive: true });
|
|
3926
|
-
}
|
|
3927
|
-
catch (error) {
|
|
3928
|
-
streams.stderr.write(`h2a keys generate: cannot create ${outDir} (${error.message})\n`);
|
|
3929
|
-
return 3;
|
|
3930
|
-
}
|
|
3931
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
3932
|
-
const privatePem = privateKey
|
|
3933
|
-
.export({ type: "pkcs8", format: "pem" })
|
|
3934
|
-
.toString();
|
|
3935
|
-
const publicPem = publicKey
|
|
3936
|
-
.export({ type: "spki", format: "pem" })
|
|
3937
|
-
.toString();
|
|
3938
|
-
// File names are derived from the instance id with `:` and `/` replaced by `-`
|
|
3939
|
-
// so they map cleanly to filesystems on every OS.
|
|
3940
|
-
const safeName = flags.instance.replace(/[:/]/g, "-");
|
|
3941
|
-
const privatePath = join(outDir, `${safeName}.key.pem`);
|
|
3942
|
-
const publicPath = join(outDir, `${safeName}.pub.pem`);
|
|
3943
|
-
try {
|
|
3944
|
-
writeFileSync(privatePath, privatePem, { encoding: "utf8", mode: 0o600 });
|
|
3945
|
-
writeFileSync(publicPath, publicPem, "utf8");
|
|
3946
|
-
}
|
|
3947
|
-
catch (error) {
|
|
3948
|
-
streams.stderr.write(`h2a keys generate: cannot write key files (${error.message})\n`);
|
|
3949
|
-
return 3;
|
|
3950
|
-
}
|
|
3951
|
-
streams.stdout.write(`${JSON.stringify({
|
|
3952
|
-
ok: true,
|
|
3953
|
-
instance: flags.instance,
|
|
3954
|
-
privateKeyPath: privatePath,
|
|
3955
|
-
publicKeyPath: publicPath,
|
|
3956
|
-
publicKeyPem: publicPem
|
|
3957
|
-
}, null, 2)}\n`);
|
|
3958
|
-
return 0;
|
|
3959
|
-
}
|
|
3960
|
-
function cmdKeysAdd(flags, streams) {
|
|
3961
|
-
// DEC-078: append a public key to an instance's keyring (rotate-in).
|
|
3962
|
-
if (!flags.instance || !flags["public-key"]) {
|
|
3963
|
-
streams.stderr.write("h2a keys add: --instance <id> and --public-key <pem-file> are required\n");
|
|
3964
|
-
return 1;
|
|
3965
|
-
}
|
|
3966
|
-
let publicKeyPem;
|
|
3967
|
-
try {
|
|
3968
|
-
publicKeyPem = readFileSync(flags["public-key"], "utf8");
|
|
3969
|
-
}
|
|
3970
|
-
catch (error) {
|
|
3971
|
-
streams.stderr.write(`h2a keys add: cannot read --public-key (${error.message})\n`);
|
|
3972
|
-
return 1;
|
|
3973
|
-
}
|
|
3974
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3975
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
3976
|
-
try {
|
|
3977
|
-
store.addInstanceKey(flags.instance, publicKeyPem);
|
|
3978
|
-
}
|
|
3979
|
-
catch (error) {
|
|
3980
|
-
const message = error.message;
|
|
3981
|
-
streams.stderr.write(`h2a keys add: ${message}\n`);
|
|
3982
|
-
return classifyStoreError(message);
|
|
3983
|
-
}
|
|
3984
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, keys: store.listInstanceKeys(flags.instance).length }, null, 2)}\n`);
|
|
3985
|
-
return 0;
|
|
3986
|
-
}
|
|
3987
|
-
function cmdKeysList(flags, streams) {
|
|
3988
|
-
if (!flags.instance) {
|
|
3989
|
-
streams.stderr.write("h2a keys list: --instance <id> is required\n");
|
|
3990
|
-
return 1;
|
|
3991
|
-
}
|
|
3992
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
3993
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
3994
|
-
streams.stdout.write(`${JSON.stringify(store.listInstanceKeys(flags.instance), null, 2)}\n`);
|
|
3995
|
-
return 0;
|
|
3996
|
-
}
|
|
3997
|
-
function cmdKeysRevoke(flags, streams) {
|
|
3998
|
-
// DEC-079: revoke a public key from an instance's keyring (rotate-out).
|
|
3999
|
-
if (!flags.instance || !flags["public-key"]) {
|
|
4000
|
-
streams.stderr.write("h2a keys revoke: --instance <id> and --public-key <pem-file> are required\n");
|
|
4001
|
-
return 1;
|
|
4002
|
-
}
|
|
4003
|
-
let publicKeyPem;
|
|
4004
|
-
try {
|
|
4005
|
-
publicKeyPem = readFileSync(flags["public-key"], "utf8");
|
|
4006
|
-
}
|
|
4007
|
-
catch (error) {
|
|
4008
|
-
streams.stderr.write(`h2a keys revoke: cannot read --public-key (${error.message})\n`);
|
|
4009
|
-
return 1;
|
|
4010
|
-
}
|
|
4011
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
4012
|
-
const store = createLocalStore({ root: resolveRoot(flags, cwd) });
|
|
4013
|
-
try {
|
|
4014
|
-
store.revokeInstanceKey(flags.instance, publicKeyPem);
|
|
4015
|
-
}
|
|
4016
|
-
catch (error) {
|
|
4017
|
-
const message = error.message;
|
|
4018
|
-
streams.stderr.write(`h2a keys revoke: ${message}\n`);
|
|
4019
|
-
return classifyStoreError(message);
|
|
4020
|
-
}
|
|
4021
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, status: "revoked", keys: store.listInstanceKeys(flags.instance).length }, null, 2)}\n`);
|
|
4022
|
-
return 0;
|
|
4023
|
-
}
|
|
4024
|
-
function cmdConnect(flags, streams) {
|
|
4025
|
-
if (!flags.host) {
|
|
4026
|
-
streams.stderr.write("h2a connect: --host <codex|claude|gemini|agy|remote> is required\n");
|
|
4027
|
-
return 1;
|
|
4028
|
-
}
|
|
4029
|
-
if (!["codex", "claude", "gemini", "agy", "remote"].includes(flags.host)) {
|
|
4030
|
-
streams.stderr.write(`h2a connect: unknown --host "${flags.host}". Supported: codex, claude, gemini, agy, remote.\n`);
|
|
4031
|
-
return 1;
|
|
4032
|
-
}
|
|
4033
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
4034
|
-
warnIfCwdRootFallback(flags, cwd, streams);
|
|
4035
|
-
const root = resolveRoot(flags, cwd);
|
|
4036
|
-
const identity = resolveLiveIdentity({
|
|
4037
|
-
root,
|
|
4038
|
-
host: flags.host,
|
|
4039
|
-
cwd: cwd(),
|
|
4040
|
-
...(flags.instance !== undefined ? { explicitInstance: flags.instance } : {}),
|
|
4041
|
-
...(flags.name !== undefined ? { name: flags.name } : {})
|
|
4042
|
-
});
|
|
4043
|
-
const instance = identity.instance;
|
|
4044
|
-
const summary = {
|
|
4045
|
-
ok: true,
|
|
4046
|
-
root,
|
|
4047
|
-
host: flags.host,
|
|
4048
|
-
instance,
|
|
4049
|
-
identity,
|
|
4050
|
-
steps: []
|
|
4051
|
-
};
|
|
4052
|
-
const steps = summary.steps;
|
|
4053
|
-
// 1. h2a init
|
|
4054
|
-
try {
|
|
4055
|
-
createLocalStore({ root });
|
|
4056
|
-
steps.push({ step: "init", ok: true, root });
|
|
4057
|
-
}
|
|
4058
|
-
catch (error) {
|
|
4059
|
-
streams.stderr.write(`h2a connect: init failed (${error.message})\n`);
|
|
4060
|
-
return 3;
|
|
4061
|
-
}
|
|
4062
|
-
// 2. host setup snippet (print only — write requires explicit --write)
|
|
4063
|
-
if (flags.host === "remote") {
|
|
4064
|
-
steps.push({
|
|
4065
|
-
step: "host-setup",
|
|
4066
|
-
ok: true,
|
|
4067
|
-
pathHint: "@sentropic/remote bridge environment",
|
|
4068
|
-
pathExample: "remote session Pod env",
|
|
4069
|
-
snippet: {
|
|
4070
|
-
H2A_HOST: "remote",
|
|
4071
|
-
H2A_INSTANCE: instance,
|
|
4072
|
-
H2A_ROOT: root
|
|
4073
|
-
}
|
|
4074
|
-
});
|
|
4075
|
-
}
|
|
4076
|
-
else {
|
|
4077
|
-
const hostDescriptor = flags.host === "codex"
|
|
4078
|
-
? H2A_CODEX_HOST
|
|
4079
|
-
: flags.host === "claude"
|
|
4080
|
-
? H2A_CLAUDE_HOST
|
|
4081
|
-
: flags.host === "gemini"
|
|
4082
|
-
? H2A_GEMINI_HOST
|
|
4083
|
-
: H2A_AGY_HOST;
|
|
4084
|
-
const snippet = hostDescriptor.renderMcpConfig({ root });
|
|
4085
|
-
steps.push({
|
|
4086
|
-
step: "host-setup",
|
|
4087
|
-
ok: true,
|
|
4088
|
-
pathHint: snippet.path.hint,
|
|
4089
|
-
pathExample: snippet.path.example,
|
|
4090
|
-
snippet: snippet.config
|
|
4091
|
-
});
|
|
4092
|
-
}
|
|
4093
|
-
// 3. Print follow-up instructions
|
|
4094
|
-
const followUp = [
|
|
4095
|
-
flags.host === "remote"
|
|
4096
|
-
? "Use the bridge-provided environment in the remote session Pod"
|
|
4097
|
-
: `Merge the snippet above under \`mcpServers\` in ${steps[1].pathExample}`,
|
|
4098
|
-
identity.privateKeyPath
|
|
4099
|
-
? `keypair ready: ${identity.privateKeyPath}`
|
|
4100
|
-
: `legacy override: run h2a keys generate --instance ${instance} --root ${root} if needed`,
|
|
4101
|
-
flags.host === "remote" ? "remote bridge skills are managed by the host" : `and: h2a install-skills --host ${flags.host}`
|
|
4102
|
-
];
|
|
4103
|
-
summary.followUp = followUp;
|
|
4104
|
-
streams.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
4105
|
-
return 0;
|
|
4106
|
-
}
|
|
4107
|
-
/**
|
|
4108
|
-
* Parse a SKILL.md file into its YAML-frontmatter `name`/`description` and
|
|
4109
|
-
* the body markdown that follows. The parser is intentionally minimal: it
|
|
4110
|
-
* accepts the canonical `---\nname: ...\ndescription: ...\n---\n<body>` shape
|
|
4111
|
-
* that every shipped h2a skill uses. Multi-line description values are
|
|
4112
|
-
* supported via simple line-continuation indent.
|
|
4113
|
-
*/
|
|
4114
|
-
function parseSkill(raw) {
|
|
4115
|
-
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/m.exec(raw);
|
|
4116
|
-
if (!match) {
|
|
4117
|
-
throw new Error("skill file missing YAML frontmatter (expected `---` delimiters)");
|
|
4118
|
-
}
|
|
4119
|
-
const [, fmRaw, body] = match;
|
|
4120
|
-
const frontmatter = {};
|
|
4121
|
-
let currentKey;
|
|
4122
|
-
for (const line of fmRaw.split(/\r?\n/)) {
|
|
4123
|
-
const kv = /^([a-zA-Z][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
4124
|
-
if (kv) {
|
|
4125
|
-
currentKey = kv[1];
|
|
4126
|
-
frontmatter[currentKey] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
4127
|
-
}
|
|
4128
|
-
else if (currentKey && /^\s+/.test(line)) {
|
|
4129
|
-
frontmatter[currentKey] = `${frontmatter[currentKey]} ${line.trim()}`;
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
if (!frontmatter.name || !frontmatter.description) {
|
|
4133
|
-
throw new Error("skill frontmatter must declare both `name` and `description`");
|
|
4134
|
-
}
|
|
4135
|
-
return {
|
|
4136
|
-
name: frontmatter.name,
|
|
4137
|
-
description: frontmatter.description,
|
|
4138
|
-
body: body.trimStart()
|
|
4139
|
-
};
|
|
4140
|
-
}
|
|
4141
|
-
/**
|
|
4142
|
-
* Render a parsed skill as a Gemini CLI custom-command TOML file
|
|
4143
|
-
* (`~/.gemini/commands/<name>.toml`). Gemini consumes a top-level
|
|
4144
|
-
* `description` plus a `prompt` (multiline triple-quoted) which is what the
|
|
4145
|
-
* agent receives when the user types `/<name>`. We include the original
|
|
4146
|
-
* frontmatter so the prompt itself remains self-describing.
|
|
4147
|
-
*/
|
|
4148
|
-
function renderSkillAsGeminiToml(skill) {
|
|
4149
|
-
const escapedDescription = skill.description.replace(/"/g, '\\"');
|
|
4150
|
-
const promptHeader = `You are the ${skill.name} custom command for Gemini CLI.\n\n`;
|
|
4151
|
-
const promptBody = `${promptHeader}${skill.body}`;
|
|
4152
|
-
// TOML multiline literal: '''...''' is verbatim, no escaping needed beyond
|
|
4153
|
-
// disallowing the closing triple-quote in content. The bundled skills do
|
|
4154
|
-
// not contain '''.
|
|
4155
|
-
return [
|
|
4156
|
-
`description = "${escapedDescription}"`,
|
|
4157
|
-
`prompt = '''`,
|
|
4158
|
-
promptBody,
|
|
4159
|
-
`'''`,
|
|
4160
|
-
""
|
|
4161
|
-
].join("\n");
|
|
4162
|
-
}
|
|
4163
|
-
function targetSpecFor(host, cwd) {
|
|
4164
|
-
if (host === "claude") {
|
|
4165
|
-
return {
|
|
4166
|
-
host: "claude",
|
|
4167
|
-
userBase: join(homedir(), ".claude", "skills"),
|
|
4168
|
-
projectBase: join(cwd, ".claude", "skills"),
|
|
4169
|
-
extension: "SKILL.md",
|
|
4170
|
-
write: (base, skillName, _parsed, raw) => {
|
|
4171
|
-
const dir = join(base, skillName);
|
|
4172
|
-
mkdirSync(dir, { recursive: true });
|
|
4173
|
-
const target = join(dir, "SKILL.md");
|
|
4174
|
-
writeFileSync(target, raw, "utf8");
|
|
4175
|
-
return target;
|
|
4176
|
-
}
|
|
4177
|
-
};
|
|
4178
|
-
}
|
|
4179
|
-
if (host === "codex") {
|
|
4180
|
-
return {
|
|
4181
|
-
host: "codex",
|
|
4182
|
-
userBase: join(homedir(), ".codex", "skills"),
|
|
4183
|
-
projectBase: join(cwd, ".codex", "skills"),
|
|
4184
|
-
extension: "SKILL.md",
|
|
4185
|
-
write: (base, skillName, _parsed, raw) => {
|
|
4186
|
-
const dir = join(base, skillName);
|
|
4187
|
-
mkdirSync(dir, { recursive: true });
|
|
4188
|
-
const target = join(dir, "SKILL.md");
|
|
4189
|
-
writeFileSync(target, raw, "utf8");
|
|
4190
|
-
return target;
|
|
4191
|
-
}
|
|
4192
|
-
};
|
|
4193
|
-
}
|
|
4194
|
-
if (host === "gemini") {
|
|
4195
|
-
return {
|
|
4196
|
-
host: "gemini",
|
|
4197
|
-
userBase: join(homedir(), ".gemini", "commands"),
|
|
4198
|
-
projectBase: join(cwd, ".gemini", "commands"),
|
|
4199
|
-
extension: ".toml",
|
|
4200
|
-
write: (base, skillName, parsed) => {
|
|
4201
|
-
mkdirSync(base, { recursive: true });
|
|
4202
|
-
const target = join(base, `${skillName}.toml`);
|
|
4203
|
-
writeFileSync(target, renderSkillAsGeminiToml(parsed), "utf8");
|
|
4204
|
-
return target;
|
|
4205
|
-
}
|
|
4206
|
-
};
|
|
4207
|
-
}
|
|
4208
|
-
if (host === "agy") {
|
|
4209
|
-
// DEC-096/101: agy (Antigravity) is in the Gemini ecosystem and shares
|
|
4210
|
-
// `~/.gemini/`; it has no own skill store but imports plugins from gemini
|
|
4211
|
-
// or claude (`agy plugin import gemini`). So installing the gemini TOML
|
|
4212
|
-
// command IS the agy source — same write/location as gemini; the import
|
|
4213
|
-
// step is surfaced as `importHint` in the summary.
|
|
4214
|
-
return {
|
|
4215
|
-
host: "agy",
|
|
4216
|
-
userBase: join(homedir(), ".gemini", "commands"),
|
|
4217
|
-
projectBase: join(cwd, ".gemini", "commands"),
|
|
4218
|
-
extension: ".toml",
|
|
4219
|
-
write: (base, skillName, parsed) => {
|
|
4220
|
-
mkdirSync(base, { recursive: true });
|
|
4221
|
-
const target = join(base, `${skillName}.toml`);
|
|
4222
|
-
writeFileSync(target, renderSkillAsGeminiToml(parsed), "utf8");
|
|
4223
|
-
return target;
|
|
4224
|
-
}
|
|
4225
|
-
};
|
|
4226
|
-
}
|
|
4227
|
-
return undefined;
|
|
4228
|
-
}
|
|
4229
|
-
/**
|
|
4230
|
-
* Skill names shipped before DEC-057 that consolidated into a single `h2a`
|
|
4231
|
-
* skill. The installer removes them on a fresh install so users do not end up
|
|
4232
|
-
* with both the legacy three-skill bundle and the unified one.
|
|
4233
|
-
*/
|
|
4234
|
-
const LEGACY_SKILL_NAMES = ["h2a-connect", "h2a-discover", "h2a-send"];
|
|
4235
|
-
function pruneLegacy(spec, base) {
|
|
4236
|
-
const pruned = [];
|
|
4237
|
-
for (const legacyName of LEGACY_SKILL_NAMES) {
|
|
4238
|
-
if (spec.host === "gemini" || spec.host === "agy") {
|
|
4239
|
-
const file = join(base, `${legacyName}.toml`);
|
|
4240
|
-
if (existsSync(file)) {
|
|
4241
|
-
try {
|
|
4242
|
-
unlinkSync(file);
|
|
4243
|
-
pruned.push({ name: legacyName, path: file });
|
|
4244
|
-
}
|
|
4245
|
-
catch {
|
|
4246
|
-
// best-effort
|
|
4247
|
-
}
|
|
4248
|
-
}
|
|
4249
|
-
}
|
|
4250
|
-
else {
|
|
4251
|
-
const dir = join(base, legacyName);
|
|
4252
|
-
if (existsSync(dir)) {
|
|
4253
|
-
try {
|
|
4254
|
-
rmSync(dir, { recursive: true, force: true });
|
|
4255
|
-
pruned.push({ name: legacyName, path: dir });
|
|
4256
|
-
}
|
|
4257
|
-
catch {
|
|
4258
|
-
// best-effort
|
|
4259
|
-
}
|
|
4260
|
-
}
|
|
4261
|
-
}
|
|
4262
|
-
}
|
|
4263
|
-
return pruned;
|
|
4264
|
-
}
|
|
4265
|
-
function cmdInstallSkills(flags, streams) {
|
|
4266
|
-
const host = flags.host;
|
|
4267
|
-
if (!host) {
|
|
4268
|
-
streams.stderr.write("h2a install-skills: --host <claude|codex|gemini|agy> is required\n");
|
|
4269
|
-
return 1;
|
|
4270
|
-
}
|
|
4271
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
4272
|
-
const spec = targetSpecFor(host, cwd());
|
|
4273
|
-
if (!spec) {
|
|
4274
|
-
streams.stderr.write(`h2a install-skills: unknown --host "${host}". Supported: claude, codex, gemini, agy.\n`);
|
|
4275
|
-
return 1;
|
|
4276
|
-
}
|
|
4277
|
-
if (!existsSync(SKILLS_DIR)) {
|
|
4278
|
-
streams.stderr.write(`h2a install-skills: skills bundle missing at ${SKILLS_DIR}\n`);
|
|
4279
|
-
return 3;
|
|
4280
|
-
}
|
|
4281
|
-
const scope = flags.scope ?? "user";
|
|
4282
|
-
if (scope !== "user" && scope !== "project") {
|
|
4283
|
-
streams.stderr.write(`h2a install-skills: --scope must be 'user' or 'project'\n`);
|
|
4284
|
-
return 1;
|
|
4285
|
-
}
|
|
4286
|
-
const targetBase = scope === "user" ? spec.userBase : spec.projectBase;
|
|
4287
|
-
// DEC-057: prune the pre-consolidation skill names if they linger on disk.
|
|
4288
|
-
const prunedLegacy = pruneLegacy(spec, targetBase);
|
|
4289
|
-
const entries = readdirSync(SKILLS_DIR, { withFileTypes: true });
|
|
4290
|
-
const installed = [];
|
|
4291
|
-
const skipped = [];
|
|
4292
|
-
for (const entry of entries) {
|
|
4293
|
-
if (!entry.isDirectory())
|
|
4294
|
-
continue;
|
|
4295
|
-
const skillName = entry.name;
|
|
4296
|
-
const src = join(SKILLS_DIR, skillName, "SKILL.md");
|
|
4297
|
-
if (!existsSync(src))
|
|
4298
|
-
continue;
|
|
4299
|
-
let raw;
|
|
4300
|
-
try {
|
|
4301
|
-
raw = readFileSync(src, "utf8");
|
|
4302
|
-
}
|
|
4303
|
-
catch (error) {
|
|
4304
|
-
streams.stderr.write(`h2a install-skills: cannot read ${src} (${error.message})\n`);
|
|
4305
|
-
return 3;
|
|
4306
|
-
}
|
|
4307
|
-
let parsed;
|
|
4308
|
-
try {
|
|
4309
|
-
parsed = parseSkill(raw);
|
|
4310
|
-
}
|
|
4311
|
-
catch (error) {
|
|
4312
|
-
streams.stderr.write(`h2a install-skills: ${skillName}: ${error.message}\n`);
|
|
4313
|
-
return 2;
|
|
4314
|
-
}
|
|
4315
|
-
// Probe the would-be target file before writing so existing files are
|
|
4316
|
-
// surfaced as skipped (mirrors the previous Claude-only behavior).
|
|
4317
|
-
const probeTarget = spec.host === "gemini" || spec.host === "agy"
|
|
4318
|
-
? join(targetBase, `${skillName}.toml`)
|
|
4319
|
-
: join(targetBase, skillName, "SKILL.md");
|
|
4320
|
-
if (existsSync(probeTarget) && flags.force !== "true") {
|
|
4321
|
-
skipped.push({
|
|
4322
|
-
name: skillName,
|
|
4323
|
-
reason: `${probeTarget} already exists (use --force to overwrite)`
|
|
4324
|
-
});
|
|
4325
|
-
continue;
|
|
4326
|
-
}
|
|
4327
|
-
try {
|
|
4328
|
-
const targetPath = spec.write(targetBase, skillName, parsed, raw);
|
|
4329
|
-
installed.push(targetPath);
|
|
4330
|
-
}
|
|
4331
|
-
catch (error) {
|
|
4332
|
-
streams.stderr.write(`h2a install-skills: failed to write ${probeTarget} (${error.message})\n`);
|
|
4333
|
-
return 3;
|
|
4334
|
-
}
|
|
4335
|
-
}
|
|
4336
|
-
streams.stdout.write(`${JSON.stringify({
|
|
4337
|
-
ok: skipped.length === 0,
|
|
4338
|
-
host,
|
|
4339
|
-
scope,
|
|
4340
|
-
targetBase,
|
|
4341
|
-
installed,
|
|
4342
|
-
skipped,
|
|
4343
|
-
prunedLegacy,
|
|
4344
|
-
// DEC-101: agy has no own skill store — it imports from gemini/claude.
|
|
4345
|
-
// The TOML above is written to the shared ~/.gemini location; the user
|
|
4346
|
-
// then pulls it into agy with `agy plugin import gemini`.
|
|
4347
|
-
...(host === "agy"
|
|
4348
|
-
? { importHint: "agy plugin import gemini # then: agy plugin enable h2a" }
|
|
4349
|
-
: {})
|
|
4350
|
-
}, null, 2)}\n`);
|
|
4351
|
-
return skipped.length === 0 ? 0 : 2;
|
|
4352
|
-
}
|
|
4353
|
-
function cmdDeploy(argv, streams) {
|
|
4354
|
-
const { command: sub, flags } = parseFlags(argv);
|
|
4355
|
-
if (!sub) {
|
|
4356
|
-
streams.stderr.write("h2a deploy: subcommand required (supported: k8s-sidecar, k8s-tenant)\n");
|
|
4357
|
-
return 1;
|
|
4358
|
-
}
|
|
4359
|
-
if (sub === "k8s-tenant") {
|
|
4360
|
-
return cmdDeployTenant(flags, streams);
|
|
4361
|
-
}
|
|
4362
|
-
if (sub !== "k8s-sidecar") {
|
|
4363
|
-
streams.stderr.write(`h2a deploy: unknown subcommand "${sub}". Supported: k8s-sidecar, k8s-tenant.\n`);
|
|
4364
|
-
return 1;
|
|
4365
|
-
}
|
|
4366
|
-
// DEC-058: render the Kubernetes sidecar fragment for Scenario A
|
|
4367
|
-
// of DEC-056 (remote session sidecar).
|
|
4368
|
-
const fragment = renderK8sSidecar({
|
|
4369
|
-
instance: flags.instance,
|
|
4370
|
-
host: flags.host,
|
|
4371
|
-
root: flags.root,
|
|
4372
|
-
image: flags.image,
|
|
4373
|
-
cliVersion: flags["cli-version"]
|
|
4374
|
-
});
|
|
4375
|
-
const writeTo = flags.write;
|
|
4376
|
-
if (writeTo) {
|
|
4377
|
-
try {
|
|
4378
|
-
mkdirSync(dirname(writeTo), { recursive: true });
|
|
4379
|
-
writeFileSync(writeTo, fragment.yaml, "utf8");
|
|
4380
|
-
}
|
|
4381
|
-
catch (error) {
|
|
4382
|
-
streams.stderr.write(`h2a deploy k8s-sidecar: cannot write ${writeTo} (${error.message})\n`);
|
|
4383
|
-
return 3;
|
|
4384
|
-
}
|
|
4385
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, target: "k8s-sidecar", path: writeTo }, null, 2)}\n`);
|
|
4386
|
-
return 0;
|
|
4387
|
-
}
|
|
4388
|
-
// Default: emit a JSON resource envelope (DEC-034). Programmatic callers
|
|
4389
|
-
// can consume the structured `container` / `volume` / `volumeMount`
|
|
4390
|
-
// pieces; humans pipe `.yaml` to kubectl: `h2a deploy k8s-sidecar | jq -r .yaml`.
|
|
4391
|
-
streams.stdout.write(`${JSON.stringify({
|
|
4392
|
-
target: "k8s-sidecar",
|
|
4393
|
-
container: fragment.container,
|
|
4394
|
-
volume: fragment.volume,
|
|
4395
|
-
mainContainerVolumeMount: fragment.mainContainerVolumeMount,
|
|
4396
|
-
yaml: fragment.yaml
|
|
4397
|
-
}, null, 2)}\n`);
|
|
4398
|
-
return 0;
|
|
4399
|
-
}
|
|
4400
|
-
function cmdDeployTenant(flags, streams) {
|
|
4401
|
-
// DEC-067: render the full cluster-tenant manifest for Scenario B of DEC-056
|
|
4402
|
-
// (shared RWX store coordinated by the lease lock, DEC-065/066).
|
|
4403
|
-
const replicas = flags.replicas ? Number.parseInt(flags.replicas, 10) : undefined;
|
|
4404
|
-
if (flags.replicas && (!Number.isFinite(replicas) || replicas < 1)) {
|
|
4405
|
-
streams.stderr.write(`h2a deploy k8s-tenant: --replicas must be a positive integer (got "${flags.replicas}")\n`);
|
|
4406
|
-
return 1;
|
|
4407
|
-
}
|
|
4408
|
-
const leaseMs = flags["lease-ms"] ? Number.parseInt(flags["lease-ms"], 10) : undefined;
|
|
4409
|
-
if (flags["lease-ms"] && (!Number.isFinite(leaseMs) || leaseMs < 1)) {
|
|
4410
|
-
streams.stderr.write(`h2a deploy k8s-tenant: --lease-ms must be a positive integer (got "${flags["lease-ms"]}")\n`);
|
|
4411
|
-
return 1;
|
|
4412
|
-
}
|
|
4413
|
-
const manifest = renderK8sTenant({
|
|
4414
|
-
namespace: flags.namespace,
|
|
4415
|
-
root: flags.root,
|
|
4416
|
-
replicas,
|
|
4417
|
-
storage: flags.storage,
|
|
4418
|
-
storageClass: flags["storage-class"],
|
|
4419
|
-
leaseMs,
|
|
4420
|
-
image: flags.image,
|
|
4421
|
-
cliVersion: flags["cli-version"]
|
|
4422
|
-
});
|
|
4423
|
-
const writeTo = flags.write;
|
|
4424
|
-
if (writeTo) {
|
|
4425
|
-
try {
|
|
4426
|
-
mkdirSync(dirname(writeTo), { recursive: true });
|
|
4427
|
-
writeFileSync(writeTo, manifest.yaml, "utf8");
|
|
4428
|
-
}
|
|
4429
|
-
catch (error) {
|
|
4430
|
-
streams.stderr.write(`h2a deploy k8s-tenant: cannot write ${writeTo} (${error.message})\n`);
|
|
4431
|
-
return 3;
|
|
4432
|
-
}
|
|
4433
|
-
streams.stdout.write(`${JSON.stringify({ ok: true, target: "k8s-tenant", path: writeTo }, null, 2)}\n`);
|
|
4434
|
-
return 0;
|
|
4435
|
-
}
|
|
4436
|
-
streams.stdout.write(`${JSON.stringify({
|
|
4437
|
-
target: "k8s-tenant",
|
|
4438
|
-
documents: manifest.documents,
|
|
4439
|
-
yaml: manifest.yaml
|
|
4440
|
-
}, null, 2)}\n`);
|
|
4441
|
-
return 0;
|
|
4442
|
-
}
|
|
4443
|
-
// ── WP-5: keepalive ────────────────────────────────────────────────────────
|
|
4444
|
-
/**
|
|
4445
|
-
* Single-pass keepalive logic, testable without real tmux.
|
|
4446
|
-
*
|
|
4447
|
-
* For each presence file under `root`, if the session has a
|
|
4448
|
-
* `launchContext.tmux.pane` that is in `livePanes`, rewrite its `heartbeatAt`
|
|
4449
|
-
* to `now` so the session does not expire.
|
|
4450
|
-
*/
|
|
4451
|
-
export function keepaliveOnce(opts) {
|
|
4452
|
-
const { root, livePanes } = opts;
|
|
4453
|
-
const nowIso = (opts.now ?? new Date()).toISOString();
|
|
4454
|
-
const refreshed = [];
|
|
4455
|
-
// includeExpired=true so we can refresh sessions that are about to expire
|
|
4456
|
-
const sessions = listPresence(root, { includeExpired: true });
|
|
4457
|
-
for (const session of sessions) {
|
|
4458
|
-
const pane = session.launchContext?.tmux?.pane;
|
|
4459
|
-
if (!pane)
|
|
4460
|
-
continue;
|
|
4461
|
-
if (!livePanes.has(pane))
|
|
4462
|
-
continue;
|
|
4463
|
-
// Rewrite heartbeatAt to now
|
|
4464
|
-
const updated = { ...session, heartbeatAt: nowIso };
|
|
4465
|
-
try {
|
|
4466
|
-
writePresence(root, updated);
|
|
4467
|
-
refreshed.push({ instance: session.instance, sessionId: session.sessionId, pane });
|
|
4468
|
-
}
|
|
4469
|
-
catch {
|
|
4470
|
-
// best-effort — ignore write failures
|
|
4471
|
-
}
|
|
4472
|
-
}
|
|
4473
|
-
return refreshed;
|
|
4474
|
-
}
|
|
4475
|
-
/**
|
|
4476
|
-
* `h2a keepalive [--root <path>] [--interval <ms>] [--once]`
|
|
4477
|
-
*
|
|
4478
|
-
* External keepalive prober: runs by the launcher/remote so a host-suspended
|
|
4479
|
-
* `mcp-serve` still shows live as long as its tmux pane is alive. `--once`
|
|
4480
|
-
* does a single pass then exits 0. Without `--once`, loops on an unref'd
|
|
4481
|
-
* interval (default 30 000 ms).
|
|
4482
|
-
*/
|
|
4483
|
-
export async function cmdKeepalive(flags, streams) {
|
|
4484
|
-
const cwd = streams.cwd ?? (() => process.cwd());
|
|
4485
|
-
const root = resolveRoot(flags, cwd);
|
|
4486
|
-
const intervalMs = flags.interval ? Number.parseInt(flags.interval, 10) : 30_000;
|
|
4487
|
-
if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
|
|
4488
|
-
streams.stderr.write(`h2a keepalive: --interval must be >= 1000 ms (got "${flags.interval}")\n`);
|
|
4489
|
-
return 1;
|
|
4490
|
-
}
|
|
4491
|
-
function getLivePanes() {
|
|
4492
|
-
try {
|
|
4493
|
-
const out = execFileSync("tmux", ["list-panes", "-aF", "#{pane_id}"], {
|
|
4494
|
-
encoding: "utf8",
|
|
4495
|
-
timeout: 5000
|
|
4496
|
-
});
|
|
4497
|
-
const panes = new Set(out
|
|
4498
|
-
.split("\n")
|
|
4499
|
-
.map((l) => l.trim())
|
|
4500
|
-
.filter((l) => l.length > 0));
|
|
4501
|
-
return panes;
|
|
4502
|
-
}
|
|
4503
|
-
catch {
|
|
4504
|
-
streams.stderr.write("h2a keepalive: tmux not available or returned an error — treating live-pane set as empty.\n");
|
|
4505
|
-
return new Set();
|
|
4506
|
-
}
|
|
4507
|
-
}
|
|
4508
|
-
function runOnce() {
|
|
4509
|
-
const livePanes = getLivePanes();
|
|
4510
|
-
const refreshed = keepaliveOnce({ root, livePanes });
|
|
4511
|
-
for (const item of refreshed) {
|
|
4512
|
-
streams.stdout.write(`h2a keepalive: refreshed ${item.instance} (session ${item.sessionId}, pane ${item.pane})\n`);
|
|
4513
|
-
}
|
|
4514
|
-
}
|
|
4515
|
-
runOnce();
|
|
4516
|
-
if (flags.once !== undefined) {
|
|
4517
|
-
return 0;
|
|
4518
|
-
}
|
|
4519
|
-
// Long-running loop — unref the interval so it does not keep the process alive.
|
|
4520
|
-
return new Promise((resolve) => {
|
|
4521
|
-
const timer = setInterval(() => {
|
|
4522
|
-
runOnce();
|
|
4523
|
-
}, intervalMs);
|
|
4524
|
-
timer.unref();
|
|
4525
|
-
// The process will exit naturally when there is nothing else keeping it alive.
|
|
4526
|
-
// For tests that want to abort, they should pass --once.
|
|
4527
|
-
void resolve; // keep linter happy
|
|
4528
|
-
});
|
|
4529
|
-
}
|
|
4530
|
-
export function runCli(argv = process.argv.slice(2), streams = {
|
|
4531
|
-
stdout: process.stdout,
|
|
4532
|
-
stderr: process.stderr
|
|
4533
|
-
}) {
|
|
4534
|
-
const { command, flags } = parseFlags(argv);
|
|
4535
|
-
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
4536
|
-
streams.stdout.write(`${renderCliHelp()}\n`);
|
|
4537
|
-
return 0;
|
|
4538
|
-
}
|
|
4539
|
-
if (command === "hosts") {
|
|
4540
|
-
streams.stdout.write(`${JSON.stringify(CLI_HOSTS, null, 2)}\n`);
|
|
4541
|
-
return 0;
|
|
4542
|
-
}
|
|
4543
|
-
if (command === "mcp-tools") {
|
|
4544
|
-
streams.stdout.write(`${JSON.stringify(H2A_CLI_MCP_TOOL_NAMES, null, 2)}\n`);
|
|
4545
|
-
return 0;
|
|
4546
|
-
}
|
|
4547
|
-
if (command === "init")
|
|
4548
|
-
return cmdInit(flags, streams);
|
|
4549
|
-
if (command === "register")
|
|
4550
|
-
return cmdRegister(flags, streams);
|
|
4551
|
-
if (command === "discover")
|
|
4552
|
-
return cmdDiscover(flags, streams);
|
|
4553
|
-
if (command === "subagent")
|
|
4554
|
-
return cmdSubagent(argv.slice(1), streams);
|
|
4555
|
-
if (command === "loop")
|
|
4556
|
-
return cmdLoop(argv.slice(1), streams);
|
|
4557
|
-
if (command === "drumbeat") {
|
|
4558
|
-
const sub = argv[1];
|
|
4559
|
-
if (sub === "watch") {
|
|
4560
|
-
streams.stderr.write("h2a drumbeat watch: async daemon — run via the h2a binary, not the synchronous API.\n");
|
|
4561
|
-
return 1;
|
|
4562
|
-
}
|
|
4563
|
-
return cmdDrumbeat(argv.slice(1), streams);
|
|
4564
|
-
}
|
|
4565
|
-
if (command === "upgrade")
|
|
4566
|
-
return cmdUpgrade(flags, streams);
|
|
4567
|
-
if (command === "nhi")
|
|
4568
|
-
return cmdNhi(argv.slice(1), streams);
|
|
4569
|
-
if (command === "org")
|
|
4570
|
-
return cmdOrg(argv.slice(1), streams);
|
|
4571
|
-
if (command === "coach")
|
|
4572
|
-
return cmdCoach(argv.slice(1), streams);
|
|
4573
|
-
if (command === "blockage")
|
|
4574
|
-
return cmdBlockage(argv.slice(1), streams);
|
|
4575
|
-
if (command === "sysml") {
|
|
4576
|
-
const sub = argv[1];
|
|
4577
|
-
if (sub === "verify") {
|
|
4578
|
-
streams.stderr.write("h2a sysml verify: async verb — run via the h2a binary, not the synchronous API.\n");
|
|
4579
|
-
return 1;
|
|
4580
|
-
}
|
|
4581
|
-
streams.stderr.write("h2a sysml: subcommand required (verify).\n");
|
|
4582
|
-
return 1;
|
|
4583
|
-
}
|
|
4584
|
-
if (command === "negotiate")
|
|
4585
|
-
return cmdNegotiate(argv.slice(1), streams);
|
|
4586
|
-
if (command === "declare-interest")
|
|
4587
|
-
return cmdDeclareInteret(flags, streams);
|
|
4588
|
-
if (command === "conflict-posture")
|
|
4589
|
-
return cmdConflictPosture(flags, streams);
|
|
4590
|
-
if (command === "dossier")
|
|
4591
|
-
return cmdDossier(flags, streams);
|
|
4592
|
-
if (command === "confiance")
|
|
4593
|
-
return cmdConfiance(flags, streams);
|
|
4594
|
-
if (command === "attest-comprehension")
|
|
4595
|
-
return cmdAttestComprehension(flags, streams);
|
|
4596
|
-
if (command === "comprehension")
|
|
4597
|
-
return cmdComprehension(argv.slice(1), streams);
|
|
4598
|
-
if (command === "inbox")
|
|
4599
|
-
return cmdMailbox(argv.slice(1), "inbox", streams);
|
|
4600
|
-
if (command === "outbox")
|
|
4601
|
-
return cmdMailbox(argv.slice(1), "outbox", streams);
|
|
4602
|
-
if (command === "host")
|
|
4603
|
-
return cmdHost(argv.slice(1), streams);
|
|
4604
|
-
if (command === "store")
|
|
4605
|
-
return cmdStore(argv.slice(1), streams);
|
|
4606
|
-
if (command === "thread")
|
|
4607
|
-
return cmdThread(flags, streams);
|
|
4608
|
-
if (command === "sessions")
|
|
4609
|
-
return cmdSessions(flags, streams);
|
|
4610
|
-
if (command === "status")
|
|
4611
|
-
return cmdStatus(flags, streams);
|
|
4612
|
-
if (command === "doctor")
|
|
4613
|
-
return cmdDoctor(flags, streams);
|
|
4614
|
-
if (command === "presence-reap")
|
|
4615
|
-
return cmdPresenceReap(flags, streams);
|
|
4616
|
-
if (command === "connect")
|
|
4617
|
-
return cmdConnect(flags, streams);
|
|
4618
|
-
if (command === "conductor")
|
|
4619
|
-
return cmdConductor(argv.slice(1), streams);
|
|
4620
|
-
if (command === "conductor-launch-check")
|
|
4621
|
-
return cmdConductorLaunchCheck(argv.slice(1), streams);
|
|
4622
|
-
if (command === "conductor-launch")
|
|
4623
|
-
return cmdConductorLaunch(argv.slice(1), streams);
|
|
4624
|
-
if (command === "wake-request")
|
|
4625
|
-
return cmdWakeRequest(argv.slice(1), streams);
|
|
4626
|
-
if (command === "keepalive") {
|
|
4627
|
-
streams.stderr.write("h2a keepalive: async command — run via the h2a binary, not the synchronous API.\n");
|
|
4628
|
-
return 1;
|
|
4629
|
-
}
|
|
4630
|
-
if (command === "install-skills")
|
|
4631
|
-
return cmdInstallSkills(flags, streams);
|
|
4632
|
-
if (command === "deploy")
|
|
4633
|
-
return cmdDeploy(argv.slice(1), streams);
|
|
4634
|
-
if (command === "drive") {
|
|
4635
|
-
if (argv[1] === "receive") {
|
|
4636
|
-
return cmdDriveReceive(parseFlags(argv.slice(1)).flags, streams);
|
|
4637
|
-
}
|
|
4638
|
-
if (argv[1] === "serve") {
|
|
4639
|
-
streams.stderr.write("h2a drive serve: async verb — run via the h2a binary, not the synchronous API.\n");
|
|
4640
|
-
return 1;
|
|
4641
|
-
}
|
|
4642
|
-
return cmdDrive(flags, streams);
|
|
4643
|
-
}
|
|
4644
|
-
if (command === "remote") {
|
|
4645
|
-
// `remote serve`/`remote send` are async and dispatched from bin.ts; if we
|
|
4646
|
-
// reach here it is a misuse (e.g. via the sync runCli) or an unknown sub.
|
|
4647
|
-
const sub = argv[1];
|
|
4648
|
-
if (sub === "serve" || sub === "send") {
|
|
4649
|
-
streams.stderr.write(`h2a remote ${sub}: async verb — run via the h2a binary, not the synchronous API.\n`);
|
|
4650
|
-
return 1;
|
|
4651
|
-
}
|
|
4652
|
-
streams.stderr.write(`h2a remote: subcommand required (serve, send).\n`);
|
|
4653
|
-
return 1;
|
|
4654
|
-
}
|
|
4655
|
-
if (command === "keys") {
|
|
4656
|
-
const { command: sub, flags: subFlags } = parseFlags(argv.slice(1));
|
|
4657
|
-
if (sub === "generate")
|
|
4658
|
-
return cmdKeysGenerate(subFlags, streams);
|
|
4659
|
-
if (sub === "add")
|
|
4660
|
-
return cmdKeysAdd(subFlags, streams);
|
|
4661
|
-
if (sub === "list")
|
|
4662
|
-
return cmdKeysList(subFlags, streams);
|
|
4663
|
-
if (sub === "revoke")
|
|
4664
|
-
return cmdKeysRevoke(subFlags, streams);
|
|
4665
|
-
streams.stderr.write(`h2a keys: unknown subcommand "${sub ?? ""}" (generate, add, list, revoke)\n`);
|
|
4666
|
-
return 1;
|
|
4667
|
-
}
|
|
4668
|
-
streams.stderr.write(`Unknown command: ${command}\n`);
|
|
4669
|
-
streams.stderr.write("Run `h2a --help`.\n");
|
|
4670
|
-
return 1;
|
|
4671
|
-
}
|
|
4672
|
-
//# sourceMappingURL=cli.js.map
|