@agfpd/iapeer 0.4.115 → 0.4.116
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/docs/15-fleet-api.md +2 -1
- package/docs/20-opencode.md +103 -6
- package/docs/21-capabilities.md +83 -0
- package/docs/README.md +2 -0
- package/package.json +1 -1
- package/src/approval/install.ts +8 -0
- package/src/capability/cli.ts +106 -0
- package/src/capability/index.ts +160 -0
- package/src/cli/index.ts +75 -6
- package/src/core/constants.ts +1 -1
- package/src/daemon/fleet.ts +9 -0
- package/src/enable/provisionCommand.ts +12 -3
- package/src/identity/index.ts +1 -1
- package/src/init/index.ts +40 -28
- package/src/launch/adapters/opencode.ts +192 -0
- package/src/launch/index.ts +29 -2
- package/src/launch/types.ts +42 -4
- package/src/lifecycle/index.ts +13 -6
- package/src/opencode/config.ts +24 -5
- package/src/opencode/control.ts +1 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.mjs +79 -0
- package/src/opencode/journal.ts +143 -0
- package/src/provision/index.ts +31 -1
- package/src/storage/index.ts +50 -0
- package/src/supervisor/daemon.ts +23 -1
- package/src/transport/index.ts +29 -2
package/docs/15-fleet-api.md
CHANGED
|
@@ -39,7 +39,7 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
39
39
|
"memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
|
|
40
40
|
"voice": { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
|
|
41
41
|
"fda": true,
|
|
42
|
-
"agent_runtimes": ["claude", "codex"]
|
|
42
|
+
"agent_runtimes": ["claude", "codex", "opencode"]
|
|
43
43
|
},
|
|
44
44
|
"peers": [
|
|
45
45
|
{
|
|
@@ -79,6 +79,7 @@ Field notes:
|
|
|
79
79
|
|
|
80
80
|
- `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
|
|
81
81
|
- `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
|
|
82
|
+
- `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
|
|
82
83
|
- `host.agent_runtimes` — the agent runtimes this core build can host, in preference order. It grows when an agent adapter ships (e.g. `opencode`, docs/20).
|
|
83
84
|
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
|
|
84
85
|
- `runtimes[].activity` — **turn activity**: is this runtime *in a turn* right now. See «Activity» below. Additive; absent on a pre-activity daemon, on every non-`live` runtime, and whenever the daemon has no evidence — clients MUST treat absence as **no claim**, never as `idle`.
|
package/docs/20-opencode.md
CHANGED
|
@@ -37,7 +37,15 @@ as a silent no-op.
|
|
|
37
37
|
- Existing slot `provision-peer --personality <p> --runtime opencode --cwd <cwd>`.
|
|
38
38
|
- Canonical child ABI: `PEER_PERSONALITY`, `PEER_RUNTIME=opencode`,
|
|
39
39
|
`PEER_IDENTITY=opencode-<p>`; `IAPEER_IDENTITY` is an alias set by the core.
|
|
40
|
-
- Private directory: `<IAPEER_ROOT>/
|
|
40
|
+
- Private directory: `<IAPEER_ROOT>/state/opencode/<p>/` (default root `~/.iapeer`).
|
|
41
|
+
It lives under the core's `state/`, never in the agent's working tree (the default
|
|
42
|
+
peer cwd is `<root>/peers/<p>`). Moved there from `<root>/peers/<p>/opencode/` on
|
|
43
|
+
2026-10-17, before any release (no producer ever wrote the old path; it is not
|
|
44
|
+
read or migrated).
|
|
45
|
+
Every consumer gets it from the core and never computes it: provider and
|
|
46
|
+
capability commands as `{privateDir}` / `IAPEER_PRIVATE_DIR` (docs/21); observers
|
|
47
|
+
the core does not launch (console, collectors, sweeps) from the fleet snapshot
|
|
48
|
+
`runtimes[].private_dir` (docs/15) or `iapeer private-dir <p> [--runtime opencode]`.
|
|
41
49
|
Memory owns `iapeer-memory.json` and `iapeer-memory-plugin.mjs` there. The core
|
|
42
50
|
reads the JSON fragment on each launch and merges its `mcp` and `plugin` fields
|
|
43
51
|
into runtime overrides. Missing means no memory surfaces; malformed/present
|
|
@@ -58,7 +66,7 @@ a synthetic fixture as proof of the producer; the core ships a native source har
|
|
|
58
66
|
The core is the **single producer**. Consumers read only these files, never the
|
|
59
67
|
native OpenCode SQLite DB or a second SDK client:
|
|
60
68
|
|
|
61
|
-
- `<IAPEER_ROOT>/
|
|
69
|
+
- `<IAPEER_ROOT>/state/opencode/<p>/events.jsonl` — append-only journal, one JSON
|
|
62
70
|
object per line:
|
|
63
71
|
`{schema:1, seq, time:<epoch ms>, identity:"opencode-<p>", launchID,
|
|
64
72
|
sessionID:<native ses_…|null>, type, properties, nativeID:<event.id|null>}`.
|
|
@@ -76,11 +84,15 @@ native OpenCode SQLite DB or a second SDK client:
|
|
|
76
84
|
with a new `launchID`, including resume, so the main session is recoverable from
|
|
77
85
|
the journal alone. A launch that fails before readiness writes `runtime.error`
|
|
78
86
|
`{error}` under its `launchID` instead.
|
|
79
|
-
- Consumers: web-runtime, telegram-runtime
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
- Consumers: web-runtime, telegram-runtime and iapeer-memory's weekly collector (it
|
|
88
|
+
relies on the path, `time`, unchanged `type`/`properties` and no rotation).
|
|
89
|
+
statusline-injector reads in-process (its own plugin hook, which also receives the
|
|
90
|
+
context window), so the journal is optional for it. Any ABI change is announced
|
|
91
|
+
to all four.
|
|
92
|
+
- Per-peer plugin fragments merged into the launch config are the memory provider's
|
|
93
|
+
plus those of every registered capability (docs/21). No plugin list is hardcoded.
|
|
82
94
|
- Native bootstrap snapshots can repeat part ids, so consumers must **upsert parts by id**.
|
|
83
|
-
- `<IAPEER_ROOT>/
|
|
95
|
+
- `<IAPEER_ROOT>/state/opencode/<p>/state.json`, written atomically:
|
|
84
96
|
`{schema:1, identity, cwd, sessionID, launchID, startedAt, lastEventSeq,
|
|
85
97
|
lastActivityMs, status:"starting|idle|busy|approval|error|stopped", error?}`.
|
|
86
98
|
- `lastEventSeq` is a fence: every journal line with `seq ≤ lastEventSeq` is
|
|
@@ -217,6 +229,91 @@ Native findings that bind the adapter:
|
|
|
217
229
|
`dispose` does not run on a signal, so `runtime.stopped` for a signal death is the
|
|
218
230
|
supervisor's job.
|
|
219
231
|
|
|
232
|
+
## Adapter under the pty supervisor — 2026-10-17
|
|
233
|
+
|
|
234
|
+
`src/launch/adapters/opencode.ts` implements the structural adapter hooks
|
|
235
|
+
(`RuntimeAdapter.prepareLaunch / structuralReady / deliver / control`,
|
|
236
|
+
`deliveryConfirm: 'structural'`). Nothing reads the screen:
|
|
237
|
+
- `prepareLaunch` writes the plugin, re-composes `OPENCODE_CONFIG_CONTENT` from
|
|
238
|
+
the freshly composed doctrine on every launch (resume included) and sets the
|
|
239
|
+
plugin env with a new `launchID`;
|
|
240
|
+
- readiness is the `runtime.ready` line of **that** `launchID` written after the
|
|
241
|
+
prepare-time journal offset, plus a control ping answering the same `launchID`;
|
|
242
|
+
- the first and every warm message go through `deliver`, whose ack means the
|
|
243
|
+
message is persisted natively; there is no composer hold and no pty paste;
|
|
244
|
+
- `interrupt` is native abort; other control commands fail loud;
|
|
245
|
+
- resume reads `state.json` `sessionID`; idle accounting reads
|
|
246
|
+
`state.lastActivityMs` (busy/approval count as activity now);
|
|
247
|
+
- the supervisor records `runtime.stopped` + `status:stopped` on child exit and on
|
|
248
|
+
teardown (idempotent with a graceful dispose). Teardown SIGKILLs the child.
|
|
249
|
+
|
|
250
|
+
`scripts/opencode-supervised-harness.ts` drives the real `launch()` with the
|
|
251
|
+
native TUI hosted by the supervisor (isolated HOME/root, synthetic provider).
|
|
252
|
+
Run of 2026-10-17 on native 1.19.3: 16/16 — cold READY in 3.5 s with the
|
|
253
|
+
first message delivered; doctrine and native `AGENTS.md` on the wire; transport
|
|
254
|
+
warm delivery idle and busy (busy acked in 6 ms, processed after the slow turn);
|
|
255
|
+
interrupt; unsupported control refused; the TUI renders the core session;
|
|
256
|
+
teardown → `runtime.stopped`; contiguous `seq`; resume on the same session with
|
|
257
|
+
`resumed=true`, the new doctrine replacing the old, history kept.
|
|
258
|
+
|
|
259
|
+
`scripts/opencode-wake-harness.ts` goes through the fleet lifecycle with a
|
|
260
|
+
registered peer (`initPeer --runtime opencode`). Run of 2026-10-17: 9/9 — cold
|
|
261
|
+
wake by a message (READY in 2.6 s, task delivered as the first message); the
|
|
262
|
+
composed doctrine (identity YAML + local `IAPEER.md`) is the system prompt; a
|
|
263
|
+
wake on a live session is idempotent; the supervise tick idle-reaps from the
|
|
264
|
+
plugin state; the next message on the same topic resumes the same native session
|
|
265
|
+
with the re-composed doctrine and earlier history. A topicless message after an
|
|
266
|
+
idle-reap wakes fresh — core executor policy, identical for every runtime.
|
|
267
|
+
|
|
268
|
+
`initPeer --runtime opencode` (create/add-runtime) first satisfies the native
|
|
269
|
+
plugin-dependency no-op in every OpenCode config dir (`src/opencode/deps.ts`:
|
|
270
|
+
`node_modules` present and `@opencode-ai/plugin` in the lock root, pinned to
|
|
271
|
+
`opencode --version`). A failure aborts before the runtime is registered. Real
|
|
272
|
+
npm, isolated HOME: installed in 11 s, the repeat is `present` with no network.
|
|
273
|
+
|
|
274
|
+
`scripts/opencode-mcp-harness.ts` runs a sandboxed daemon in-process with two
|
|
275
|
+
registered opencode peers. Run of 2026-10-17: 10/10. The core MCP is imported
|
|
276
|
+
(`iapeer_send_to_peer` offered next to native `bash`); the model's native `bash`
|
|
277
|
+
call runs; its `send_to_peer` call reaches the daemon with OpenCode's identity
|
|
278
|
+
header, wakes the receiver on miss and delivers through the structural path. The
|
|
279
|
+
receiver sees `<iap from="ocsend" runtime="opencode" …>`, the tool result is
|
|
280
|
+
`ok` with `delivered_to.runtime: opencode`, the sender's journal has completed
|
|
281
|
+
tool parts, and `delivery.log` records the send.
|
|
282
|
+
|
|
283
|
+
Gated approval (OC10). Native 1.19.3 declares but never calls the
|
|
284
|
+
`permission.ask` plugin hook, so the bridge rides the `permission.asked` event.
|
|
285
|
+
Under `approval_mode: gated` the launch omits `--auto`, sets agent-level ask
|
|
286
|
+
rules (`bash`, `edit`, `webfetch`, `external_directory`, `doom_loop` — reads,
|
|
287
|
+
search and the core MCP stay allowed, as on gated claude; agent-level so an
|
|
288
|
+
operator `permission: {"*":"allow"}` cannot undo them), and the plugin POSTs
|
|
289
|
+
each native ask to `/fleet/v1/approvals` (`runtime: opencode`, `tool` = native
|
|
290
|
+
permission, summary = permission + patterns), then answers natively
|
|
291
|
+
(`once`/`reject`). Every failure direction rejects. A human who answers the
|
|
292
|
+
native TUI prompt first wins: `permission.replied` aborts the broker request and
|
|
293
|
+
the card is withdrawn from every face. `approval-mode` reports a launch-time
|
|
294
|
+
surface only (nothing persistent). `scripts/opencode-gated-harness.ts`, run of
|
|
295
|
+
2026-10-17: 13/13 — allow runs the command, deny does not and the model sees the
|
|
296
|
+
rejection, broker timeout default-denies and clears the card, a TUI answer
|
|
297
|
+
withdraws the card with exactly one native reply, `approvals.log` audits each.
|
|
298
|
+
|
|
299
|
+
Observation (not closed): on startup the native TUI may detect its install method
|
|
300
|
+
by probing package managers (`npm/yarn/pnpm/bun list -g`, `brew list`) unless
|
|
301
|
+
the binary lives in `~/.opencode/bin` or `~/.local/bin`. With a fresh HOME a
|
|
302
|
+
`brew` probe auto-updates over the network. The adapter sets
|
|
303
|
+
`OPENCODE_DISABLE_AUTOUPDATE=1`. **Install OpenCode only with the official
|
|
304
|
+
installer** (binary in `~/.opencode/bin`): that location skips the probes.
|
|
305
|
+
|
|
306
|
+
Still not proved here: a live daemon on the host,
|
|
307
|
+
approval routing, memory slot, two simultaneous runtimes of one personality.
|
|
308
|
+
Since the commit after this proof, `opencode` is a supported local agent runtime:
|
|
309
|
+
listed in `host.agent_runtimes` (docs/15), accepted by `add-runtime`/`create
|
|
310
|
+
--runtime opencode`, gated to artificial peers, refreshed by `refresh`, and
|
|
311
|
+
included in the memory provider's provision/unprovision runs and in capability
|
|
312
|
+
applicability (docs/21). `create` does NOT auto-add it as a secondary runtime
|
|
313
|
+
(an endpoint without a provider/model configuration would be dead) — it is an
|
|
314
|
+
explicit opt-in. The binary resolves from `IAPEER_OPENCODE_BIN`, else
|
|
315
|
+
`~/.opencode/bin/opencode`, else `opencode` on PATH.
|
|
316
|
+
|
|
220
317
|
## Delivery gate
|
|
221
318
|
|
|
222
319
|
Source changes and isolated native artifacts are allowed. npm publication and host
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# 21 — Capability-plugin lifecycle
|
|
2
|
+
|
|
3
|
+
A **capability plugin** adds per-peer surfaces to a peer's agent runtime: hooks, a
|
|
4
|
+
private config fragment, an MCP entry (examples: statusline-injector, totp-presence).
|
|
5
|
+
Like the memory slot, the **core owns when and for whom** these surfaces are provisioned
|
|
6
|
+
and the **plugin owns what** they are. The core shells into two declared commands and
|
|
7
|
+
knows nothing about surface forms.
|
|
8
|
+
|
|
9
|
+
## Declaration
|
|
10
|
+
|
|
11
|
+
`<IAPEER_ROOT>/capabilities/<name>.json` (default root `~/.iapeer`). The plugin never
|
|
12
|
+
writes this file directly. Its installer runs `iapeer capability register <file>`, which
|
|
13
|
+
validates the declaration and stores it.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"schema": 1,
|
|
18
|
+
"capability": "statusline-injector",
|
|
19
|
+
"package": "@agfpd/statusline-injector",
|
|
20
|
+
"version": "1.2.3",
|
|
21
|
+
"runtimes": ["claude", "codex", "opencode"],
|
|
22
|
+
"provision": { "command": "/abs/path/bin", "args": ["provision-peer", "--personality", "{personality}", "--runtime", "{runtime}", "--cwd", "{cwd}", "--occasion", "{occasion}"] },
|
|
23
|
+
"unprovision": { "command": "/abs/path/bin", "args": ["unprovision-peer", "--personality", "{personality}", "--runtime", "{runtime}", "--cwd", "{cwd}", "--occasion", "{occasion}"] }
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Validation rules. A violation refuses `register`; an invalid file found on disk is
|
|
28
|
+
reported loudly and never skipped silently.
|
|
29
|
+
- `schema` is `1`. `capability` is a valid name and equals the file stem.
|
|
30
|
+
- `runtimes` is a non-empty list of the agent runtimes the plugin supports.
|
|
31
|
+
- `command` is an **absolute** path, because launchd's minimal PATH resolves nothing.
|
|
32
|
+
- `args` is an argv tail. `{personality}`, `{runtime}`, `{cwd}` and `{occasion}` are
|
|
33
|
+
substituted per argument, and **no shell** is involved.
|
|
34
|
+
- `{privateDir}` is the core-owned private directory of the endpoint (OpenCode:
|
|
35
|
+
`<root>/state/opencode/<p>/`). It is also exported as `IAPEER_PRIVATE_DIR` to the
|
|
36
|
+
command for every runtime that has one. Plugins take the directory from the core
|
|
37
|
+
and never compute the layout themselves. `{privateDir}` on a runtime without one
|
|
38
|
+
is refused (`failed`) before spawn. A command that spans runtimes reads the env
|
|
39
|
+
variable instead.
|
|
40
|
+
- Both commands must carry `{personality}`. Several personalities can share one cwd,
|
|
41
|
+
and a command that cannot tell them apart would touch another peer's surfaces.
|
|
42
|
+
|
|
43
|
+
## Joints (core-owned)
|
|
44
|
+
|
|
45
|
+
An **endpoint** is one `(peer, runtime)` pair where the runtime is:
|
|
46
|
+
- declared by the peer;
|
|
47
|
+
- hostable as an agent by this core (`host.agent_runtimes`, docs/15);
|
|
48
|
+
- listed in the declaration's `runtimes`.
|
|
49
|
+
|
|
50
|
+
Channel runtimes (telegram, web, …) are never endpoints.
|
|
51
|
+
|
|
52
|
+
| Joint | Command | `{occasion}` |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Peer birth: `create`, `init`, `add-runtime`, and `default-runtime` convergence onto an unwired runtime | provision for each endpoint | `birth` |
|
|
55
|
+
| `iapeer capability register` / `sync <name>` (sync is the repair) | provision for every endpoint of every registered peer | `sweep-on` |
|
|
56
|
+
| `iapeer remove <peer>` | unprovision for each endpoint, before state purge | `remove` |
|
|
57
|
+
| `iapeer capability unregister <name>` | unprovision for every endpoint, then the declaration is deleted | `off-all` |
|
|
58
|
+
|
|
59
|
+
Timeout per call: 120 s. Every call is logged to `lifecycle.log` as
|
|
60
|
+
`ev=capability-provision capability=<name> identity=<runtime>-<peer> occasion=… state=…`.
|
|
61
|
+
Failure handling:
|
|
62
|
+
- At birth and remove, a failure is a loud warning and the flow continues. A plugin
|
|
63
|
+
hiccup never kills a birth or blocks a removal.
|
|
64
|
+
- For the sweep verbs, failures are listed per endpoint and the exit code is 1.
|
|
65
|
+
|
|
66
|
+
## Plugin obligations
|
|
67
|
+
|
|
68
|
+
- **Idempotent** by construction. The core may repeat `birth`/`sweep-on` for an
|
|
69
|
+
already-provisioned endpoint, for example on add-runtime repair or `sync`.
|
|
70
|
+
- Surfaces are scoped by **personality and runtime**, not by cwd alone. For opencode
|
|
71
|
+
they live in the peer's private directory `<root>/state/opencode/<p>/`, and the core
|
|
72
|
+
merges `<name>.json` fragments from there (docs/20).
|
|
73
|
+
- Exit 0 only when the surfaces are in place. stderr is the failure detail the operator
|
|
74
|
+
sees.
|
|
75
|
+
- On `remove` / `off-all`, remove only this plugin's surfaces for that endpoint.
|
|
76
|
+
- Hold your own lock against concurrent invocations.
|
|
77
|
+
|
|
78
|
+
## Install / uninstall of the plugin package
|
|
79
|
+
|
|
80
|
+
- Install: install the package, then `iapeer capability register <decl.json>`. This
|
|
81
|
+
provisions every existing peer.
|
|
82
|
+
- Update: re-register with the new version. Registration is idempotent and re-sweeps.
|
|
83
|
+
- Uninstall: `iapeer capability unregister <name>`, then remove the package.
|
package/docs/README.md
CHANGED
|
@@ -81,6 +81,8 @@ The sections are layered: from "install and use" to "understand the architecture
|
|
|
81
81
|
- [17 — Human approval](17-approval.md) — per-peer gated/yolo mode: routing a runtime's blocking approvals to a human through the daemon broker (hook interception, the broker, Fleet API + CLI)
|
|
82
82
|
- [18 — Origin guard](18-origin-guard.md) — an agent's reply to a human must target the channel the human wrote from: the armed-pair model, the hold, `confirm-send`
|
|
83
83
|
- [19 — Notices](19-notices.md) — telling the owner a peer went mute: detecting the structural API error that leaves a session alive, green and speechless, and routing it to the owner's surfaces one-way (not an approval)
|
|
84
|
+
- [20 — OpenCode agent runtime](20-opencode.md) — acceptance contract, memory and observability ABI, core plugin, native harness
|
|
85
|
+
- [21 — Capability-plugin lifecycle](21-capabilities.md) — declaration and core-owned provision/unprovision joints for per-peer plugin surfaces
|
|
84
86
|
|
|
85
87
|
## Status
|
|
86
88
|
|
package/package.json
CHANGED
package/src/approval/install.ts
CHANGED
|
@@ -203,6 +203,14 @@ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.Pro
|
|
|
203
203
|
? `codex: native modal + supervisor proxy, no broker hook${removed ? ' (cleared prior hook)' : ''}`
|
|
204
204
|
: `codex: ${removed ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`,
|
|
205
205
|
)
|
|
206
|
+
} else if (rt === 'opencode') {
|
|
207
|
+
// No persistent surface: argv (--auto), the agent's ask rules and the plugin's broker bridge are
|
|
208
|
+
// all composed at launch from this profile field (docs/20 OC10).
|
|
209
|
+
surfaces.push(
|
|
210
|
+
mode === 'gated'
|
|
211
|
+
? 'opencode: launch-time ask rules + plugin broker bridge (no persistent surface)'
|
|
212
|
+
: 'opencode: launch-time --auto (no persistent surface)',
|
|
213
|
+
)
|
|
206
214
|
}
|
|
207
215
|
}
|
|
208
216
|
return { mode, surfaces }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `iapeer capability <list|register|unregister|sync>` — the operator/installer surface
|
|
2
|
+
// of the capability-plugin lifecycle (docs/21-capabilities.md).
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
5
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
6
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
7
|
+
import type { ProvisionOccasion } from '../enable/provisionCommand.ts'
|
|
8
|
+
import {
|
|
9
|
+
capabilityEndpoints,
|
|
10
|
+
listCapabilities,
|
|
11
|
+
parseCapabilityDeclaration,
|
|
12
|
+
removeCapabilityDeclaration,
|
|
13
|
+
runCapabilityLifecycle,
|
|
14
|
+
writeCapabilityDeclaration,
|
|
15
|
+
type CapabilityDeclaration,
|
|
16
|
+
} from './index.ts'
|
|
17
|
+
|
|
18
|
+
export interface CapabilitySweepLine {
|
|
19
|
+
personality: string
|
|
20
|
+
runtime: string
|
|
21
|
+
state: string
|
|
22
|
+
detail?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run one command of ONE capability across every registered peer's applicable endpoints. */
|
|
26
|
+
export async function sweepCapability(
|
|
27
|
+
decl: CapabilityDeclaration,
|
|
28
|
+
kind: 'provision' | 'unprovision',
|
|
29
|
+
occasion: ProvisionOccasion,
|
|
30
|
+
env: NodeJS.ProcessEnv,
|
|
31
|
+
): Promise<CapabilitySweepLine[]> {
|
|
32
|
+
const lines: CapabilitySweepLine[] = []
|
|
33
|
+
for (const p of readPeersIndex({ env }).peers) {
|
|
34
|
+
if (capabilityEndpoints(decl, p.runtimes).length === 0) continue
|
|
35
|
+
for (const run of runCapabilityLifecycle({ kind, personality: p.personality, cwd: p.cwd, runtimes: p.runtimes, occasion, env, only: decl })) {
|
|
36
|
+
const o = run.outcome
|
|
37
|
+
appendLifecycleEvent(
|
|
38
|
+
pluginLogsDir('iapeer', { env }),
|
|
39
|
+
{ ev: 'capability-provision', capability: decl.capability, identity: `${run.runtime}-${p.personality}`, occasion, state: o.state, exit: o.exitCode ?? undefined, ms: o.durationMs, detail: o.detail },
|
|
40
|
+
{ env },
|
|
41
|
+
)
|
|
42
|
+
lines.push({ personality: p.personality, runtime: run.runtime, state: o.state, ...(o.state !== 'ok' && o.detail ? { detail: o.detail } : {}) })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return lines
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function find(name: string, env: NodeJS.ProcessEnv): CapabilityDeclaration {
|
|
49
|
+
const decl = listCapabilities({ env }).declarations.find(d => d.capability === name)
|
|
50
|
+
if (!decl) throw new Error(`capability "${name}" is not registered`)
|
|
51
|
+
return decl
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns the process exit code. */
|
|
55
|
+
export async function capabilityCli(
|
|
56
|
+
positionals: string[],
|
|
57
|
+
env: NodeJS.ProcessEnv,
|
|
58
|
+
out: (s: string) => void,
|
|
59
|
+
errOut: (s: string) => void,
|
|
60
|
+
): Promise<number> {
|
|
61
|
+
const [sub, arg] = positionals
|
|
62
|
+
const report = (lines: CapabilitySweepLine[]): number => {
|
|
63
|
+
for (const l of lines) out(`${l.personality} (${l.runtime}): ${l.state}${l.detail ? ` — ${l.detail}` : ''}\n`)
|
|
64
|
+
if (lines.length === 0) out('no applicable peer endpoints\n')
|
|
65
|
+
return lines.some(l => l.state !== 'ok') ? 1 : 0
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
switch (sub) {
|
|
69
|
+
case 'list': {
|
|
70
|
+
const { declarations, invalid } = listCapabilities({ env })
|
|
71
|
+
for (const d of declarations) out(`${d.capability}${d.version ? `@${d.version}` : ''} runtimes=[${d.runtimes.join(', ')}] ${d.provision.command}\n`)
|
|
72
|
+
for (const b of invalid) out(`INVALID ${b.file}: ${b.error}\n`)
|
|
73
|
+
if (declarations.length + invalid.length === 0) out('no capabilities registered\n')
|
|
74
|
+
return invalid.length ? 1 : 0
|
|
75
|
+
}
|
|
76
|
+
case 'register': {
|
|
77
|
+
if (!arg) throw new Error('usage: iapeer capability register <declaration.json>')
|
|
78
|
+
const decl = parseCapabilityDeclaration(JSON.parse(readFileSync(arg, 'utf8')))
|
|
79
|
+
const path = writeCapabilityDeclaration(decl, { env })
|
|
80
|
+
out(`registered ${decl.capability} → ${path}\n`)
|
|
81
|
+
// Existing peers get the surfaces now; new peers get them at birth.
|
|
82
|
+
return report(await sweepCapability(decl, 'provision', 'sweep-on', env))
|
|
83
|
+
}
|
|
84
|
+
case 'sync': {
|
|
85
|
+
if (!arg) throw new Error('usage: iapeer capability sync <name>')
|
|
86
|
+
return report(await sweepCapability(find(arg, env), 'provision', 'sweep-on', env))
|
|
87
|
+
}
|
|
88
|
+
case 'unregister': {
|
|
89
|
+
if (!arg) throw new Error('usage: iapeer capability unregister <name>')
|
|
90
|
+
const decl = find(arg, env)
|
|
91
|
+
const code = report(await sweepCapability(decl, 'unprovision', 'off-all', env))
|
|
92
|
+
// The declaration goes even if a peer failed to unwind: a lingering declaration
|
|
93
|
+
// would re-provision surfaces on the next birth. The failures are reported above.
|
|
94
|
+
removeCapabilityDeclaration(decl.capability, { env })
|
|
95
|
+
out(`unregistered ${decl.capability}\n`)
|
|
96
|
+
return code
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
errOut('usage: iapeer capability <list | register <declaration.json> | sync <name> | unregister <name>>\n')
|
|
100
|
+
return 2
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
errOut(`capability ${sub ?? ''}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
104
|
+
return 1
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Capability-plugin lifecycle (docs/21-capabilities.md).
|
|
2
|
+
//
|
|
3
|
+
// A capability plugin (statusline-injector, totp-presence, …) puts per-peer
|
|
4
|
+
// surfaces into a peer's runtime: hooks, a private config fragment, an MCP entry.
|
|
5
|
+
// Like the memory slot (ADR-009 inversion), the CORE owns WHEN and FOR WHOM —
|
|
6
|
+
// every agent endpoint of every peer, at birth, at a new runtime, at removal, and
|
|
7
|
+
// on registration — and the PLUGIN owns WHAT: it declares two commands and the
|
|
8
|
+
// core shells into them. The core knows nothing about surface forms.
|
|
9
|
+
//
|
|
10
|
+
// Declaration: <IAPEER_ROOT>/capabilities/<name>.json, written ONLY through
|
|
11
|
+
// `iapeer capability register` (validated), removed through `unregister`.
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
13
|
+
import { isAbsolute, join } from 'node:path'
|
|
14
|
+
import { isSupportedLocalRuntime, isValidName } from '../core/constants.ts'
|
|
15
|
+
import { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
16
|
+
import { runProvisionCommand, type ProvisionCommandOutcome, type ProvisionOccasion } from '../enable/provisionCommand.ts'
|
|
17
|
+
|
|
18
|
+
export const CAPABILITY_SCHEMA = 1
|
|
19
|
+
|
|
20
|
+
export interface CapabilityCommand {
|
|
21
|
+
/** Absolute path to the plugin's executable (launchd's minimal PATH resolves nothing). */
|
|
22
|
+
command: string
|
|
23
|
+
/** Argv tail; {cwd} {runtime} {personality} {occasion} are substituted per argument, no shell. */
|
|
24
|
+
args: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CapabilityDeclaration {
|
|
28
|
+
schema: 1
|
|
29
|
+
/** The capability name = the declaration file stem. */
|
|
30
|
+
capability: string
|
|
31
|
+
package?: string
|
|
32
|
+
version?: string
|
|
33
|
+
/** Agent runtimes the plugin provisions for. Endpoints of other runtimes are skipped. */
|
|
34
|
+
runtimes: string[]
|
|
35
|
+
provision: CapabilityCommand
|
|
36
|
+
unprovision: CapabilityCommand
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function capabilitiesDir(options: StorageOptions = {}): string {
|
|
40
|
+
return join(options.rootDir ?? resolveGlobalRoot(options.env), 'capabilities')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function command(value: unknown, where: string): CapabilityCommand {
|
|
44
|
+
const v = value as Partial<CapabilityCommand> | null
|
|
45
|
+
if (!v || typeof v !== 'object') throw new Error(`${where}: expected {command, args}`)
|
|
46
|
+
if (typeof v.command !== 'string' || !isAbsolute(v.command)) throw new Error(`${where}.command must be an absolute path`)
|
|
47
|
+
if (!Array.isArray(v.args) || v.args.some(a => typeof a !== 'string')) throw new Error(`${where}.args must be an array of strings`)
|
|
48
|
+
if (!v.args.some(a => a.includes('{personality}'))) {
|
|
49
|
+
// Several personalities can share one cwd: a command that cannot tell them apart
|
|
50
|
+
// would provision or tear down the wrong peer's surfaces.
|
|
51
|
+
throw new Error(`${where}.args must carry {personality}`)
|
|
52
|
+
}
|
|
53
|
+
return { command: v.command, args: [...v.args] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validate an untrusted declaration. Throws with the first violation. */
|
|
57
|
+
export function parseCapabilityDeclaration(raw: unknown): CapabilityDeclaration {
|
|
58
|
+
const d = raw as Record<string, unknown> | null
|
|
59
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('declaration must be a JSON object')
|
|
60
|
+
if (d.schema !== CAPABILITY_SCHEMA) throw new Error(`unsupported schema ${JSON.stringify(d.schema)} (expected ${CAPABILITY_SCHEMA})`)
|
|
61
|
+
if (!isValidName(d.capability)) throw new Error(`invalid capability name ${JSON.stringify(d.capability)}`)
|
|
62
|
+
if (!Array.isArray(d.runtimes) || d.runtimes.length === 0 || d.runtimes.some(r => typeof r !== 'string')) {
|
|
63
|
+
throw new Error('runtimes must be a non-empty array of runtime names')
|
|
64
|
+
}
|
|
65
|
+
for (const k of ['package', 'version'] as const) {
|
|
66
|
+
if (d[k] !== undefined && typeof d[k] !== 'string') throw new Error(`${k} must be a string`)
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
schema: CAPABILITY_SCHEMA,
|
|
70
|
+
capability: d.capability as string,
|
|
71
|
+
...(d.package ? { package: d.package as string } : {}),
|
|
72
|
+
...(d.version ? { version: d.version as string } : {}),
|
|
73
|
+
runtimes: [...new Set(d.runtimes as string[])],
|
|
74
|
+
provision: command(d.provision, 'provision'),
|
|
75
|
+
unprovision: command(d.unprovision, 'unprovision'),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CapabilityListing {
|
|
80
|
+
declarations: CapabilityDeclaration[]
|
|
81
|
+
/** Files that exist but do not validate — reported, never silently skipped. */
|
|
82
|
+
invalid: { file: string; error: string }[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function listCapabilities(options: StorageOptions = {}): CapabilityListing {
|
|
86
|
+
const dir = capabilitiesDir(options)
|
|
87
|
+
const out: CapabilityListing = { declarations: [], invalid: [] }
|
|
88
|
+
if (!existsSync(dir)) return out
|
|
89
|
+
for (const f of readdirSync(dir).filter(f => f.endsWith('.json')).sort()) {
|
|
90
|
+
try {
|
|
91
|
+
const decl = parseCapabilityDeclaration(JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
|
92
|
+
if (`${decl.capability}.json` !== f) throw new Error(`file name must be ${decl.capability}.json`)
|
|
93
|
+
out.declarations.push(decl)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
out.invalid.push({ file: join(dir, f), error: e instanceof Error ? e.message : String(e) })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeCapabilityDeclaration(decl: CapabilityDeclaration, options: StorageOptions = {}): string {
|
|
102
|
+
const dir = capabilitiesDir(options)
|
|
103
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
104
|
+
const path = join(dir, `${decl.capability}.json`)
|
|
105
|
+
writeFileAtomic(path, JSON.stringify(decl, null, 2) + '\n', 0o600)
|
|
106
|
+
return path
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeCapabilityDeclaration(name: string, options: StorageOptions = {}): boolean {
|
|
110
|
+
if (!isValidName(name)) throw new Error(`invalid capability name ${JSON.stringify(name)}`)
|
|
111
|
+
const path = join(capabilitiesDir(options), `${name}.json`)
|
|
112
|
+
if (!existsSync(path)) return false
|
|
113
|
+
rmSync(path)
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The (runtime) endpoints of a peer a capability applies to: declared by the peer,
|
|
118
|
+
* hostable as an agent by this core, and listed by the capability. */
|
|
119
|
+
export function capabilityEndpoints(decl: CapabilityDeclaration, peerRuntimes: readonly string[]): string[] {
|
|
120
|
+
return peerRuntimes.filter(r => isSupportedLocalRuntime(r) && decl.runtimes.includes(r))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CapabilityRun {
|
|
124
|
+
capability: string
|
|
125
|
+
runtime: string
|
|
126
|
+
outcome: ProvisionCommandOutcome
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run one lifecycle command of every applicable capability for ONE peer. Never
|
|
131
|
+
* throws; callers decide how loud a failure is (birth/remove: warn and continue).
|
|
132
|
+
* `only` narrows to one capability (register/unregister sweeps).
|
|
133
|
+
*/
|
|
134
|
+
export function runCapabilityLifecycle(args: {
|
|
135
|
+
kind: 'provision' | 'unprovision'
|
|
136
|
+
personality: string
|
|
137
|
+
cwd: string
|
|
138
|
+
runtimes: readonly string[]
|
|
139
|
+
occasion: ProvisionOccasion
|
|
140
|
+
env?: NodeJS.ProcessEnv
|
|
141
|
+
only?: CapabilityDeclaration
|
|
142
|
+
rootDir?: string
|
|
143
|
+
}): CapabilityRun[] {
|
|
144
|
+
const decls = args.only ? [args.only] : listCapabilities({ env: args.env, rootDir: args.rootDir }).declarations
|
|
145
|
+
const runs: CapabilityRun[] = []
|
|
146
|
+
for (const decl of decls) {
|
|
147
|
+
for (const runtime of capabilityEndpoints(decl, args.runtimes)) {
|
|
148
|
+
const outcome = runProvisionCommand({
|
|
149
|
+
block: args.kind === 'provision' ? decl.provision : decl.unprovision,
|
|
150
|
+
cwd: args.cwd,
|
|
151
|
+
runtime,
|
|
152
|
+
personality: args.personality,
|
|
153
|
+
occasion: args.occasion,
|
|
154
|
+
env: args.env,
|
|
155
|
+
})
|
|
156
|
+
runs.push({ capability: decl.capability, runtime, outcome })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return runs
|
|
160
|
+
}
|