@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.
@@ -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`.
@@ -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>/peers/<p>/opencode/` (default root `~/.iapeer`).
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>/peers/<p>/opencode/events.jsonl` — append-only journal, one JSON
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, statusline-injector and iapeer-memory's
80
- weekly collector (it relies on the path, `time`, unchanged `type`/`properties` and
81
- no rotation). Changing any of these requires notifying all four.
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>/peers/<p>/opencode/state.json`, written atomically:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.115",
3
+ "version": "0.4.116",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -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
+ }