@agfpd/iapeer 0.4.114 → 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.
@@ -38,7 +38,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
38
38
  "uptimeSecs": 32400,
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
- "fda": true
41
+ "fda": true,
42
+ "agent_runtimes": ["claude", "codex", "opencode"]
42
43
  },
43
44
  "peers": [
44
45
  {
@@ -48,8 +49,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
48
49
  "default_runtime": "claude",
49
50
  "cwd": "/Users/me/.iapeer/peers/boris",
50
51
  "runtimes": [
51
- { "runtime": "claude", "status": "live", "attached": true, "activity": "working" },
52
- { "runtime": "codex", "status": "asleep" }
52
+ { "runtime": "claude", "status": "live", "agent": true, "attached": true, "activity": "working" },
53
+ { "runtime": "codex", "status": "asleep", "agent": true }
53
54
  ],
54
55
  "last_active_runtime": "claude",
55
56
  "last_active_ms": 1751731100000,
@@ -77,6 +78,9 @@ The full fleet state. The peer rows are produced by the **same in-process functi
77
78
  Field notes:
78
79
 
79
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
+ - `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.
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).
80
84
  - `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
81
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`.
82
86
  - `attached` — any runtime attached (peer-level convenience).
@@ -0,0 +1,323 @@
1
+ # 20 — OpenCode agent runtime
2
+
3
+ **Implementation in progress; not a released capability.** This is the acceptance
4
+ contract, not a claim that the matrix below has passed. Existing Claude/Codex
5
+ behaviour and the signing-phase activation restrictions remain unchanged.
6
+
7
+ ## Ownership and mechanism
8
+
9
+ OpenCode is an **agent adapter**, not an infrastructure router. Its native process
10
+ and TUI belong to the existing pty supervisor; wake, locks, crash guard, idle-reap,
11
+ resume/fresh choice, identity, routing and operator commands stay foundation-owned.
12
+ It does not acquire a launchd KeepAlive plist or bypass H4.
13
+
14
+ Provider and model configuration belongs to OpenCode configuration/launch.env.
15
+ NVIDIA is one provider, not a dependency of the adapter. Credentials are environment
16
+ references or native credential storage, never embedded in generated artifacts.
17
+
18
+ The native `agent.iapeer.prompt` replaces the provider's built-in base prompt.
19
+ The native environment, tools and project-instruction channels remain enabled, as
20
+ they do for Claude/Codex. A user-message prefix is not a system-prompt replacement.
21
+ The file is re-composed on every process launch, including resume; live processes
22
+ keep their launch snapshot. Foreign config/plugins are preserved, not overwritten.
23
+ OpenCode's inline config **merges** other sources; managed config/MDM can override
24
+ it. Effective-config checks must refuse a conflicting required setting rather
25
+ than claim success, erase foreign settings, or circumvent an administrative gate.
26
+
27
+ Structural delivery/readiness/control use the native plugin SDK, not a guessed
28
+ screen glyph or a successful process spawn. The core-owned plugin must be present
29
+ and healthy before incoming delivery. The native TUI remains the attach surface.
30
+ An acknowledgement means the **specific message** is persisted/accepted, not merely
31
+ that bytes reached a socket. Message IDs, session IDs, tool events and errors must
32
+ remain available to history/observability clients. No mandatory command may succeed
33
+ as a silent no-op.
34
+
35
+ ## Memory-provider ABI (agreed with iapeer-memory)
36
+
37
+ - Existing slot `provision-peer --personality <p> --runtime opencode --cwd <cwd>`.
38
+ - Canonical child ABI: `PEER_PERSONALITY`, `PEER_RUNTIME=opencode`,
39
+ `PEER_IDENTITY=opencode-<p>`; `IAPEER_IDENTITY` is an alias set by the core.
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]`.
49
+ Memory owns `iapeer-memory.json` and `iapeer-memory-plugin.mjs` there. The core
50
+ reads the JSON fragment on each launch and merges its `mcp` and `plugin` fields
51
+ into runtime overrides. Missing means no memory surfaces; malformed/present
52
+ means an explicit failure. Other keys are rejected, not given core authority.
53
+ - MCP fragment uses `type: remote`, `oauth: false`, and
54
+ `headers: {"X-IAPeer-Identity": "{env:PEER_IDENTITY}"}`. Memory owns its MCP and
55
+ write-attribution hooks; foundation owns IAP, doctrine and approvals.
56
+ - Guide/index remain ordinary global/local `.iapeer/fragments` input to the
57
+ foundation composer. They are not duplicated in a separate OpenCode memory.
58
+ - Unprovision receives `--personality` as well as `--cwd` and `--runtime` so removal
59
+ cannot target another personality sharing a working directory.
60
+
61
+ ## Observability ABI (agreed with web-runtime, telegram-runtime, statusline-injector)
62
+
63
+ **Contract under implementation — no live producer yet.** Consumers must not treat
64
+ a synthetic fixture as proof of the producer; the core ships a native source harness.
65
+
66
+ The core is the **single producer**. Consumers read only these files, never the
67
+ native OpenCode SQLite DB or a second SDK client:
68
+
69
+ - `<IAPEER_ROOT>/state/opencode/<p>/events.jsonl` — append-only journal, one JSON
70
+ object per line:
71
+ `{schema:1, seq, time:<epoch ms>, identity:"opencode-<p>", launchID,
72
+ sessionID:<native ses_…|null>, type, properties, nativeID:<event.id|null>}`.
73
+ - `seq` is monotonic across process restarts and resumes. The journal is never
74
+ rotated or renamed on fresh. Consumers dedup by `seq`.
75
+ - Native events are passed through **unchanged** (`type` + `properties`):
76
+ `message.updated` (info: tokens, cost, modelID, providerID, time.completed),
77
+ `message.part.updated` (the full part with id, tool args/output),
78
+ `message.part.delta`, `session.status`, `session.error`, `session.created`,
79
+ `session.compacted`, `permission.asked`/`permission.replied`.
80
+ - Core events: `runtime.ready` `{session:<native session info>, resumed:boolean}`;
81
+ `runtime.stopped` `{reason, exitCode?:number, signal?:string}`. A resume emits
82
+ `runtime.ready` with the existing session; it never fabricates `session.created`.
83
+ **Every process launch that reaches readiness writes exactly one `runtime.ready`**
84
+ with a new `launchID`, including resume, so the main session is recoverable from
85
+ the journal alone. A launch that fails before readiness writes `runtime.error`
86
+ `{error}` under its `launchID` instead.
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.
94
+ - Native bootstrap snapshots can repeat part ids, so consumers must **upsert parts by id**.
95
+ - `<IAPEER_ROOT>/state/opencode/<p>/state.json`, written atomically:
96
+ `{schema:1, identity, cwd, sessionID, launchID, startedAt, lastEventSeq,
97
+ lastActivityMs, status:"starting|idle|busy|approval|error|stopped", error?}`.
98
+ - `lastEventSeq` is a fence: every journal line with `seq ≤ lastEventSeq` is
99
+ durable on disk before the state write.
100
+ - Busy/idle comes from `status`, not from the pane-log mtime.
101
+ - An abrupt process death (SIGKILL) is recorded by the core supervisor; the
102
+ in-process plugin alone cannot promise `stopped`. A stale `busy` after a death
103
+ is resolved from supervisor liveness.
104
+ - Session identity: exactly one native session per personality. Fresh starts a new
105
+ ID; resume keeps the same one.
106
+ - Turn completion: correlate assistant `info.time.completed` with terminal tool
107
+ parts. `session.status` idle is a native observation only: it does not guarantee
108
+ that no event is still in flight. The live ordering is still to be verified.
109
+ - Control (`interrupt`/`new`/`compact`) and approvals use the existing Fleet API and
110
+ approval faces. There is no new face API.
111
+ - Endpoint selection: send and control from one channel dialogue anchor on the
112
+ same endpoint. A send without a runtime resolves to the profile's
113
+ `default_runtime`. `delivery.delivered_to.runtime` is authoritative for activity.
114
+ The live runtime is not a selector when two endpoints are live.
115
+
116
+ Open core contracts this ABI depends on:
117
+
118
+ - **Agent-endpoint discovery** — the declared profile runtimes plus the agent
119
+ adapters the core supports, for channel menus. Not `Bun.which` and not a
120
+ consumer-side hardcode.
121
+ - **Capability-plugin lifecycle** — a stable declaration through which a
122
+ registered capability plugin (e.g. statusline-injector) gets
123
+ provision/unprovision per peer and runtime, like the memory slot. Until it
124
+ exists, a manual install is not reported as final.
125
+
126
+ ## Acceptance matrix
127
+
128
+ `OPEN` includes implementation and/or missing live proof. Unit tests, config-file
129
+ existence and isolated SDK probes alone do not close an end-to-end row. Row IDs are
130
+ stable interfaces for the independent acceptance harness.
131
+
132
+ | ID | Mandatory invariant and normal triggers | Owner / evidence required | State |
133
+ |---|---|---|---|
134
+ | OC01 | create/init/add-runtime; discovery intelligence gate; no default-runtime migration | core; cold peer and shared-cwd contrast | OPEN |
135
+ | OC02 | explicit/default endpoint, simultaneous runtimes, exact per-request identity | core; real MCP headers and routing contrast | OPEN |
136
+ | OC03 | cold/warm inbound, busy queue, attachments, response, no loss/duplicate | core; native records, IAP history, 3/3 full cycles | OPEN |
137
+ | OC04 | process launch, MCP import, actual readiness, missing/invalid config/plugin failure | core; native process and effective-config contrast | OPEN |
138
+ | OC05 | attach/detach, human input coexistence, full native history | core + channel clients; actual PTY and history replay | OPEN |
139
+ | OC06 | stop/start, idle-reap/resume, refresh/new/self-fresh/self-done | core; each original trigger and same/fresh session IDs | OPEN |
140
+ | OC07 | abrupt death, crash-loop guard, restart/recovery, durable queued work | core; actual death and recovery, not synthetic mid-pipeline events | OPEN |
141
+ | OC08 | base system swap, native instructions, fresh doctrine on resumed history | core; wire capture before/after full process restart | MECHANISM VERIFIED; adapter proof OPEN |
142
+ | OC09 | memory MCP, shared personality, guide/index, native write/edit/patch authorship, removal | memory provider + core slot; isolated vault and actual tool writes | OPEN |
143
+ | OC10 | gated/yolo, native risky action → broker allow/deny/timeout/cancel; direct attach race | core + approval faces; real risky triggers, no production cards | OPEN |
144
+ | OC11 | interrupt and compact, completion/failure detection; unsupported runtime-specific commands fail loud | core; native turn interruption/compaction records | OPEN |
145
+ | OC12 | native skills and MCP extensions; foreign configuration preserved | core + capability owners; actual tool/skill calls | OPEN |
146
+ | OC13 | state, busy/idle/tool work, errors/notices, usage where available, full owner-channel history | core event contract + web/telegram/statusline owners | OPEN |
147
+ | OC14 | install/update/remove/rollback; signature/exec failure preserves previous state | core + provider installers; exact installed published artifact | OPEN |
148
+ | OC15 | provider-independent config and NVIDIA synthetic cloud trial | core + linus; native model/tool behaviour, no fleet secrets/memory | OPEN |
149
+ | OC16 | Claude/Codex and H4/path-isolation regressions | core + PM; hermetic suite and coordinated native regression | OPEN |
150
+
151
+ Codex thread goals and third-party Claude/Codex marketplace plugins are not
152
+ cross-runtime guarantees. Their availability is reported explicitly. Native
153
+ OpenCode skills, core MCP and the mandatory memory/approval surfaces are required;
154
+ calling those optional would not satisfy this contract. Unavailable provider
155
+ metrics are null/unknown, never fabricated zeroes.
156
+
157
+ ## Verified mechanism baseline — 2026-10-17
158
+
159
+ Native npm `opencode-darwin-arm64@1.19.3`, executable SHA-256
160
+ `6d3e6458fce4b649edf974c19959d5dbfe1e7f255a503dcfc01215b24ad3ea5b`.
161
+ The official source tag `v1.19.3` resolves to
162
+ `e3c4eb242585dcd77be1a867391b7c25fd47ce52`; this is a source reference, not
163
+ cryptographic provenance of the npm binary (npm metadata has no gitHead).
164
+
165
+ The native server was started against a local synthetic OpenAI-compatible provider,
166
+ fresh HOME/XDG directories and a synthetic working directory. The first wire system
167
+ starts with doctrine ONE; after full process stop/start, the same session's next
168
+ system starts with doctrine TWO and contains no ONE. Both user turns survive;
169
+ native AGENTS.md remains in both system messages. This establishes swap/resume
170
+ semantics, not fleet delivery or the acceptance cycles. Production and NVIDIA were
171
+ not contacted. The original probe retained the two requests and native events;
172
+ the reproducible packaged proof is part of the implementation deliverable.
173
+
174
+ Official references (checked against native 1.19.3, not assumed from latest docs):
175
+ [CLI](https://opencode.ai/docs/cli/),
176
+ [agents](https://opencode.ai/docs/agents/),
177
+ [plugins](https://opencode.ai/docs/plugins/),
178
+ [MCP](https://opencode.ai/docs/mcp-servers/),
179
+ [config precedence](https://opencode.ai/docs/config/#precedence-order),
180
+ [NVIDIA](https://opencode.ai/docs/providers/#nvidia),
181
+ [native prompt selection](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/session/llm/request.ts),
182
+ [native permission lifecycle](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/permission/index.ts).
183
+
184
+ ## Core plugin and native harness — 2026-10-17
185
+
186
+ `src/opencode/iapeer-plugin.mjs` is the core-owned plugin. It is embedded in the
187
+ binary as text and written to `<dir>/iapeer-plugin.mjs` before every launch. Its
188
+ launch environment is `IAPEER_OPENCODE_DIR`, `PEER_IDENTITY`,
189
+ `IAPEER_OPENCODE_LAUNCH_ID` and, for resume, `IAPEER_OPENCODE_SESSION`. It produces
190
+ the observability ABI above and serves `<dir>/control.sock`
191
+ (`src/opencode/control.ts`). The socket takes one JSON line per connection:
192
+ - `ping` returns the identity, session and status;
193
+ - `deliver {text}` acknowledges `{messageID}` only after the native
194
+ `message.updated` for that exact core-chosen ID is observed;
195
+ - `interrupt` runs the native abort;
196
+ - `select` publishes `tui.session.select`.
197
+
198
+ Readiness runs after plugin init returns, because calling the in-process client
199
+ during plugin loading would wait on the instance being built.
200
+
201
+ `scripts/opencode-native-harness.ts` runs the real binary (`opencode serve`) with a
202
+ synthetic provider and an isolated HOME and root. Run of 2026-10-17 on native 1.19.3:
203
+ 16/16 checks passed.
204
+ - Readiness and ping.
205
+ - Idle delivery acknowledged after persistence; doctrine on the wire.
206
+ - A delivery during a running slow turn was acknowledged in 8 ms and processed after
207
+ that turn. The native queue is durable.
208
+ - Interrupt.
209
+ - Contiguous `seq` and the state fence.
210
+ - Full process restart with `--session`: same session, `resumed=true`, `seq`
211
+ continues, the new doctrine replaces the old one, native `AGENTS.md` and earlier
212
+ history are kept.
213
+
214
+ This proves the producer and plugin mechanics under `serve`. It does not prove the
215
+ TUI-under-supervisor adapter, matrix rows or acceptance cycles.
216
+
217
+ Native findings that bind the adapter:
218
+ - **Cold config dir means npm on the critical path.** Before loading any external
219
+ plugin, OpenCode runs `npm install @opencode-ai/plugin@<version>` in each config
220
+ dir that lacks it (`Config.waitForDependencies`). On 17.10 this took about 60 s
221
+ on one run and hung for over 4 min on others: a single npm https connection
222
+ stalled. The adapter must install and verify this dependency at
223
+ `install-runtime`/update time, not on the wake path. The harness uses
224
+ `--seed-config` for offline determinism.
225
+ - **`serve` builds the directory instance lazily** (on the first request); the TUI
226
+ does it at startup.
227
+ - **A SIGTERM during startup can leave the process running.** It killed the npm
228
+ child, then initialization continued. Stop must escalate TERM → KILL. The plugin's
229
+ `dispose` does not run on a signal, so `runtime.stopped` for a signal death is the
230
+ supervisor's job.
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
+
317
+ ## Delivery gate
318
+
319
+ Source changes and isolated native artifacts are allowed. npm publication and host
320
+ activation are separate coordinated stages. Neither this document nor the runtime
321
+ task authorizes production START, neighbouring service restarts, auth/MFA changes,
322
+ or a signing-gate bypass. Final acceptance uses the exact installed published
323
+ bytes and a PM-owned activation window with recovery instructions.
@@ -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.114",
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
+ }