@agfpd/iapeer 0.4.114 → 0.4.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/15-fleet-api.md +7 -3
- package/docs/20-opencode.md +323 -0
- package/docs/21-capabilities.md +83 -0
- package/docs/README.md +2 -0
- package/package.json +1 -1
- package/src/approval/install.ts +8 -0
- package/src/capability/cli.ts +106 -0
- package/src/capability/index.ts +160 -0
- package/src/cli/index.ts +75 -6
- package/src/core/constants.ts +1 -1
- package/src/daemon/fleet.ts +19 -0
- package/src/enable/provisionCommand.ts +12 -3
- package/src/identity/index.ts +1 -1
- package/src/init/index.ts +40 -28
- package/src/launch/adapters/opencode.ts +192 -0
- package/src/launch/index.ts +29 -2
- package/src/launch/types.ts +42 -4
- package/src/lifecycle/index.ts +13 -6
- package/src/opencode/config.ts +121 -0
- package/src/opencode/control.ts +56 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.d.mts +4 -0
- package/src/opencode/iapeer-plugin.mjs +362 -0
- package/src/opencode/journal.ts +143 -0
- package/src/provision/index.ts +31 -1
- package/src/storage/index.ts +50 -0
- package/src/supervisor/daemon.ts +23 -1
- package/src/transport/index.ts +29 -2
|
@@ -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
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -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
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|