@agfpd/iapeer 0.4.114 → 0.4.115

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"]
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,8 @@ 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
+ - `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
83
  - `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
81
84
  - `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
85
  - `attached` — any runtime attached (peer-level convenience).
@@ -0,0 +1,226 @@
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>/peers/<p>/opencode/` (default root `~/.iapeer`).
41
+ Memory owns `iapeer-memory.json` and `iapeer-memory-plugin.mjs` there. The core
42
+ reads the JSON fragment on each launch and merges its `mcp` and `plugin` fields
43
+ into runtime overrides. Missing means no memory surfaces; malformed/present
44
+ means an explicit failure. Other keys are rejected, not given core authority.
45
+ - MCP fragment uses `type: remote`, `oauth: false`, and
46
+ `headers: {"X-IAPeer-Identity": "{env:PEER_IDENTITY}"}`. Memory owns its MCP and
47
+ write-attribution hooks; foundation owns IAP, doctrine and approvals.
48
+ - Guide/index remain ordinary global/local `.iapeer/fragments` input to the
49
+ foundation composer. They are not duplicated in a separate OpenCode memory.
50
+ - Unprovision receives `--personality` as well as `--cwd` and `--runtime` so removal
51
+ cannot target another personality sharing a working directory.
52
+
53
+ ## Observability ABI (agreed with web-runtime, telegram-runtime, statusline-injector)
54
+
55
+ **Contract under implementation — no live producer yet.** Consumers must not treat
56
+ a synthetic fixture as proof of the producer; the core ships a native source harness.
57
+
58
+ The core is the **single producer**. Consumers read only these files, never the
59
+ native OpenCode SQLite DB or a second SDK client:
60
+
61
+ - `<IAPEER_ROOT>/peers/<p>/opencode/events.jsonl` — append-only journal, one JSON
62
+ object per line:
63
+ `{schema:1, seq, time:<epoch ms>, identity:"opencode-<p>", launchID,
64
+ sessionID:<native ses_…|null>, type, properties, nativeID:<event.id|null>}`.
65
+ - `seq` is monotonic across process restarts and resumes. The journal is never
66
+ rotated or renamed on fresh. Consumers dedup by `seq`.
67
+ - Native events are passed through **unchanged** (`type` + `properties`):
68
+ `message.updated` (info: tokens, cost, modelID, providerID, time.completed),
69
+ `message.part.updated` (the full part with id, tool args/output),
70
+ `message.part.delta`, `session.status`, `session.error`, `session.created`,
71
+ `session.compacted`, `permission.asked`/`permission.replied`.
72
+ - Core events: `runtime.ready` `{session:<native session info>, resumed:boolean}`;
73
+ `runtime.stopped` `{reason, exitCode?:number, signal?:string}`. A resume emits
74
+ `runtime.ready` with the existing session; it never fabricates `session.created`.
75
+ **Every process launch that reaches readiness writes exactly one `runtime.ready`**
76
+ with a new `launchID`, including resume, so the main session is recoverable from
77
+ the journal alone. A launch that fails before readiness writes `runtime.error`
78
+ `{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.
82
+ - Native bootstrap snapshots can repeat part ids, so consumers must **upsert parts by id**.
83
+ - `<IAPEER_ROOT>/peers/<p>/opencode/state.json`, written atomically:
84
+ `{schema:1, identity, cwd, sessionID, launchID, startedAt, lastEventSeq,
85
+ lastActivityMs, status:"starting|idle|busy|approval|error|stopped", error?}`.
86
+ - `lastEventSeq` is a fence: every journal line with `seq ≤ lastEventSeq` is
87
+ durable on disk before the state write.
88
+ - Busy/idle comes from `status`, not from the pane-log mtime.
89
+ - An abrupt process death (SIGKILL) is recorded by the core supervisor; the
90
+ in-process plugin alone cannot promise `stopped`. A stale `busy` after a death
91
+ is resolved from supervisor liveness.
92
+ - Session identity: exactly one native session per personality. Fresh starts a new
93
+ ID; resume keeps the same one.
94
+ - Turn completion: correlate assistant `info.time.completed` with terminal tool
95
+ parts. `session.status` idle is a native observation only: it does not guarantee
96
+ that no event is still in flight. The live ordering is still to be verified.
97
+ - Control (`interrupt`/`new`/`compact`) and approvals use the existing Fleet API and
98
+ approval faces. There is no new face API.
99
+ - Endpoint selection: send and control from one channel dialogue anchor on the
100
+ same endpoint. A send without a runtime resolves to the profile's
101
+ `default_runtime`. `delivery.delivered_to.runtime` is authoritative for activity.
102
+ The live runtime is not a selector when two endpoints are live.
103
+
104
+ Open core contracts this ABI depends on:
105
+
106
+ - **Agent-endpoint discovery** — the declared profile runtimes plus the agent
107
+ adapters the core supports, for channel menus. Not `Bun.which` and not a
108
+ consumer-side hardcode.
109
+ - **Capability-plugin lifecycle** — a stable declaration through which a
110
+ registered capability plugin (e.g. statusline-injector) gets
111
+ provision/unprovision per peer and runtime, like the memory slot. Until it
112
+ exists, a manual install is not reported as final.
113
+
114
+ ## Acceptance matrix
115
+
116
+ `OPEN` includes implementation and/or missing live proof. Unit tests, config-file
117
+ existence and isolated SDK probes alone do not close an end-to-end row. Row IDs are
118
+ stable interfaces for the independent acceptance harness.
119
+
120
+ | ID | Mandatory invariant and normal triggers | Owner / evidence required | State |
121
+ |---|---|---|---|
122
+ | OC01 | create/init/add-runtime; discovery intelligence gate; no default-runtime migration | core; cold peer and shared-cwd contrast | OPEN |
123
+ | OC02 | explicit/default endpoint, simultaneous runtimes, exact per-request identity | core; real MCP headers and routing contrast | OPEN |
124
+ | OC03 | cold/warm inbound, busy queue, attachments, response, no loss/duplicate | core; native records, IAP history, 3/3 full cycles | OPEN |
125
+ | OC04 | process launch, MCP import, actual readiness, missing/invalid config/plugin failure | core; native process and effective-config contrast | OPEN |
126
+ | OC05 | attach/detach, human input coexistence, full native history | core + channel clients; actual PTY and history replay | OPEN |
127
+ | OC06 | stop/start, idle-reap/resume, refresh/new/self-fresh/self-done | core; each original trigger and same/fresh session IDs | OPEN |
128
+ | OC07 | abrupt death, crash-loop guard, restart/recovery, durable queued work | core; actual death and recovery, not synthetic mid-pipeline events | OPEN |
129
+ | OC08 | base system swap, native instructions, fresh doctrine on resumed history | core; wire capture before/after full process restart | MECHANISM VERIFIED; adapter proof OPEN |
130
+ | OC09 | memory MCP, shared personality, guide/index, native write/edit/patch authorship, removal | memory provider + core slot; isolated vault and actual tool writes | OPEN |
131
+ | OC10 | gated/yolo, native risky action → broker allow/deny/timeout/cancel; direct attach race | core + approval faces; real risky triggers, no production cards | OPEN |
132
+ | OC11 | interrupt and compact, completion/failure detection; unsupported runtime-specific commands fail loud | core; native turn interruption/compaction records | OPEN |
133
+ | OC12 | native skills and MCP extensions; foreign configuration preserved | core + capability owners; actual tool/skill calls | OPEN |
134
+ | OC13 | state, busy/idle/tool work, errors/notices, usage where available, full owner-channel history | core event contract + web/telegram/statusline owners | OPEN |
135
+ | OC14 | install/update/remove/rollback; signature/exec failure preserves previous state | core + provider installers; exact installed published artifact | OPEN |
136
+ | OC15 | provider-independent config and NVIDIA synthetic cloud trial | core + linus; native model/tool behaviour, no fleet secrets/memory | OPEN |
137
+ | OC16 | Claude/Codex and H4/path-isolation regressions | core + PM; hermetic suite and coordinated native regression | OPEN |
138
+
139
+ Codex thread goals and third-party Claude/Codex marketplace plugins are not
140
+ cross-runtime guarantees. Their availability is reported explicitly. Native
141
+ OpenCode skills, core MCP and the mandatory memory/approval surfaces are required;
142
+ calling those optional would not satisfy this contract. Unavailable provider
143
+ metrics are null/unknown, never fabricated zeroes.
144
+
145
+ ## Verified mechanism baseline — 2026-10-17
146
+
147
+ Native npm `opencode-darwin-arm64@1.19.3`, executable SHA-256
148
+ `6d3e6458fce4b649edf974c19959d5dbfe1e7f255a503dcfc01215b24ad3ea5b`.
149
+ The official source tag `v1.19.3` resolves to
150
+ `e3c4eb242585dcd77be1a867391b7c25fd47ce52`; this is a source reference, not
151
+ cryptographic provenance of the npm binary (npm metadata has no gitHead).
152
+
153
+ The native server was started against a local synthetic OpenAI-compatible provider,
154
+ fresh HOME/XDG directories and a synthetic working directory. The first wire system
155
+ starts with doctrine ONE; after full process stop/start, the same session's next
156
+ system starts with doctrine TWO and contains no ONE. Both user turns survive;
157
+ native AGENTS.md remains in both system messages. This establishes swap/resume
158
+ semantics, not fleet delivery or the acceptance cycles. Production and NVIDIA were
159
+ not contacted. The original probe retained the two requests and native events;
160
+ the reproducible packaged proof is part of the implementation deliverable.
161
+
162
+ Official references (checked against native 1.19.3, not assumed from latest docs):
163
+ [CLI](https://opencode.ai/docs/cli/),
164
+ [agents](https://opencode.ai/docs/agents/),
165
+ [plugins](https://opencode.ai/docs/plugins/),
166
+ [MCP](https://opencode.ai/docs/mcp-servers/),
167
+ [config precedence](https://opencode.ai/docs/config/#precedence-order),
168
+ [NVIDIA](https://opencode.ai/docs/providers/#nvidia),
169
+ [native prompt selection](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/session/llm/request.ts),
170
+ [native permission lifecycle](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/permission/index.ts).
171
+
172
+ ## Core plugin and native harness — 2026-10-17
173
+
174
+ `src/opencode/iapeer-plugin.mjs` is the core-owned plugin. It is embedded in the
175
+ binary as text and written to `<dir>/iapeer-plugin.mjs` before every launch. Its
176
+ launch environment is `IAPEER_OPENCODE_DIR`, `PEER_IDENTITY`,
177
+ `IAPEER_OPENCODE_LAUNCH_ID` and, for resume, `IAPEER_OPENCODE_SESSION`. It produces
178
+ the observability ABI above and serves `<dir>/control.sock`
179
+ (`src/opencode/control.ts`). The socket takes one JSON line per connection:
180
+ - `ping` returns the identity, session and status;
181
+ - `deliver {text}` acknowledges `{messageID}` only after the native
182
+ `message.updated` for that exact core-chosen ID is observed;
183
+ - `interrupt` runs the native abort;
184
+ - `select` publishes `tui.session.select`.
185
+
186
+ Readiness runs after plugin init returns, because calling the in-process client
187
+ during plugin loading would wait on the instance being built.
188
+
189
+ `scripts/opencode-native-harness.ts` runs the real binary (`opencode serve`) with a
190
+ synthetic provider and an isolated HOME and root. Run of 2026-10-17 on native 1.19.3:
191
+ 16/16 checks passed.
192
+ - Readiness and ping.
193
+ - Idle delivery acknowledged after persistence; doctrine on the wire.
194
+ - A delivery during a running slow turn was acknowledged in 8 ms and processed after
195
+ that turn. The native queue is durable.
196
+ - Interrupt.
197
+ - Contiguous `seq` and the state fence.
198
+ - Full process restart with `--session`: same session, `resumed=true`, `seq`
199
+ continues, the new doctrine replaces the old one, native `AGENTS.md` and earlier
200
+ history are kept.
201
+
202
+ This proves the producer and plugin mechanics under `serve`. It does not prove the
203
+ TUI-under-supervisor adapter, matrix rows or acceptance cycles.
204
+
205
+ Native findings that bind the adapter:
206
+ - **Cold config dir means npm on the critical path.** Before loading any external
207
+ plugin, OpenCode runs `npm install @opencode-ai/plugin@<version>` in each config
208
+ dir that lacks it (`Config.waitForDependencies`). On 17.10 this took about 60 s
209
+ on one run and hung for over 4 min on others: a single npm https connection
210
+ stalled. The adapter must install and verify this dependency at
211
+ `install-runtime`/update time, not on the wake path. The harness uses
212
+ `--seed-config` for offline determinism.
213
+ - **`serve` builds the directory instance lazily** (on the first request); the TUI
214
+ does it at startup.
215
+ - **A SIGTERM during startup can leave the process running.** It killed the npm
216
+ child, then initialization continued. Stop must escalate TERM → KILL. The plugin's
217
+ `dispose` does not run on a signal, so `runtime.stopped` for a signal death is the
218
+ supervisor's job.
219
+
220
+ ## Delivery gate
221
+
222
+ Source changes and isolated native artifacts are allowed. npm publication and host
223
+ activation are separate coordinated stages. Neither this document nor the runtime
224
+ task authorizes production START, neighbouring service restarts, auth/MFA changes,
225
+ or a signing-gate bypass. Final acceptance uses the exact installed published
226
+ bytes and a PM-owned activation window with recovery instructions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.114",
3
+ "version": "0.4.115",
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",
@@ -35,6 +35,7 @@
35
35
 
36
36
  import type { IncomingMessage, ServerResponse } from 'http'
37
37
  import { closeSync, openSync, readSync, statSync } from 'fs'
38
+ import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
38
39
  import { buildProcessAddress } from '../core/socket.ts'
39
40
  import { IAPEER_VERSION } from '../core/version.ts'
40
41
  import {
@@ -79,6 +80,11 @@ export const FLEET_API_VERSION = 1
79
80
  export interface FleetRuntimeStatus {
80
81
  runtime: string
81
82
  status: 'live' | 'asleep' | 'stopped'
83
+ /** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
84
+ * agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
85
+ * `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
86
+ * A declared runtime the running core cannot host (newer peer, older core) is `false`. */
87
+ agent: boolean
82
88
  /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
83
89
  attached?: boolean
84
90
  /** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
@@ -125,6 +131,8 @@ export interface FleetHost {
125
131
  memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
126
132
  voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
127
133
  fda: boolean | null
134
+ /** The agent runtimes this core build can host (its agent adapters), in preference order. */
135
+ agent_runtimes: string[]
128
136
  }
129
137
 
130
138
  /** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
@@ -238,6 +246,7 @@ export function buildFleetSnapshot(
238
246
  return {
239
247
  runtime: s.runtime,
240
248
  status: s.status,
249
+ agent: isSupportedLocalRuntime(s.runtime),
241
250
  ...(attached ? { attached: true } : {}),
242
251
  ...(activity ? { activity } : {}),
243
252
  }
@@ -289,6 +298,7 @@ export function buildFleetSnapshot(
289
298
  ? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
290
299
  : null,
291
300
  fda: probeFullDiskAccess(env),
301
+ agent_runtimes: [...SUPPORTED_LOCAL_RUNTIMES],
292
302
  },
293
303
  peers,
294
304
  // ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
@@ -0,0 +1,102 @@
1
+ // Private, personality-scoped OpenCode configuration. This module is preparatory:
2
+ // declaring a runtime as supported is gated on the complete adapter, not this file.
3
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { isValidName } from '../core/constants.ts'
7
+ import { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
8
+ import pluginSource from './iapeer-plugin.mjs' with { type: 'text' }
9
+
10
+ export const OPENCODE_AGENT = 'iapeer'
11
+ export const OPENCODE_EXTENSION_FRAGMENTS = ['iapeer-memory', 'statusline-injector'] as const
12
+ type JsonObject = Record<string, unknown>
13
+
14
+ export function opencodePaths(personality: string, options: StorageOptions = {}) {
15
+ if (!isValidName(personality)) throw new Error(`invalid OpenCode personality: ${personality}`)
16
+ const dir = join(options.rootDir ?? resolveGlobalRoot(options.env), 'peers', personality, 'opencode')
17
+ return {
18
+ dir,
19
+ plugin: join(dir, 'iapeer-plugin.mjs'),
20
+ state: join(dir, 'state.json'),
21
+ events: join(dir, 'events.jsonl'),
22
+ config: join(dir, 'opencode.json'), // optional operator-owned provider/model config
23
+ }
24
+ }
25
+
26
+ function object(value: unknown, source: string): JsonObject {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${source}: expected a JSON object`)
28
+ return value as JsonObject
29
+ }
30
+
31
+ /** OpenCode-like object merge; plugin arrays are additive, with stable dedup. */
32
+ export function mergeOpencodeConfig(base: JsonObject, extra: JsonObject): JsonObject {
33
+ const out = { ...base }
34
+ for (const [key, value] of Object.entries(extra)) {
35
+ if (key === 'plugin' && Array.isArray(value)) {
36
+ const all = [...(Array.isArray(out[key]) ? out[key] as unknown[] : []), ...value]
37
+ out[key] = all.filter((v, i) => all.findIndex(x => JSON.stringify(x) === JSON.stringify(v)) === i)
38
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
39
+ const prior = out[key]
40
+ out[key] = mergeOpencodeConfig(prior && typeof prior === 'object' && !Array.isArray(prior) ? prior as JsonObject : {}, value as JsonObject)
41
+ } else out[key] = value
42
+ }
43
+ return out
44
+ }
45
+
46
+ export function readOpencodeExtension(path: string): JsonObject {
47
+ const fragment = object(JSON.parse(readFileSync(path, 'utf8')), path)
48
+ for (const key of Object.keys(fragment)) {
49
+ if (key !== 'mcp' && key !== 'plugin') throw new Error(`${path}: unsupported extension field ${key}`)
50
+ }
51
+ if (fragment.mcp !== undefined) {
52
+ const mcp = object(fragment.mcp, `${path}: mcp`)
53
+ if ('iapeer' in mcp) throw new Error(`${path}: extension cannot replace core IAP MCP`)
54
+ for (const [name, config] of Object.entries(mcp)) object(config, `${path}: mcp.${name}`)
55
+ }
56
+ if (fragment.plugin !== undefined && (!Array.isArray(fragment.plugin) || fragment.plugin.some(p => typeof p !== 'string' || !p.startsWith('file:///')))) {
57
+ throw new Error(`${path}: plugins must be absolute file URLs`)
58
+ }
59
+ return fragment
60
+ }
61
+
62
+ export interface ComposeOpencodeConfigInput {
63
+ personality: string
64
+ doctrine: string
65
+ daemonUrl: string
66
+ /** Existing OPENCODE_CONFIG_CONTENT. It is merged, never discarded. */
67
+ inline?: string
68
+ }
69
+
70
+ /** No writes and no env resolution except through injected StorageOptions. */
71
+ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options: StorageOptions = {}): JsonObject {
72
+ if (!input.doctrine.trim()) throw new Error('OpenCode requires a non-empty composed system prompt')
73
+ const paths = opencodePaths(input.personality, options)
74
+ let result: JsonObject = existsSync(paths.config) ? object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config) : {}
75
+ if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
76
+ for (const stem of OPENCODE_EXTENSION_FRAGMENTS) {
77
+ const path = join(paths.dir, `${stem}.json`)
78
+ if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
79
+ }
80
+ // Last writer for required fields; foreign MCP servers, agents, plugins and
81
+ // provider settings survive. Effective managed overrides still need a live gate.
82
+ return mergeOpencodeConfig(result, {
83
+ autoupdate: false,
84
+ share: 'disabled',
85
+ default_agent: OPENCODE_AGENT,
86
+ agent: { [OPENCODE_AGENT]: { mode: 'primary', prompt: input.doctrine } },
87
+ mcp: { iapeer: { type: 'remote', url: input.daemonUrl, enabled: true, oauth: false, headers: { 'X-IAPeer-Identity': '{env:PEER_IDENTITY}' } } },
88
+ plugin: [pathToFileURL(paths.plugin).href],
89
+ })
90
+ }
91
+
92
+ /** Installer/launcher must pass only trusted core source. Provider secrets stay in
93
+ * native env/config, never interpolated into this executable source artifact. */
94
+ export function writeOpencodePlugin(personality: string, source: string, options: StorageOptions = {}): string {
95
+ const paths = opencodePaths(personality, options)
96
+ mkdirSync(paths.dir, { recursive: true, mode: 0o700 })
97
+ writeFileAtomic(paths.plugin, source, 0o600)
98
+ return paths.plugin
99
+ }
100
+
101
+ /** The core-owned plugin source shipped with this package (embedded in the compiled binary). */
102
+ export const OPENCODE_PLUGIN_SOURCE: string = pluginSource
@@ -0,0 +1,55 @@
1
+ // Core side of the OpenCode control socket served by the core-owned plugin
2
+ // (src/opencode/iapeer-plugin.mjs). One newline-terminated JSON request per
3
+ // connection, one JSON response line back. Used for readiness, delivery, interrupt.
4
+ import { connect } from 'node:net'
5
+
6
+ export type OpencodeControlRequest =
7
+ | { op: 'ping' }
8
+ | { op: 'deliver'; text: string }
9
+ | { op: 'interrupt' }
10
+ | { op: 'select' }
11
+
12
+ export interface OpencodeControlResponse {
13
+ ok: boolean
14
+ error?: string
15
+ identity?: string
16
+ launchID?: string
17
+ sessionID?: string | null
18
+ messageID?: string
19
+ status?: string
20
+ lastEventSeq?: number
21
+ }
22
+
23
+ export function opencodeControl(
24
+ socketPath: string,
25
+ request: OpencodeControlRequest,
26
+ timeoutMs = 20000,
27
+ ): Promise<OpencodeControlResponse> {
28
+ return new Promise(resolve => {
29
+ let buf = ''
30
+ let settled = false
31
+ const done = (r: OpencodeControlResponse) => {
32
+ if (settled) return
33
+ settled = true
34
+ clearTimeout(timer)
35
+ sock.destroy()
36
+ resolve(r)
37
+ }
38
+ const sock = connect(socketPath)
39
+ const timer = setTimeout(() => done({ ok: false, error: `control ${request.op}: no response within ${timeoutMs}ms` }), timeoutMs)
40
+ sock.setEncoding('utf8')
41
+ sock.on('connect', () => sock.write(JSON.stringify(request) + '\n'))
42
+ sock.on('data', chunk => {
43
+ buf += chunk
44
+ const nl = buf.indexOf('\n')
45
+ if (nl < 0) return
46
+ try {
47
+ done(JSON.parse(buf.slice(0, nl)) as OpencodeControlResponse)
48
+ } catch {
49
+ done({ ok: false, error: `control ${request.op}: malformed response` })
50
+ }
51
+ })
52
+ sock.on('error', e => done({ ok: false, error: `control ${request.op}: ${e.message}` }))
53
+ sock.on('close', () => done({ ok: false, error: `control ${request.op}: connection closed without a response` }))
54
+ })
55
+ }
@@ -0,0 +1,4 @@
1
+ // The plugin is shipped as SOURCE TEXT (imported with `{ type: 'text' }`) and written
2
+ // into the peer's private OpenCode directory before each launch.
3
+ declare const source: string
4
+ export default source
@@ -0,0 +1,283 @@
1
+ // iapeer core plugin for OpenCode — the core writes this file before every launch
2
+ // (src/opencode/iapeer-plugin.mjs in @agfpd/iapeer). Do not edit the installed copy.
3
+ //
4
+ // Responsibilities (docs/20-opencode.md):
5
+ // • the single PRODUCER of the observability ABI: events.jsonl (append-only,
6
+ // seq monotonic across restarts) + state.json (atomic; lastEventSeq is a fence);
7
+ // • the structural control surface: a unix socket the core uses for readiness,
8
+ // message delivery (acknowledged only once THIS message is persisted natively),
9
+ // interrupt and TUI session selection.
10
+ // Plain ESM, no dependencies: it runs inside OpenCode's own server worker.
11
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { createServer } from 'node:net'
13
+ import { join } from 'node:path'
14
+
15
+ const SCHEMA = 1
16
+ const DELIVER_ACK_MS = 15000
17
+ // Native events forwarded into the journal unchanged (type + properties).
18
+ const JOURNALED = new Set([
19
+ 'message.updated',
20
+ 'message.removed',
21
+ 'message.part.updated',
22
+ 'message.part.removed',
23
+ 'message.part.delta',
24
+ 'session.status',
25
+ 'session.idle',
26
+ 'session.error',
27
+ 'session.created',
28
+ 'session.updated',
29
+ 'session.compacted',
30
+ 'permission.asked',
31
+ 'permission.replied',
32
+ ])
33
+
34
+ // Same layout as OpenCode's own ascending identifiers, so a core-chosen messageID
35
+ // sorts correctly in the native history.
36
+ const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
37
+ let idLast = 0
38
+ let idCounter = 0
39
+ function ascendingId(prefix) {
40
+ const now = Date.now()
41
+ if (now !== idLast) {
42
+ idLast = now
43
+ idCounter = 0
44
+ }
45
+ idCounter++
46
+ const value = BigInt(now) * 0x1000n + BigInt(idCounter)
47
+ let time = ''
48
+ for (let i = 0; i < 6; i++) time += Number((value >> BigInt(40 - 8 * i)) & 0xffn).toString(16).padStart(2, '0')
49
+ const bytes = crypto.getRandomValues(new Uint8Array(14))
50
+ return prefix + '_' + time + Array.from(bytes, b => ID_CHARS[b % 62]).join('')
51
+ }
52
+
53
+ function sessionOf(type, p) {
54
+ if (!p || typeof p !== 'object') return null
55
+ if (typeof p.sessionID === 'string') return p.sessionID
56
+ if (p.info && typeof p.info.sessionID === 'string') return p.info.sessionID
57
+ if (p.part && typeof p.part.sessionID === 'string') return p.part.sessionID
58
+ if (type.startsWith('session.') && p.info && typeof p.info.id === 'string') return p.info.id
59
+ return null
60
+ }
61
+
62
+ /** Last `seq` in an existing journal: read only the tail (the journal never shrinks). */
63
+ function lastSeq(path) {
64
+ if (!existsSync(path)) return 0
65
+ const fd = openSync(path, 'r')
66
+ try {
67
+ const size = fstatSync(fd).size
68
+ let span = 4096
69
+ while (true) {
70
+ const start = Math.max(0, size - span)
71
+ const buf = Buffer.alloc(size - start)
72
+ readSync(fd, buf, 0, buf.length, start)
73
+ const lines = buf.toString('utf8').split('\n').filter(Boolean)
74
+ // the first line may be cut unless we read from 0
75
+ for (let i = lines.length - 1; i >= (start === 0 ? 0 : 1); i--) {
76
+ try {
77
+ const seq = JSON.parse(lines[i]).seq
78
+ if (Number.isInteger(seq)) return seq
79
+ } catch {}
80
+ }
81
+ if (start === 0) return 0
82
+ span *= 4
83
+ }
84
+ } finally {
85
+ closeSync(fd)
86
+ }
87
+ }
88
+
89
+ async function server(input) {
90
+ const env = process.env
91
+ const dir = env.IAPEER_OPENCODE_DIR
92
+ const identity = env.PEER_IDENTITY
93
+ // Not launched by iapeer (or by an incompatible core): stay inert rather than guess.
94
+ if (!dir || !identity) return {}
95
+ const client = input.client
96
+ const launchID = env.IAPEER_OPENCODE_LAUNCH_ID || ascendingId('lch')
97
+ const paths = {
98
+ events: join(dir, 'events.jsonl'),
99
+ state: join(dir, 'state.json'),
100
+ socket: join(dir, 'control.sock'),
101
+ }
102
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
103
+
104
+ let seq = lastSeq(paths.events)
105
+ const state = {
106
+ schema: SCHEMA,
107
+ identity,
108
+ cwd: input.directory,
109
+ sessionID: env.IAPEER_OPENCODE_SESSION || null,
110
+ launchID,
111
+ startedAt: Date.now(),
112
+ lastEventSeq: seq,
113
+ lastActivityMs: Date.now(),
114
+ status: 'starting',
115
+ }
116
+ function writeState() {
117
+ const tmp = paths.state + '.' + process.pid + '.tmp'
118
+ writeFileSync(tmp, JSON.stringify(state) + '\n', { mode: 0o600 })
119
+ renameSync(tmp, paths.state)
120
+ }
121
+ function journal(type, properties, nativeID, sessionID) {
122
+ seq++
123
+ const line = { schema: SCHEMA, seq, time: Date.now(), identity, launchID, sessionID: sessionID ?? null, type, properties, nativeID: nativeID ?? null }
124
+ appendFileSync(paths.events, JSON.stringify(line) + '\n', { mode: 0o600 })
125
+ state.lastEventSeq = seq // fence: the line above is on disk before the state write below
126
+ state.lastActivityMs = line.time
127
+ }
128
+
129
+ // ── session: exactly one per identity ──────────────────────────────────────
130
+ let ensuring
131
+ async function ensureSession() {
132
+ if (state.sessionID) return state.sessionID
133
+ ensuring ??= (async () => {
134
+ const res = await client.session.create({ body: { title: identity } })
135
+ if (!res.data || typeof res.data.id !== 'string') throw new Error('session.create failed: ' + JSON.stringify(res.error ?? null))
136
+ state.sessionID = res.data.id
137
+ return state.sessionID
138
+ })().finally(() => {
139
+ ensuring = undefined
140
+ })
141
+ return ensuring
142
+ }
143
+ async function selectInTui() {
144
+ if (!state.sessionID) return
145
+ await client.tui.publish({ body: { type: 'tui.session.select', properties: { sessionID: state.sessionID } } }).catch(() => {})
146
+ }
147
+
148
+ // ── message acknowledgements: resolved by the native persist event ──────────
149
+ const waiters = new Map()
150
+ function awaitPersisted(messageID) {
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(() => {
153
+ waiters.delete(messageID)
154
+ reject(new Error('message ' + messageID + ' not persisted within ' + DELIVER_ACK_MS + 'ms'))
155
+ }, DELIVER_ACK_MS)
156
+ waiters.set(messageID, () => {
157
+ clearTimeout(timer)
158
+ waiters.delete(messageID)
159
+ resolve()
160
+ })
161
+ })
162
+ }
163
+
164
+ async function handle(req) {
165
+ switch (req && req.op) {
166
+ case 'ping':
167
+ return { ok: true, identity, launchID, sessionID: state.sessionID, status: state.status, lastEventSeq: state.lastEventSeq }
168
+ case 'deliver': {
169
+ if (typeof req.text !== 'string' || !req.text) return { ok: false, error: 'deliver: empty text' }
170
+ const sessionID = await ensureSession()
171
+ const messageID = ascendingId('msg')
172
+ const persisted = awaitPersisted(messageID)
173
+ const res = await client.session.promptAsync({
174
+ path: { id: sessionID },
175
+ body: { messageID, agent: 'iapeer', parts: [{ type: 'text', text: req.text }] },
176
+ })
177
+ if (res.error) {
178
+ waiters.delete(messageID)
179
+ persisted.catch(() => {})
180
+ return { ok: false, error: 'prompt_async: ' + JSON.stringify(res.error) }
181
+ }
182
+ await persisted
183
+ return { ok: true, sessionID, messageID }
184
+ }
185
+ case 'interrupt': {
186
+ if (!state.sessionID) return { ok: false, error: 'no session' }
187
+ const res = await client.session.abort({ path: { id: state.sessionID } })
188
+ return res.error ? { ok: false, error: JSON.stringify(res.error) } : { ok: true }
189
+ }
190
+ case 'select':
191
+ await selectInTui()
192
+ return { ok: true, sessionID: state.sessionID }
193
+ default:
194
+ return { ok: false, error: 'unknown op ' + JSON.stringify(req && req.op) }
195
+ }
196
+ }
197
+
198
+ // ── control socket ──────────────────────────────────────────────────────────
199
+ rmSync(paths.socket, { force: true })
200
+ const control = createServer(conn => {
201
+ let buf = ''
202
+ conn.setEncoding('utf8')
203
+ conn.on('data', chunk => {
204
+ buf += chunk
205
+ const nl = buf.indexOf('\n')
206
+ if (nl < 0) return
207
+ const line = buf.slice(0, nl)
208
+ buf = ''
209
+ let req
210
+ try {
211
+ req = JSON.parse(line)
212
+ } catch {
213
+ conn.end(JSON.stringify({ ok: false, error: 'bad request' }) + '\n')
214
+ return
215
+ }
216
+ handle(req).then(
217
+ r => conn.end(JSON.stringify(r) + '\n'),
218
+ e => conn.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }) + '\n'),
219
+ )
220
+ })
221
+ conn.on('error', () => {})
222
+ })
223
+ await new Promise((resolve, reject) => {
224
+ control.once('error', reject)
225
+ control.listen(paths.socket, () => resolve())
226
+ })
227
+
228
+ // Readiness runs AFTER init returns: calling the in-process client while OpenCode is
229
+ // still loading this plugin would wait on the very instance state being built.
230
+ // The socket is up and the session is known (fresh: created now) → runtime.ready —
231
+ // the core's readiness signal, never a screen glyph.
232
+ setTimeout(async () => {
233
+ const resumed = Boolean(state.sessionID)
234
+ try {
235
+ const sessionID = await ensureSession()
236
+ const got = await client.session.get({ path: { id: sessionID } })
237
+ if (!got.data) throw new Error('session ' + sessionID + ' not found: ' + JSON.stringify(got.error ?? null))
238
+ if (state.status === 'starting') state.status = 'idle'
239
+ journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
240
+ } catch (e) {
241
+ state.status = 'error'
242
+ state.error = e instanceof Error ? e.message : String(e)
243
+ journal('runtime.error', { error: state.error }, null, state.sessionID)
244
+ }
245
+ writeState()
246
+ // The TUI may not be subscribed yet; the core re-selects on demand via op:select.
247
+ setTimeout(() => void selectInTui(), 500)
248
+ }, 0)
249
+ writeState()
250
+
251
+ return {
252
+ event: async ({ event }) => {
253
+ const type = event && event.type
254
+ if (!JOURNALED.has(type)) return
255
+ const p = event.properties
256
+ const sessionID = sessionOf(type, p)
257
+ if (type === 'message.updated' && p && p.info && waiters.has(p.info.id)) waiters.get(p.info.id)()
258
+ if (sessionID && sessionID === state.sessionID) {
259
+ if (type === 'session.status' && p.status) {
260
+ state.status = p.status.type === 'busy' || p.status.type === 'retry' ? 'busy' : p.status.type === 'idle' ? 'idle' : state.status
261
+ delete state.error
262
+ } else if (type === 'session.idle') state.status = 'idle'
263
+ else if (type === 'permission.asked') state.status = 'approval'
264
+ else if (type === 'permission.replied' && state.status === 'approval') state.status = 'busy'
265
+ else if (type === 'session.error') {
266
+ state.status = 'error'
267
+ state.error = JSON.stringify(p.error ?? null)
268
+ }
269
+ }
270
+ journal(type, p, event.id ?? null, sessionID)
271
+ writeState()
272
+ },
273
+ dispose: async () => {
274
+ state.status = 'stopped'
275
+ journal('runtime.stopped', { reason: 'dispose' }, null, state.sessionID)
276
+ writeState()
277
+ await new Promise(resolve => control.close(() => resolve()))
278
+ rmSync(paths.socket, { force: true })
279
+ },
280
+ }
281
+ }
282
+
283
+ export default { id: 'iapeer', server }