@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.
- package/docs/15-fleet-api.md +7 -3
- package/docs/20-opencode.md +323 -0
- 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 +19 -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 +121 -0
- package/src/opencode/control.ts +56 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.d.mts +4 -0
- package/src/opencode/iapeer-plugin.mjs +362 -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
|
@@ -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
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
|
+
}
|