@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,192 @@
1
+ // opencode RuntimeAdapter — the "HOW to launch / observe ONE OpenCode session" half of the
2
+ // launch contract (docs/20-opencode.md). Unlike claude/codex, NOTHING here reads the screen:
3
+ // readiness, delivery and control go through the core-owned in-process plugin
4
+ // (src/opencode/iapeer-plugin.mjs) — its runtime.ready journal line, its control socket and its
5
+ // per-message persistence ack. The native TUI is still hosted by the pty supervisor (the human
6
+ // attach surface), but the core never pastes into it.
7
+ //
8
+ // Per-launch preparation (prepareLaunch):
9
+ // • write the plugin source into <root>/state/opencode/<p>/ (re-written every launch — the
10
+ // installed copy is never trusted to be current);
11
+ // • compose OPENCODE_CONFIG_CONTENT: the operator's opencode.json + inline env config, the memory
12
+ // and capability fragments, then the core-required fields (agent.iapeer.prompt = the freshly
13
+ // composed doctrine, the iapeer MCP, the plugin) — doctrine is re-read on EVERY process launch,
14
+ // including resume;
15
+ // • the plugin ABI env: IAPEER_OPENCODE_DIR / _LAUNCH_ID / _SESSION (resume only).
16
+ //
17
+ // State is personality-scoped (not cwd-scoped: two personalities may share a cwd), so the
18
+ // observation methods take the optional ObserveContext; without it they answer null/not-ok.
19
+
20
+ import { randomBytes } from 'node:crypto'
21
+ import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import { composeOpencodeConfig, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
23
+ import { opencodeControl } from '../../opencode/control.ts'
24
+ import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
25
+ import { resolveDaemonMcpUrl } from '../../storage/index.ts'
26
+ import { resolveFleetBase } from '../../approval/brokerClient.ts'
27
+ import type {
28
+ ApprovalMode,
29
+ ControlCommand,
30
+ ControlPlan,
31
+ LaunchAdapterConfig,
32
+ LaunchSpec,
33
+ ObserveContext,
34
+ PreparedLaunch,
35
+ RuntimeAdapter,
36
+ } from '../types.ts'
37
+
38
+ /** Compaction is a model turn; bound it like a long turn, not like a socket round-trip. */
39
+ export const OPENCODE_COMPACT_TIMEOUT_MS = 600_000
40
+
41
+ /** Journal byte offset at prepare time, per launchID — structuralReady scans only THIS launch's lines. */
42
+ const readyOffsets = new Map<string, number>()
43
+
44
+ function newLaunchID(): string {
45
+ return `lch_${Date.now().toString(36)}${randomBytes(6).toString('hex')}`
46
+ }
47
+
48
+ function paths(personality: string, env?: NodeJS.ProcessEnv) {
49
+ return opencodePaths(personality, { env })
50
+ }
51
+
52
+ function controlSocket(personality: string, env?: NodeJS.ProcessEnv): string {
53
+ return `${paths(personality, env).dir}/control.sock`
54
+ }
55
+
56
+ export const opencodeAdapter: RuntimeAdapter = {
57
+ runtime: 'opencode',
58
+ kind: 'tui',
59
+ usesDoctrine: true,
60
+ // No pty submit surface: the core never pastes into the OpenCode TUI.
61
+ deliveryMarkers: { promptGlyphs: [] },
62
+ deliveryConfirm: 'structural',
63
+
64
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
65
+ const argv = [cfg.opencodeBin ?? 'opencode']
66
+ if (spec.resume && spec.resumeRef) argv.push('--session', spec.resumeRef)
67
+ argv.push('--agent', OPENCODE_AGENT)
68
+ // yolo (default) = the fleet's bypass posture; gated launches without it so native
69
+ // permission requests reach the approval path (docs/17, OC10).
70
+ if ((spec.approvalMode ?? 'yolo') === 'yolo') argv.push('--auto')
71
+ if (spec.extraArgs?.length) argv.push(...spec.extraArgs)
72
+ return argv
73
+ },
74
+
75
+ prepareLaunch(spec, baseEnv, cfg): PreparedLaunch {
76
+ const env = cfg.env ?? process.env
77
+ if (!spec.systemPromptFile || !existsSync(spec.systemPromptFile)) {
78
+ throw new Error(`opencode: composed system prompt missing (${spec.systemPromptFile ?? 'none'})`)
79
+ }
80
+ const doctrine = readFileSync(spec.systemPromptFile, 'utf8')
81
+ const p = paths(spec.personality, env)
82
+ writeOpencodePlugin(spec.personality, OPENCODE_PLUGIN_SOURCE, { env })
83
+ const config = composeOpencodeConfig(
84
+ {
85
+ personality: spec.personality,
86
+ doctrine,
87
+ daemonUrl: resolveDaemonMcpUrl({ env }),
88
+ inline: baseEnv.OPENCODE_CONFIG_CONTENT,
89
+ approvalMode: spec.approvalMode ?? 'yolo',
90
+ },
91
+ { env },
92
+ )
93
+ const launchID = newLaunchID()
94
+ readyOffsets.set(launchID, existsSync(p.events) ? statSync(p.events).size : 0)
95
+ const out: Record<string, string> = {
96
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
97
+ IAPEER_OPENCODE_DIR: p.dir,
98
+ IAPEER_OPENCODE_LAUNCH_ID: launchID,
99
+ IAPEER_IDENTITY: spec.identity,
100
+ // A fleet peer never self-updates (config autoupdate:false says the same; the flag also covers
101
+ // native paths that read only the env). Updates are an operator action.
102
+ OPENCODE_DISABLE_AUTOUPDATE: '1',
103
+ // docs/17: the plugin bridges native permission asks to the daemon broker only when gated.
104
+ IAPEER_APPROVAL_MODE: spec.approvalMode ?? 'yolo',
105
+ IAPEER_FLEET_URL: resolveFleetBase(env),
106
+ }
107
+ if (spec.resume && spec.resumeRef) out.IAPEER_OPENCODE_SESSION = spec.resumeRef
108
+ return { env: out, readyToken: launchID }
109
+ },
110
+
111
+ async structuralReady(spec, launchID, deadlineMs, alive, env) {
112
+ const p = paths(spec.personality, env)
113
+ let offset = readyOffsets.get(launchID) ?? 0
114
+ try {
115
+ while (Date.now() < deadlineMs) {
116
+ const { lines, end } = readJournalFrom(p.events, offset)
117
+ offset = end
118
+ for (const l of lines) {
119
+ if (l.launchID !== launchID) continue
120
+ if (l.type === 'runtime.ready') {
121
+ // The control socket is up before readiness is journaled; one ping proves it serves.
122
+ const ping = await opencodeControl(controlSocket(spec.personality, env), { op: 'ping' }, 5000)
123
+ if (!ping.ok) return { ok: false, reason: `runtime.ready seen but control ping failed: ${ping.error}` }
124
+ if (ping.launchID !== launchID) return { ok: false, reason: `control socket answers launch ${ping.launchID}, expected ${launchID}` }
125
+ return { ok: true }
126
+ }
127
+ if (l.type === 'runtime.error') {
128
+ const e = (l.properties as { error?: unknown } | null)?.error
129
+ return { ok: false, reason: `runtime.error: ${typeof e === 'string' ? e : JSON.stringify(e)}` }
130
+ }
131
+ }
132
+ if (!alive()) return { ok: false, reason: 'process exited before runtime.ready' }
133
+ await new Promise(r => setTimeout(r, 500))
134
+ }
135
+ return { ok: false, reason: 'no runtime.ready before the boot deadline (plugin not loaded?)' }
136
+ } finally {
137
+ readyOffsets.delete(launchID)
138
+ }
139
+ },
140
+
141
+ async deliver(target, text, env) {
142
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'deliver', text })
143
+ return r.ok ? { ok: true, messageID: r.messageID } : { ok: false, error: r.error ?? 'deliver failed' }
144
+ },
145
+
146
+ async control(target, command, env) {
147
+ if (command.name === 'interrupt') {
148
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'interrupt' })
149
+ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'interrupt failed' }
150
+ }
151
+ if (command.name === 'compact') {
152
+ // Blocks until the native summary turn completed and session.compacted is recorded.
153
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'compact' }, OPENCODE_COMPACT_TIMEOUT_MS)
154
+ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'compact failed' }
155
+ }
156
+ return { ok: false, error: `opencode does not support control command "${command.name}"` }
157
+ },
158
+
159
+ // ── Screen predicates: deliberately inert (structural runtime). ──────────────
160
+ bootDialogKeys(): string[] | null {
161
+ return null
162
+ },
163
+ isInputReady(_pane: string, _mode?: ApprovalMode): boolean {
164
+ return false
165
+ },
166
+
167
+ newestActivityMtime(_cwd: string, ctx?: ObserveContext): number | null {
168
+ return ctx ? fileMtimeMs(paths(ctx.personality, ctx.env).events) : null
169
+ },
170
+
171
+ /** Busy/idle come from the plugin's state, not from a file mtime: lastActivityMs advances only on
172
+ * journaled native events. A busy/approval status counts as activity NOW (a long tool call). */
173
+ lastTurnMtime(_cwd: string, ctx?: ObserveContext): number | null {
174
+ if (!ctx) return null
175
+ const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
176
+ if (!s) return null
177
+ if (s.status === 'busy' || s.status === 'approval') return Date.now()
178
+ return s.lastActivityMs
179
+ },
180
+
181
+ resolveResume(_cwd: string, ctx?: ObserveContext): { ok: boolean; ref?: string; reason?: string } {
182
+ if (!ctx) return { ok: false, reason: 'opencode resume needs the personality (no observation context)' }
183
+ const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
184
+ if (!s?.sessionID) return { ok: false, reason: `no recorded OpenCode session for "${ctx.personality}"` }
185
+ return { ok: true, ref: s.sessionID }
186
+ },
187
+
188
+ // Control goes through `control` above; there is no pty key plan.
189
+ executeControl(_command: ControlCommand): ControlPlan | null {
190
+ return null
191
+ },
192
+ }
@@ -25,6 +25,7 @@ import { telegramAdapter } from './adapters/telegram.ts'
25
25
  import { notifierAdapter } from './adapters/notifier.ts'
26
26
  import { voicetalkAdapter } from './adapters/voicetalk.ts'
27
27
  import { webAdapter } from './adapters/web.ts'
28
+ import { opencodeAdapter } from './adapters/opencode.ts'
28
29
  import type {
29
30
  LaunchConfig,
30
31
  LaunchFn,
@@ -97,6 +98,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
97
98
  if (runtime === 'notifier') return notifierAdapter
98
99
  if (runtime === 'voicetalk') return voicetalkAdapter
99
100
  if (runtime === 'web') return webAdapter
101
+ if (runtime === 'opencode') return opencodeAdapter
100
102
  throw new Error(`no launch adapter for runtime "${runtime}"`)
101
103
  }
102
104
 
@@ -189,6 +191,19 @@ export const launch: LaunchFn = async (
189
191
  // loads here, default-on — and the router branch never reaches it).
190
192
  if (adapter.kind === 'tui' || adapter.kind === 'router') {
191
193
  const invocation = buildLaunchInvocation(spec, adapter, cfg)
194
+ // Structural runtimes (opencode) prepare launch artifacts + extra env BEFORE spawn. The identity
195
+ // ABI stays authoritative: prepared env never overrides PEER_PERSONALITY/RUNTIME/IDENTITY.
196
+ let readyToken: string | undefined
197
+ if (adapter.prepareLaunch) {
198
+ try {
199
+ const prepared = adapter.prepareLaunch(spec, invocation.env, cfg)
200
+ const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
201
+ invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
202
+ readyToken = prepared.readyToken
203
+ } catch (e) {
204
+ return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
205
+ }
206
+ }
192
207
  mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
193
208
  const paneLogPath = `${cfg.logDir}/${identity}.log`
194
209
  // SESSION-START BOUNDARY (premature-ready fix): the pane-log is a SHARED append-only file across a
@@ -219,6 +234,18 @@ export const launch: LaunchFn = async (
219
234
  if (hostSessionAlive(identity, env)) return ready(identity)
220
235
  return fail(identity, 'hosted router session not alive immediately after bringup')
221
236
  }
237
+ // STRUCTURAL runtime (opencode): readiness = the runtime's own ready record for THIS launch, and
238
+ // the first message goes through the adapter's deliver, whose ack means THIS message is persisted
239
+ // natively (a durable native queue) — so there is no pane gate and no activity ready-gate.
240
+ if (adapter.structuralReady && readyToken) {
241
+ const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
242
+ if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
243
+ if (firstMessage.trim().length === 0) return ready(identity)
244
+ if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
245
+ const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
246
+ if (!d.ok) return fail(identity, `structural deliver failed: ${d.error}`)
247
+ return ready(identity)
248
+ }
222
249
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
223
250
  // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
224
251
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
@@ -234,7 +261,7 @@ export const launch: LaunchFn = async (
234
261
  // alone push mtime past the baseline, so the gate below fires on the boot write — NOT on the
235
262
  // model PROCESSING firstMessage — returning ready/taskDelivered while the message is unseen
236
263
  // (silent loss). Snapped here — post-boot, pre-deliver — only the model's own turn advances it.
237
- const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
264
+ const baselineMtime = adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0
238
265
  // INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
239
266
  // session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
240
267
  // paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
@@ -255,7 +282,7 @@ export const launch: LaunchFn = async (
255
282
  while (Date.now() < readyDeadline) {
256
283
  await sleep(2000)
257
284
  if (!hostSessionAlive(identity, env)) return fail(identity, 'hosted session vanished during ready-gate')
258
- if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
285
+ if ((adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0) > baselineMtime) return ready(identity)
259
286
  }
260
287
  return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
261
288
  }
@@ -143,6 +143,8 @@ export interface LaunchAdapterConfig {
143
143
  voicetalkBin?: string
144
144
  /** web-runtime launch binary (router runtime, infra/always-on — the owner's browser fleet-console). */
145
145
  webBin?: string
146
+ /** OpenCode native TUI binary (agent runtime, docs/20). Absent → `opencode` on PATH. */
147
+ opencodeBin?: string
146
148
  }
147
149
 
148
150
  export interface LaunchSpec {
@@ -283,8 +285,11 @@ export interface RuntimeAdapter {
283
285
  * transcript grace here only ever FALSE-FAILs a message that WILL be processed,
284
286
  * wrongly escalating to a fallback peer. A genuinely dead session still fails
285
287
  * at the socket-ack (no socket / stalled flush) → really-dead still escalates.
288
+ * - 'structural' (opencode): the runtime has NO pty submit surface at all — delivery goes through
289
+ * the adapter's own `deliver` hook (a native control channel) whose acknowledgement already means
290
+ * THIS message was persisted natively. deliverViaHost calls `deliver` instead of the pty paste.
286
291
  */
287
- deliveryConfirm?: 'transcript' | 'socket-ack'
292
+ deliveryConfirm?: 'transcript' | 'socket-ack' | 'structural'
288
293
 
289
294
  /**
290
295
  * If set, launch REFUSES unless the peer's intelligence is IN this set (fail-loud —
@@ -381,13 +386,13 @@ export interface RuntimeAdapter {
381
386
  /** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
382
387
  * baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
383
388
  * this file without a new entry, so it is falsely fresh at idle. null for a router (no proxy). */
384
- newestActivityMtime(cwd: string): number | null
389
+ newestActivityMtime(cwd: string, ctx?: ObserveContext): number | null
385
390
 
386
391
  /** Content-timestamp (epoch ms) of the LAST meaningful transcript ENTRY — the reliable IDLE-accounting
387
392
  * signal. Unlike newestActivityMtime (file mtime, re-touched at idle) and the pane-log mtime (a
388
393
  * statusline re-render ticks it at idle), the entry stream only advances on real turn activity.
389
394
  * superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
390
- lastTurnMtime(cwd: string): number | null
395
+ lastTurnMtime(cwd: string, ctx?: ObserveContext): number | null
391
396
 
392
397
  /** В58 — newest FILE mtime among the session's CHILD workflow/subagent transcripts (a running workflow
393
398
  * writes them continuously). A peer that launched a long workflow (>1h) makes NO turns of its own, so
@@ -398,7 +403,7 @@ export interface RuntimeAdapter {
398
403
 
399
404
  /** Resume preflight — validate a resume request fail-loud (never silent fresh).
400
405
  * Returns the resolved ref (claude uuid) or ok:false with a reason. */
401
- resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
406
+ resolveResume(cwd: string, ctx?: ObserveContext): { ok: boolean; ref?: string; reason?: string }
402
407
 
403
408
  /**
404
409
  * Map an abstract in-session control command to this runtime's mechanism (a tmux
@@ -411,6 +416,39 @@ export interface RuntimeAdapter {
411
416
  * interrupt / drive a possibly-stuck session, exactly when normal delivery wouldn't.
412
417
  */
413
418
  executeControl(command: ControlCommand): ControlPlan | null
419
+
420
+ // ── Structural runtime hooks (docs/20 — OpenCode). All optional; a pty-glyph runtime (claude/codex)
421
+ // omits every one and launch/transport stay byte-identical for it. ─────────────────────────────────
422
+
423
+ /** Pre-spawn preparation (writes runtime-owned launch artifacts, e.g. the core plugin) → extra child
424
+ * env merged into the composed invocation BEFORE the identity ABI. `baseEnv` is the composed child env
425
+ * (base + launch.env) so an operator inline config is merged, not discarded. `readyToken` identifies
426
+ * THIS process launch for structuralReady (e.g. a launchID). Throws → launch FAILS loud. */
427
+ prepareLaunch?(spec: LaunchSpec, baseEnv: NodeJS.ProcessEnv, cfg: LaunchAdapterConfig & { env?: NodeJS.ProcessEnv }): PreparedLaunch
428
+
429
+ /** Structural readiness replacing the pane-glyph gate: resolves when the runtime itself reported
430
+ * readiness for `readyToken` (fail on deadline / reported error / `alive()` false). */
431
+ structuralReady?(spec: LaunchSpec, readyToken: string, deadlineMs: number, alive: () => boolean, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; reason?: string }>
432
+
433
+ /** Structural delivery (deliveryConfirm 'structural'): resolves ok only when THIS message is accepted
434
+ * and persisted by the runtime. */
435
+ deliver?(target: { personality: string; identity: string }, text: string, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; error?: string; messageID?: string }>
436
+
437
+ /** Structural in-session control (interrupt, …). A runtime that declares it is controlled through
438
+ * THIS, not executeControl's pty key plan. Unsupported command → ok:false with an explicit reason. */
439
+ control?(target: { personality: string; identity: string }, command: ControlCommand, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; error?: string }>
440
+ }
441
+
442
+ /** Optional per-peer observation context for adapters whose state is personality-scoped (opencode keeps
443
+ * it under <root>/state/opencode/<p>, not per-cwd). cwd-keyed runtimes ignore it. */
444
+ export interface ObserveContext {
445
+ personality: string
446
+ env?: NodeJS.ProcessEnv
447
+ }
448
+
449
+ export interface PreparedLaunch {
450
+ env: Record<string, string>
451
+ readyToken?: string
414
452
  }
415
453
 
416
454
  // ─────────────────────────────────────────────────────────────────────────────
@@ -60,6 +60,8 @@ import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
60
60
  export interface LifecycleConfig {
61
61
  claudeBin: string
62
62
  codexBin: string
63
+ /** OpenCode native binary (docs/20). Optional so hand-built test configs stay valid. */
64
+ opencodeBin?: string
63
65
  sockDir: string
64
66
  stateDir: string // ~/.iapeer/state/lifecycle
65
67
  logDir: string // ~/.iapeer/logs/lifecycle
@@ -109,6 +111,8 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
109
111
  return {
110
112
  claudeBin: env.IAPEER_CLAUDE_BIN ?? join(home, '.local', 'bin', 'claude'),
111
113
  codexBin: env.IAPEER_CODEX_BIN ?? 'codex',
114
+ // The official installer's location is not on launchd's PATH; prefer it over a bare name.
115
+ opencodeBin: env.IAPEER_OPENCODE_BIN ?? (existsSync(join(home, '.opencode', 'bin', 'opencode')) ? join(home, '.opencode', 'bin', 'opencode') : 'opencode'),
112
116
  sockDir: resolveSockDir(env),
113
117
  stateDir: join(root, STATE_DIR, 'lifecycle'),
114
118
  logDir: join(root, LOGS_DIR, 'lifecycle'),
@@ -1103,7 +1107,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1103
1107
  // by the death cause (.idle-reaped marker) + peer-type/topic. An EXPLICIT resume
1104
1108
  // that finds nothing to resume fails loud. incomingTopic (args.topic) is the
1105
1109
  // executor discriminator.
1106
- const mode = resolveWakeMode(cfg, identity, cwd, args.resume, c => adapter.resolveResume(c), args.topic)
1110
+ const mode = resolveWakeMode(cfg, identity, cwd, args.resume, c => adapter.resolveResume(c, { personality: args.personality, env }), args.topic)
1107
1111
  // The bring-up decision is the durable trace — log it BEFORE launch (the decision
1108
1112
  // stands regardless of whether the subsequent launch succeeds). resolveWakeMode has
1109
1113
  // already consumed any .idle-reaped marker, so `cause` is now its only record.
@@ -1158,6 +1162,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1158
1162
  const launchCfg: LaunchConfig = {
1159
1163
  claudeBin: cfg.claudeBin,
1160
1164
  codexBin: cfg.codexBin,
1165
+ ...(cfg.opencodeBin ? { opencodeBin: cfg.opencodeBin } : {}),
1161
1166
  sockDir: cfg.sockDir,
1162
1167
  bootDeadlineSecs: cfg.bootDeadlineSecs,
1163
1168
  readyGateSecs: cfg.readyGateSecs,
@@ -1295,7 +1300,7 @@ export interface SuperviseDeps {
1295
1300
  * transcript entry). This is the RELIABLE idle signal — NOT the transcript FILE mtime (re-saved at
1296
1301
  * idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
1297
1302
  * idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
1298
- lastTurnMtime?: (runtime: string, cwd: string) => number | null
1303
+ lastTurnMtime?: (runtime: string, cwd: string, personality?: string) => number | null
1299
1304
  /** В58 — child workflow/subagent activity seam (default: the adapter's childActivityMtime). Folded into
1300
1305
  * the idle proxy so a peer driving a long workflow (no turns of its own) is not reaped mid-work. */
1301
1306
  childActivityMtime?: (runtime: string, cwd: string) => number | null
@@ -1312,7 +1317,7 @@ interface SuperviseCtx {
1312
1317
  cfg: LifecycleConfig
1313
1318
  env: NodeJS.ProcessEnv
1314
1319
  nowMs: number
1315
- lastTurnMt: (runtime: string, cwd: string) => number | null
1320
+ lastTurnMt: (runtime: string, cwd: string, personality?: string) => number | null
1316
1321
  childActivityMt: (runtime: string, cwd: string) => number | null
1317
1322
  isAlive: (sock: string, identity: string) => boolean
1318
1323
  kill: (sock: string, identity: string) => void
@@ -1425,7 +1430,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1425
1430
  // governs (an old wokeAt = woken-long-ago = idle → reaped; a recent wokeAt = just-woken → protected).
1426
1431
  const ephemeral = isEphemeralPeer(s.cwd)
1427
1432
  let lt = 0
1428
- try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1433
+ try { lt = lastTurnMt(s.runtime, s.cwd, s.personality) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1429
1434
  // В58 — fold in CHILD workflow/subagent activity. A peer driving a long (>1h) workflow makes NO turns
1430
1435
  // of its own, so lt is stale; without this the busy-with-workflow session is idle-reaped mid-work (live
1431
1436
  // incident: iapeer-memory cut mid-audit). A running workflow writes its subagent transcripts
@@ -1507,7 +1512,9 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1507
1512
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
1508
1513
  const env = deps.env ?? process.env
1509
1514
  const nowMs = deps.nowMs ?? Date.now()
1510
- const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
1515
+ const lastTurnMt =
1516
+ deps.lastTurnMtime ??
1517
+ ((rt: string, c: string, p?: string) => getAdapter(rt as Runtime).lastTurnMtime(c, p ? { personality: p, env } : undefined))
1511
1518
  const childActivityMt =
1512
1519
  deps.childActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).childActivityMtime?.(c) ?? null)
1513
1520
  // Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
@@ -1799,7 +1806,7 @@ export function lastActiveRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runti
1799
1806
  let bestMt = -1
1800
1807
  for (const rt of peer.runtimes) {
1801
1808
  try {
1802
- const mt = getAdapter(rt).newestActivityMtime(peer.cwd)
1809
+ const mt = getAdapter(rt).newestActivityMtime(peer.cwd, { personality: peer.personality })
1803
1810
  if (mt !== null && mt > bestMt) {
1804
1811
  bestMt = mt
1805
1812
  best = rt
@@ -0,0 +1,121 @@
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 { opencodePrivateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
8
+ import pluginSource from './iapeer-plugin.mjs' with { type: 'text' }
9
+ import { listCapabilities } from '../capability/index.ts'
10
+
11
+ export const OPENCODE_AGENT = 'iapeer'
12
+ /** The memory provider's private fragment stem (docs/20 §Memory-provider ABI). */
13
+ export const OPENCODE_MEMORY_FRAGMENT = 'iapeer-memory'
14
+ type JsonObject = Record<string, unknown>
15
+
16
+ export function opencodePaths(personality: string, options: StorageOptions = {}) {
17
+ if (!isValidName(personality)) throw new Error(`invalid OpenCode personality: ${personality}`)
18
+ const dir = opencodePrivateDir(personality, options)
19
+ return {
20
+ dir,
21
+ plugin: join(dir, 'iapeer-plugin.mjs'),
22
+ state: join(dir, 'state.json'),
23
+ events: join(dir, 'events.jsonl'),
24
+ config: join(dir, 'opencode.json'), // optional operator-owned provider/model config
25
+ }
26
+ }
27
+
28
+ function object(value: unknown, source: string): JsonObject {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${source}: expected a JSON object`)
30
+ return value as JsonObject
31
+ }
32
+
33
+ /** OpenCode-like object merge; plugin arrays are additive, with stable dedup. */
34
+ export function mergeOpencodeConfig(base: JsonObject, extra: JsonObject): JsonObject {
35
+ const out = { ...base }
36
+ for (const [key, value] of Object.entries(extra)) {
37
+ if (key === 'plugin' && Array.isArray(value)) {
38
+ const all = [...(Array.isArray(out[key]) ? out[key] as unknown[] : []), ...value]
39
+ out[key] = all.filter((v, i) => all.findIndex(x => JSON.stringify(x) === JSON.stringify(v)) === i)
40
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
41
+ const prior = out[key]
42
+ out[key] = mergeOpencodeConfig(prior && typeof prior === 'object' && !Array.isArray(prior) ? prior as JsonObject : {}, value as JsonObject)
43
+ } else out[key] = value
44
+ }
45
+ return out
46
+ }
47
+
48
+ export function readOpencodeExtension(path: string): JsonObject {
49
+ const fragment = object(JSON.parse(readFileSync(path, 'utf8')), path)
50
+ for (const key of Object.keys(fragment)) {
51
+ if (key !== 'mcp' && key !== 'plugin') throw new Error(`${path}: unsupported extension field ${key}`)
52
+ }
53
+ if (fragment.mcp !== undefined) {
54
+ const mcp = object(fragment.mcp, `${path}: mcp`)
55
+ if ('iapeer' in mcp) throw new Error(`${path}: extension cannot replace core IAP MCP`)
56
+ for (const [name, config] of Object.entries(mcp)) object(config, `${path}: mcp.${name}`)
57
+ }
58
+ if (fragment.plugin !== undefined && (!Array.isArray(fragment.plugin) || fragment.plugin.some(p => typeof p !== 'string' || !p.startsWith('file:///')))) {
59
+ throw new Error(`${path}: plugins must be absolute file URLs`)
60
+ }
61
+ return fragment
62
+ }
63
+
64
+ export interface ComposeOpencodeConfigInput {
65
+ personality: string
66
+ doctrine: string
67
+ daemonUrl: string
68
+ /** Existing OPENCODE_CONFIG_CONTENT. It is merged, never discarded. */
69
+ inline?: string
70
+ /** docs/17. gated → the iapeer agent ASKS before exec/mutation/web (bash, edit, webfetch), and the
71
+ * plugin bridges each native ask to the human broker. yolo (default) adds nothing (`--auto`). */
72
+ approvalMode?: 'yolo' | 'gated'
73
+ }
74
+
75
+ /** Gated ask rules — the claude/codex «ask before acting» set mapped onto OpenCode permissions.
76
+ * Agent-level, so an operator's top-level `permission: {"*":"allow"}` cannot silently undo it. Reads,
77
+ * search and the core MCP stay allowed (as on gated claude). */
78
+ export const OPENCODE_GATED_PERMISSION = { bash: 'ask', edit: 'ask', webfetch: 'ask', external_directory: 'ask', doom_loop: 'ask' } as const
79
+
80
+ /** No writes and no env resolution except through injected StorageOptions. */
81
+ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options: StorageOptions = {}): JsonObject {
82
+ if (!input.doctrine.trim()) throw new Error('OpenCode requires a non-empty composed system prompt')
83
+ const paths = opencodePaths(input.personality, options)
84
+ let result: JsonObject = existsSync(paths.config) ? object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config) : {}
85
+ if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
86
+ // Fragment owners: the memory provider + every REGISTERED capability (docs/21) — never a
87
+ // hardcoded plugin list. An unregistered plugin's leftover fragment is not merged.
88
+ const stems = [OPENCODE_MEMORY_FRAGMENT, ...listCapabilities(options).declarations.map(d => d.capability)]
89
+ for (const stem of stems) {
90
+ const path = join(paths.dir, `${stem}.json`)
91
+ if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
92
+ }
93
+ // Last writer for required fields; foreign MCP servers, agents, plugins and
94
+ // provider settings survive. Effective managed overrides still need a live gate.
95
+ return mergeOpencodeConfig(result, {
96
+ autoupdate: false,
97
+ share: 'disabled',
98
+ default_agent: OPENCODE_AGENT,
99
+ agent: {
100
+ [OPENCODE_AGENT]: {
101
+ mode: 'primary',
102
+ prompt: input.doctrine,
103
+ ...(input.approvalMode === 'gated' ? { permission: { ...OPENCODE_GATED_PERMISSION } } : {}),
104
+ },
105
+ },
106
+ mcp: { iapeer: { type: 'remote', url: input.daemonUrl, enabled: true, oauth: false, headers: { 'X-IAPeer-Identity': '{env:PEER_IDENTITY}' } } },
107
+ plugin: [pathToFileURL(paths.plugin).href],
108
+ })
109
+ }
110
+
111
+ /** Installer/launcher must pass only trusted core source. Provider secrets stay in
112
+ * native env/config, never interpolated into this executable source artifact. */
113
+ export function writeOpencodePlugin(personality: string, source: string, options: StorageOptions = {}): string {
114
+ const paths = opencodePaths(personality, options)
115
+ mkdirSync(paths.dir, { recursive: true, mode: 0o700 })
116
+ writeFileAtomic(paths.plugin, source, 0o600)
117
+ return paths.plugin
118
+ }
119
+
120
+ /** The core-owned plugin source shipped with this package (embedded in the compiled binary). */
121
+ export const OPENCODE_PLUGIN_SOURCE: string = pluginSource
@@ -0,0 +1,56 @@
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
+ | { op: 'compact' }
12
+
13
+ export interface OpencodeControlResponse {
14
+ ok: boolean
15
+ error?: string
16
+ identity?: string
17
+ launchID?: string
18
+ sessionID?: string | null
19
+ messageID?: string
20
+ status?: string
21
+ lastEventSeq?: number
22
+ }
23
+
24
+ export function opencodeControl(
25
+ socketPath: string,
26
+ request: OpencodeControlRequest,
27
+ timeoutMs = 20000,
28
+ ): Promise<OpencodeControlResponse> {
29
+ return new Promise(resolve => {
30
+ let buf = ''
31
+ let settled = false
32
+ const done = (r: OpencodeControlResponse) => {
33
+ if (settled) return
34
+ settled = true
35
+ clearTimeout(timer)
36
+ sock.destroy()
37
+ resolve(r)
38
+ }
39
+ const sock = connect(socketPath)
40
+ const timer = setTimeout(() => done({ ok: false, error: `control ${request.op}: no response within ${timeoutMs}ms` }), timeoutMs)
41
+ sock.setEncoding('utf8')
42
+ sock.on('connect', () => sock.write(JSON.stringify(request) + '\n'))
43
+ sock.on('data', chunk => {
44
+ buf += chunk
45
+ const nl = buf.indexOf('\n')
46
+ if (nl < 0) return
47
+ try {
48
+ done(JSON.parse(buf.slice(0, nl)) as OpencodeControlResponse)
49
+ } catch {
50
+ done({ ok: false, error: `control ${request.op}: malformed response` })
51
+ }
52
+ })
53
+ sock.on('error', e => done({ ok: false, error: `control ${request.op}: ${e.message}` }))
54
+ sock.on('close', () => done({ ok: false, error: `control ${request.op}: connection closed without a response` }))
55
+ })
56
+ }