@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.
@@ -0,0 +1,110 @@
1
+ // Keep npm OFF the OpenCode wake path (docs/20 §Native findings). Before loading any external
2
+ // plugin, native OpenCode (1.19.3 core/src/npm.ts Npm.install) reifies `@opencode-ai/plugin` in
3
+ // EVERY config directory it scans, unless that directory already has `node_modules` AND the name
4
+ // is in the root of its `package-lock.json` (the version is not re-checked once locked). A cold
5
+ // directory therefore costs one npm https round-trip at startup — observed 60 s and >4 min stalls.
6
+ // The core satisfies that exact no-op condition at runtime-attach time (an operator verb), so a
7
+ // wake never waits on the network.
8
+ //
9
+ // Directories (native ConfigPaths.directories): the global config dir ($OPENCODE_CONFIG_DIR, else
10
+ // $XDG_CONFIG_HOME/opencode, else ~/.config/opencode), every existing `.opencode` from the peer cwd
11
+ // upwards, and ~/.opencode (where the official installer puts the binary). Non-writable dirs are
12
+ // skipped natively and here.
13
+ import { execFileSync } from 'node:child_process'
14
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, join, resolve } from 'node:path'
17
+
18
+ export const OPENCODE_PLUGIN_PACKAGE = '@opencode-ai/plugin'
19
+
20
+ export function opencodeConfigDirs(cwd: string, env: NodeJS.ProcessEnv = process.env): string[] {
21
+ const home = env.HOME?.trim() || homedir()
22
+ const globalDir = env.OPENCODE_CONFIG_DIR?.trim() || join(env.XDG_CONFIG_HOME?.trim() || join(home, '.config'), 'opencode')
23
+ const dirs = [globalDir]
24
+ let dir = resolve(cwd)
25
+ while (true) {
26
+ const candidate = join(dir, '.opencode')
27
+ if (existsSync(candidate)) dirs.push(candidate)
28
+ const parent = dirname(dir)
29
+ if (parent === dir || dir === home) break
30
+ dir = parent
31
+ }
32
+ const homeDir = join(home, '.opencode')
33
+ if (existsSync(homeDir)) dirs.push(homeDir)
34
+ return [...new Set(dirs)]
35
+ }
36
+
37
+ /** The exact native no-op condition for one directory. */
38
+ export function pluginDependencySatisfied(dir: string): boolean {
39
+ if (!existsSync(join(dir, 'node_modules'))) return false
40
+ try {
41
+ const lock = JSON.parse(readFileSync(join(dir, 'package-lock.json'), 'utf8')) as { packages?: Record<string, Record<string, Record<string, string>>> }
42
+ const root = lock.packages?.[''] ?? {}
43
+ return [root.dependencies, root.devDependencies, root.peerDependencies, root.optionalDependencies].some(d => d && OPENCODE_PLUGIN_PACKAGE in d)
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ function writable(dir: string): boolean {
50
+ try {
51
+ accessSync(dir, constants.W_OK)
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ export interface DependencyOutcome {
59
+ dir: string
60
+ state: 'present' | 'installed' | 'skipped-readonly' | 'failed'
61
+ detail?: string
62
+ }
63
+
64
+ export interface EnsureDepsDeps {
65
+ /** Native binary version (the plugin package is pinned to it, as native does). */
66
+ version: () => string
67
+ /** Run `npm install` in `dir` (throws on failure). */
68
+ npmInstall: (dir: string, spec: string) => void
69
+ }
70
+
71
+ export function defaultEnsureDepsDeps(bin: string, env: NodeJS.ProcessEnv): EnsureDepsDeps {
72
+ return {
73
+ version: () => execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 20000, env }).trim(),
74
+ npmInstall: (dir, spec) =>
75
+ void execFileSync('npm', ['install', '--no-audit', '--no-fund', '--save', spec], { cwd: dir, env, stdio: 'pipe', timeout: 300000 }),
76
+ }
77
+ }
78
+
79
+ /** Satisfy the native no-op condition in every config dir. Never throws; the caller decides. */
80
+ export function ensureOpencodePluginDependency(cwd: string, env: NodeJS.ProcessEnv, deps: EnsureDepsDeps): DependencyOutcome[] {
81
+ // Fail-closed sandbox guard (the IAPEER_TEST_SANDBOX contract): these dirs resolve from HOME, which
82
+ // the IAPEER_ROOT isolation does not cover — a test that forgot to isolate HOME would write into the
83
+ // operator's real OpenCode config (it happened once, 17.10).
84
+ if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') && resolve(env.HOME?.trim() || homedir()) === resolve(homedir())) {
85
+ throw new Error('IAPEER_TEST_SANDBOX: refusing to preinstall into the real HOME — inject an isolated HOME')
86
+ }
87
+ const dirs = opencodeConfigDirs(cwd, env)
88
+ mkdirSync(dirs[0]!, { recursive: true }) // the global config dir always exists natively
89
+ let version: string | undefined
90
+ const out: DependencyOutcome[] = []
91
+ for (const dir of dirs) {
92
+ if (pluginDependencySatisfied(dir)) {
93
+ out.push({ dir, state: 'present' })
94
+ continue
95
+ }
96
+ if (!writable(dir)) {
97
+ out.push({ dir, state: 'skipped-readonly' })
98
+ continue
99
+ }
100
+ try {
101
+ version ??= deps.version()
102
+ if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error(`unexpected opencode --version output: ${JSON.stringify(version)}`)
103
+ deps.npmInstall(dir, `${OPENCODE_PLUGIN_PACKAGE}@${version}`)
104
+ out.push(pluginDependencySatisfied(dir) ? { dir, state: 'installed' } : { dir, state: 'failed', detail: 'npm finished but the lock does not record the package' })
105
+ } catch (e) {
106
+ out.push({ dir, state: 'failed', detail: e instanceof Error ? e.message.split('\n')[0] : String(e) })
107
+ }
108
+ }
109
+ return out
110
+ }
@@ -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,362 @@
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
+ // Model of the last assistant turn — compaction runs on it. Seeded from history on resume.
149
+ let lastModel = null
150
+ let compactedAfter = 0
151
+ function noteModel(info) {
152
+ if (info && info.role === 'assistant' && typeof info.providerID === 'string' && typeof info.modelID === 'string') {
153
+ lastModel = { providerID: info.providerID, modelID: info.modelID }
154
+ }
155
+ }
156
+
157
+ // ── message acknowledgements: resolved by the native persist event ──────────
158
+ const waiters = new Map()
159
+ function awaitPersisted(messageID) {
160
+ return new Promise((resolve, reject) => {
161
+ const timer = setTimeout(() => {
162
+ waiters.delete(messageID)
163
+ reject(new Error('message ' + messageID + ' not persisted within ' + DELIVER_ACK_MS + 'ms'))
164
+ }, DELIVER_ACK_MS)
165
+ waiters.set(messageID, () => {
166
+ clearTimeout(timer)
167
+ waiters.delete(messageID)
168
+ resolve()
169
+ })
170
+ })
171
+ }
172
+
173
+ async function handle(req) {
174
+ switch (req && req.op) {
175
+ case 'ping':
176
+ return { ok: true, identity, launchID, sessionID: state.sessionID, status: state.status, lastEventSeq: state.lastEventSeq }
177
+ case 'deliver': {
178
+ if (typeof req.text !== 'string' || !req.text) return { ok: false, error: 'deliver: empty text' }
179
+ const sessionID = await ensureSession()
180
+ const messageID = ascendingId('msg')
181
+ const persisted = awaitPersisted(messageID)
182
+ const res = await client.session.promptAsync({
183
+ path: { id: sessionID },
184
+ body: { messageID, agent: 'iapeer', parts: [{ type: 'text', text: req.text }] },
185
+ })
186
+ if (res.error) {
187
+ waiters.delete(messageID)
188
+ persisted.catch(() => {})
189
+ return { ok: false, error: 'prompt_async: ' + JSON.stringify(res.error) }
190
+ }
191
+ await persisted
192
+ return { ok: true, sessionID, messageID }
193
+ }
194
+ case 'interrupt': {
195
+ if (!state.sessionID) return { ok: false, error: 'no session' }
196
+ const res = await client.session.abort({ path: { id: state.sessionID } })
197
+ return res.error ? { ok: false, error: JSON.stringify(res.error) } : { ok: true }
198
+ }
199
+ case 'compact': {
200
+ // Native compaction (POST /session/:id/summarize) runs the summary turn to completion before
201
+ // it returns; the ack additionally requires the native `session.compacted` record for THIS
202
+ // session after the request (a silent no-op is never reported as success).
203
+ if (!state.sessionID) return { ok: false, error: 'no session' }
204
+ if (!lastModel) return { ok: false, error: 'nothing to compact (no assistant turn in this session yet)' }
205
+ const before = seq
206
+ const res = await client.session.summarize({ path: { id: state.sessionID }, body: { providerID: lastModel.providerID, modelID: lastModel.modelID } })
207
+ if (res.error) return { ok: false, error: 'summarize: ' + JSON.stringify(res.error) }
208
+ for (let i = 0; i < 50 && compactedAfter <= before; i++) await new Promise(r => setTimeout(r, 100))
209
+ if (compactedAfter <= before) return { ok: false, error: 'summarize returned but no session.compacted was recorded' }
210
+ return { ok: true, sessionID: state.sessionID, lastEventSeq: compactedAfter }
211
+ }
212
+ case 'select':
213
+ await selectInTui()
214
+ return { ok: true, sessionID: state.sessionID }
215
+ default:
216
+ return { ok: false, error: 'unknown op ' + JSON.stringify(req && req.op) }
217
+ }
218
+ }
219
+
220
+ // ── human approval (gated, docs/17 + docs/20 OC10) ───────────────────────────
221
+ // Native 1.19.3 never calls the `permission.ask` plugin hook, so the bridge rides the
222
+ // `permission.asked` event: a blocking broker request per native permission, answered back
223
+ // through the native reply route. Every failure direction is reject (fail-safe). A human who
224
+ // answers the native TUI prompt first wins: `permission.replied` aborts the broker request,
225
+ // the daemon sees the transport close and clears the card from every face.
226
+ const gated = env.IAPEER_APPROVAL_MODE === 'gated'
227
+ const fleetUrl = env.IAPEER_FLEET_URL
228
+ const approvals = new Map()
229
+ const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s)
230
+ function approvalCard(p) {
231
+ const patterns = Array.isArray(p.patterns) ? p.patterns.map(String) : []
232
+ const meta = p.metadata && typeof p.metadata === 'object' ? p.metadata : {}
233
+ const head = patterns.join('\n') || String(p.permission)
234
+ const extra = Object.keys(meta).length ? '\n\n' + clip(JSON.stringify(meta, null, 2), 6000) : ''
235
+ return { tool: String(p.permission), content: clip(head, 2000) + extra, summary: clip(`${p.permission}: ${patterns.join(' ') || '?'}`, 200) }
236
+ }
237
+ async function replyNative(p, response) {
238
+ await client
239
+ .postSessionIdPermissionsPermissionId({ path: { id: p.sessionID, permissionID: p.id }, body: { response } })
240
+ .catch(() => {}) // already answered (human in the TUI) → native NotFound; nothing to do
241
+ }
242
+ async function brokerApproval(p) {
243
+ const controller = new AbortController()
244
+ approvals.set(p.id, controller)
245
+ let response = 'reject'
246
+ try {
247
+ if (!fleetUrl) throw new Error('no IAPEER_FLEET_URL')
248
+ const headers = { 'content-type': 'application/json' }
249
+ if (env.IAPEER_BEARER_TOKEN) headers.authorization = 'Bearer ' + env.IAPEER_BEARER_TOKEN
250
+ const card = approvalCard(p)
251
+ const res = await fetch(fleetUrl + '/fleet/v1/approvals', {
252
+ method: 'POST',
253
+ headers,
254
+ body: JSON.stringify({ personality: env.PEER_PERSONALITY, runtime: 'opencode', kind: 'tool', ...card }),
255
+ signal: controller.signal,
256
+ })
257
+ if (!res.ok) throw new Error('broker ' + res.status)
258
+ const d = await res.json()
259
+ if (d && d.decision === 'allow') response = 'once'
260
+ } catch (e) {
261
+ if (controller.signal.aborted) return // the human answered natively; never double-reply
262
+ } finally {
263
+ approvals.delete(p.id)
264
+ }
265
+ await replyNative(p, response)
266
+ }
267
+
268
+ // ── control socket ──────────────────────────────────────────────────────────
269
+ rmSync(paths.socket, { force: true })
270
+ const control = createServer(conn => {
271
+ let buf = ''
272
+ conn.setEncoding('utf8')
273
+ conn.on('data', chunk => {
274
+ buf += chunk
275
+ const nl = buf.indexOf('\n')
276
+ if (nl < 0) return
277
+ const line = buf.slice(0, nl)
278
+ buf = ''
279
+ let req
280
+ try {
281
+ req = JSON.parse(line)
282
+ } catch {
283
+ conn.end(JSON.stringify({ ok: false, error: 'bad request' }) + '\n')
284
+ return
285
+ }
286
+ handle(req).then(
287
+ r => conn.end(JSON.stringify(r) + '\n'),
288
+ e => conn.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }) + '\n'),
289
+ )
290
+ })
291
+ conn.on('error', () => {})
292
+ })
293
+ await new Promise((resolve, reject) => {
294
+ control.once('error', reject)
295
+ control.listen(paths.socket, () => resolve())
296
+ })
297
+
298
+ // Readiness runs AFTER init returns: calling the in-process client while OpenCode is
299
+ // still loading this plugin would wait on the very instance state being built.
300
+ // The socket is up and the session is known (fresh: created now) → runtime.ready —
301
+ // the core's readiness signal, never a screen glyph.
302
+ setTimeout(async () => {
303
+ const resumed = Boolean(state.sessionID)
304
+ try {
305
+ const sessionID = await ensureSession()
306
+ const got = await client.session.get({ path: { id: sessionID } })
307
+ if (!got.data) throw new Error('session ' + sessionID + ' not found: ' + JSON.stringify(got.error ?? null))
308
+ if (resumed) {
309
+ const hist = await client.session.messages({ path: { id: sessionID } }).catch(() => null)
310
+ for (const m of (hist && hist.data) || []) noteModel(m && m.info)
311
+ }
312
+ if (state.status === 'starting') state.status = 'idle'
313
+ journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
314
+ } catch (e) {
315
+ state.status = 'error'
316
+ state.error = e instanceof Error ? e.message : String(e)
317
+ journal('runtime.error', { error: state.error }, null, state.sessionID)
318
+ }
319
+ writeState()
320
+ // The TUI may not be subscribed yet; the core re-selects on demand via op:select.
321
+ setTimeout(() => void selectInTui(), 500)
322
+ }, 0)
323
+ writeState()
324
+
325
+ return {
326
+ event: async ({ event }) => {
327
+ const type = event && event.type
328
+ if (!JOURNALED.has(type)) return
329
+ const p = event.properties
330
+ const sessionID = sessionOf(type, p)
331
+ if (type === 'message.updated' && p && p.info && waiters.has(p.info.id)) waiters.get(p.info.id)()
332
+ if (type === 'message.updated' && p && sessionID === state.sessionID) noteModel(p.info)
333
+ if (gated && type === 'permission.asked' && p && typeof p.id === 'string' && !approvals.has(p.id)) void brokerApproval(p)
334
+ if (type === 'permission.replied' && p && approvals.has(p.requestID)) approvals.get(p.requestID).abort()
335
+ if (sessionID && sessionID === state.sessionID) {
336
+ if (type === 'session.status' && p.status) {
337
+ state.status = p.status.type === 'busy' || p.status.type === 'retry' ? 'busy' : p.status.type === 'idle' ? 'idle' : state.status
338
+ delete state.error
339
+ } else if (type === 'session.idle') state.status = 'idle'
340
+ else if (type === 'permission.asked') state.status = 'approval'
341
+ else if (type === 'permission.replied' && state.status === 'approval') state.status = 'busy'
342
+ else if (type === 'session.error') {
343
+ state.status = 'error'
344
+ state.error = JSON.stringify(p.error ?? null)
345
+ }
346
+ }
347
+ journal(type, p, event.id ?? null, sessionID)
348
+ if (type === 'session.compacted' && sessionID === state.sessionID) compactedAfter = seq
349
+ writeState()
350
+ },
351
+ dispose: async () => {
352
+ for (const c of approvals.values()) c.abort()
353
+ state.status = 'stopped'
354
+ journal('runtime.stopped', { reason: 'dispose' }, null, state.sessionID)
355
+ writeState()
356
+ await new Promise(resolve => control.close(() => resolve()))
357
+ rmSync(paths.socket, { force: true })
358
+ },
359
+ }
360
+ }
361
+
362
+ export default { id: 'iapeer', server }
@@ -0,0 +1,143 @@
1
+ // Core-side reader/writer of the OpenCode observability ABI (docs/20 §Observability ABI).
2
+ // The in-process plugin (iapeer-plugin.mjs) is the producer while the process lives; the core
3
+ // writes ONLY when the process is gone (supervisor death record) — so the journal stays
4
+ // single-writer at every instant. seq continues from the journal tail.
5
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs'
6
+
7
+ export type OpencodeStatus = 'starting' | 'idle' | 'busy' | 'approval' | 'error' | 'stopped'
8
+
9
+ export interface OpencodeState {
10
+ schema: 1
11
+ identity: string
12
+ cwd: string
13
+ sessionID: string | null
14
+ launchID: string
15
+ startedAt: number
16
+ lastEventSeq: number
17
+ lastActivityMs: number
18
+ status: OpencodeStatus
19
+ error?: string
20
+ }
21
+
22
+ export interface OpencodeJournalLine {
23
+ schema: 1
24
+ seq: number
25
+ time: number
26
+ identity: string
27
+ launchID: string
28
+ sessionID: string | null
29
+ type: string
30
+ properties: unknown
31
+ nativeID: string | null
32
+ }
33
+
34
+ export function readOpencodeState(path: string): OpencodeState | null {
35
+ try {
36
+ const s = JSON.parse(readFileSync(path, 'utf8')) as OpencodeState
37
+ return s && typeof s === 'object' && typeof s.launchID === 'string' ? s : null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ function writeStateAtomic(path: string, state: OpencodeState): void {
44
+ const tmp = `${path}.${process.pid}.tmp`
45
+ writeFileSync(tmp, JSON.stringify(state) + '\n', { mode: 0o600 })
46
+ renameSync(tmp, path)
47
+ }
48
+
49
+ /** Parsed journal lines appended at or after byte `offset` (a partial trailing line is skipped). */
50
+ export function readJournalFrom(path: string, offset: number): { lines: OpencodeJournalLine[]; end: number } {
51
+ if (!existsSync(path)) return { lines: [], end: 0 }
52
+ const fd = openSync(path, 'r')
53
+ try {
54
+ const size = fstatSync(fd).size
55
+ if (size <= offset) return { lines: [], end: size }
56
+ const buf = Buffer.alloc(size - offset)
57
+ readSync(fd, buf, 0, buf.length, offset)
58
+ const text = buf.toString('utf8')
59
+ const lastNl = text.lastIndexOf('\n')
60
+ if (lastNl < 0) return { lines: [], end: offset }
61
+ const lines: OpencodeJournalLine[] = []
62
+ for (const raw of text.slice(0, lastNl).split('\n')) {
63
+ if (!raw) continue
64
+ try {
65
+ lines.push(JSON.parse(raw) as OpencodeJournalLine)
66
+ } catch {
67
+ /* a malformed line is not ours to repair; consumers dedup by seq */
68
+ }
69
+ }
70
+ return { lines, end: offset + Buffer.byteLength(text.slice(0, lastNl + 1)) }
71
+ } finally {
72
+ closeSync(fd)
73
+ }
74
+ }
75
+
76
+ /** Last `seq` in the journal (tail read; the journal never shrinks). 0 when absent/empty. */
77
+ export function lastJournalSeq(path: string): number {
78
+ if (!existsSync(path)) return 0
79
+ const fd = openSync(path, 'r')
80
+ try {
81
+ const size = fstatSync(fd).size
82
+ let span = 4096
83
+ while (true) {
84
+ const start = Math.max(0, size - span)
85
+ const buf = Buffer.alloc(size - start)
86
+ readSync(fd, buf, 0, buf.length, start)
87
+ const lines = buf.toString('utf8').split('\n').filter(Boolean)
88
+ for (let i = lines.length - 1; i >= (start === 0 ? 0 : 1); i--) {
89
+ try {
90
+ const seq = (JSON.parse(lines[i]!) as { seq?: unknown }).seq
91
+ if (Number.isInteger(seq)) return seq as number
92
+ } catch {
93
+ /* skip */
94
+ }
95
+ }
96
+ if (start === 0) return 0
97
+ span *= 4
98
+ }
99
+ } finally {
100
+ closeSync(fd)
101
+ }
102
+ }
103
+
104
+ export function fileMtimeMs(path: string): number | null {
105
+ try {
106
+ return statSync(path).mtimeMs
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Supervisor-side death record: the process is gone, so the plugin cannot write `stopped`
114
+ * (dispose does not run on a signal). Appends exactly one `runtime.stopped` for the launch in
115
+ * state.json and flips status → stopped. Idempotent: a launch whose state already says stopped
116
+ * (graceful dispose wrote it) is left untouched. No state file → nothing launched → no record.
117
+ */
118
+ export function recordOpencodeStopped(
119
+ paths: { state: string; events: string },
120
+ info: { reason: string; exitCode?: number; signal?: string },
121
+ now: number = Date.now(),
122
+ ): boolean {
123
+ const state = readOpencodeState(paths.state)
124
+ if (!state || state.status === 'stopped') return false
125
+ const seq = lastJournalSeq(paths.events) + 1
126
+ const properties: Record<string, unknown> = { reason: info.reason }
127
+ if (info.exitCode !== undefined) properties.exitCode = info.exitCode
128
+ if (info.signal !== undefined) properties.signal = info.signal
129
+ const line: OpencodeJournalLine = {
130
+ schema: 1,
131
+ seq,
132
+ time: now,
133
+ identity: state.identity,
134
+ launchID: state.launchID,
135
+ sessionID: state.sessionID,
136
+ type: 'runtime.stopped',
137
+ properties,
138
+ nativeID: null,
139
+ }
140
+ appendFileSync(paths.events, JSON.stringify(line) + '\n', { mode: 0o600 })
141
+ writeStateAtomic(paths.state, { ...state, status: 'stopped', lastEventSeq: seq, lastActivityMs: now })
142
+ return true
143
+ }
@@ -141,7 +141,7 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
141
141
  const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
142
142
  const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
143
143
  const { pluginLogsDir } = await import('../storage/index.ts')
144
- const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
144
+ const agentic = profile.runtimes.filter(r => r === 'claude' || r === 'codex' || r === 'opencode')
145
145
  for (const rt of agentic) {
146
146
  const o = runProvisionCommand({
147
147
  block: slot.provision,
@@ -182,6 +182,36 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
182
182
  opts.warn?.(`memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
183
183
  }
184
184
 
185
+ // Capability-plugin lifecycle (docs/21): every registered capability provisions its
186
+ // surfaces for every agent endpoint the peer declares (occasion=birth). This joint
187
+ // also runs for add-runtime / default-runtime convergence (they call initPeer →
188
+ // here), so a new endpoint is never left without the host's capabilities.
189
+ // Idempotency is the plugin's obligation; a failure is LOUD but never kills a birth.
190
+ try {
191
+ const { runCapabilityLifecycle, listCapabilities } = await import('../capability/index.ts')
192
+ const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
193
+ const { pluginLogsDir } = await import('../storage/index.ts')
194
+ for (const bad of listCapabilities({ env }).invalid) {
195
+ opts.warn?.(`capability declaration ${bad.file} is INVALID (${bad.error}) — not provisioned for "${profile.personality}"`)
196
+ }
197
+ for (const run of runCapabilityLifecycle({ kind: 'provision', personality: profile.personality, cwd, runtimes: profile.runtimes, occasion: 'birth', env })) {
198
+ const o = run.outcome
199
+ appendLifecycleEvent(
200
+ pluginLogsDir('iapeer', { env }),
201
+ { ev: 'capability-provision', capability: run.capability, identity: `${run.runtime}-${profile.personality}`, occasion: 'birth', state: o.state, exit: o.exitCode ?? undefined, ms: o.durationMs, detail: o.detail },
202
+ { env },
203
+ )
204
+ if (o.state !== 'ok') {
205
+ opts.warn?.(
206
+ `capability ${run.capability} provision (${run.runtime}) ${o.state.toUpperCase()} for "${profile.personality}"` +
207
+ `${o.detail ? `: ${o.detail}` : ''} — repair: iapeer capability sync ${run.capability}`,
208
+ )
209
+ }
210
+ }
211
+ } catch (e) {
212
+ opts.warn?.(`capability birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
213
+ }
214
+
185
215
  // wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
186
216
  // lives in the hand-editable local profile, provision just surfaces the mismatch):
187
217
  // • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy