@agfpd/iapeer 0.4.115 → 0.4.117
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 +2 -1
- package/docs/20-opencode.md +103 -6
- 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 +79 -7
- package/src/core/constants.ts +1 -1
- package/src/daemon/fleet.ts +9 -0
- package/src/daemon/main.ts +21 -1
- 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 +24 -5
- package/src/opencode/control.ts +1 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.mjs +79 -0
- package/src/opencode/journal.ts +143 -0
- package/src/provision/index.ts +31 -1
- package/src/storage/index.ts +50 -0
- package/src/supervisor/daemon.ts +23 -1
- package/src/transport/index.ts +29 -2
package/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
|
package/src/opencode/config.ts
CHANGED
|
@@ -4,16 +4,18 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import { isValidName } from '../core/constants.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { opencodePrivateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
8
8
|
import pluginSource from './iapeer-plugin.mjs' with { type: 'text' }
|
|
9
|
+
import { listCapabilities } from '../capability/index.ts'
|
|
9
10
|
|
|
10
11
|
export const OPENCODE_AGENT = 'iapeer'
|
|
11
|
-
|
|
12
|
+
/** The memory provider's private fragment stem (docs/20 §Memory-provider ABI). */
|
|
13
|
+
export const OPENCODE_MEMORY_FRAGMENT = 'iapeer-memory'
|
|
12
14
|
type JsonObject = Record<string, unknown>
|
|
13
15
|
|
|
14
16
|
export function opencodePaths(personality: string, options: StorageOptions = {}) {
|
|
15
17
|
if (!isValidName(personality)) throw new Error(`invalid OpenCode personality: ${personality}`)
|
|
16
|
-
const dir =
|
|
18
|
+
const dir = opencodePrivateDir(personality, options)
|
|
17
19
|
return {
|
|
18
20
|
dir,
|
|
19
21
|
plugin: join(dir, 'iapeer-plugin.mjs'),
|
|
@@ -65,15 +67,26 @@ export interface ComposeOpencodeConfigInput {
|
|
|
65
67
|
daemonUrl: string
|
|
66
68
|
/** Existing OPENCODE_CONFIG_CONTENT. It is merged, never discarded. */
|
|
67
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'
|
|
68
73
|
}
|
|
69
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
|
+
|
|
70
80
|
/** No writes and no env resolution except through injected StorageOptions. */
|
|
71
81
|
export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options: StorageOptions = {}): JsonObject {
|
|
72
82
|
if (!input.doctrine.trim()) throw new Error('OpenCode requires a non-empty composed system prompt')
|
|
73
83
|
const paths = opencodePaths(input.personality, options)
|
|
74
84
|
let result: JsonObject = existsSync(paths.config) ? object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config) : {}
|
|
75
85
|
if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
|
|
76
|
-
|
|
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) {
|
|
77
90
|
const path = join(paths.dir, `${stem}.json`)
|
|
78
91
|
if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
|
|
79
92
|
}
|
|
@@ -83,7 +96,13 @@ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options
|
|
|
83
96
|
autoupdate: false,
|
|
84
97
|
share: 'disabled',
|
|
85
98
|
default_agent: OPENCODE_AGENT,
|
|
86
|
-
agent: {
|
|
99
|
+
agent: {
|
|
100
|
+
[OPENCODE_AGENT]: {
|
|
101
|
+
mode: 'primary',
|
|
102
|
+
prompt: input.doctrine,
|
|
103
|
+
...(input.approvalMode === 'gated' ? { permission: { ...OPENCODE_GATED_PERMISSION } } : {}),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
87
106
|
mcp: { iapeer: { type: 'remote', url: input.daemonUrl, enabled: true, oauth: false, headers: { 'X-IAPeer-Identity': '{env:PEER_IDENTITY}' } } },
|
|
88
107
|
plugin: [pathToFileURL(paths.plugin).href],
|
|
89
108
|
})
|
package/src/opencode/control.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -145,6 +145,15 @@ async function server(input) {
|
|
|
145
145
|
await client.tui.publish({ body: { type: 'tui.session.select', properties: { sessionID: state.sessionID } } }).catch(() => {})
|
|
146
146
|
}
|
|
147
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
|
+
|
|
148
157
|
// ── message acknowledgements: resolved by the native persist event ──────────
|
|
149
158
|
const waiters = new Map()
|
|
150
159
|
function awaitPersisted(messageID) {
|
|
@@ -187,6 +196,19 @@ async function server(input) {
|
|
|
187
196
|
const res = await client.session.abort({ path: { id: state.sessionID } })
|
|
188
197
|
return res.error ? { ok: false, error: JSON.stringify(res.error) } : { ok: true }
|
|
189
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
|
+
}
|
|
190
212
|
case 'select':
|
|
191
213
|
await selectInTui()
|
|
192
214
|
return { ok: true, sessionID: state.sessionID }
|
|
@@ -195,6 +217,54 @@ async function server(input) {
|
|
|
195
217
|
}
|
|
196
218
|
}
|
|
197
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
|
+
|
|
198
268
|
// ── control socket ──────────────────────────────────────────────────────────
|
|
199
269
|
rmSync(paths.socket, { force: true })
|
|
200
270
|
const control = createServer(conn => {
|
|
@@ -235,6 +305,10 @@ async function server(input) {
|
|
|
235
305
|
const sessionID = await ensureSession()
|
|
236
306
|
const got = await client.session.get({ path: { id: sessionID } })
|
|
237
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
|
+
}
|
|
238
312
|
if (state.status === 'starting') state.status = 'idle'
|
|
239
313
|
journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
|
|
240
314
|
} catch (e) {
|
|
@@ -255,6 +329,9 @@ async function server(input) {
|
|
|
255
329
|
const p = event.properties
|
|
256
330
|
const sessionID = sessionOf(type, p)
|
|
257
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()
|
|
258
335
|
if (sessionID && sessionID === state.sessionID) {
|
|
259
336
|
if (type === 'session.status' && p.status) {
|
|
260
337
|
state.status = p.status.type === 'busy' || p.status.type === 'retry' ? 'busy' : p.status.type === 'idle' ? 'idle' : state.status
|
|
@@ -268,9 +345,11 @@ async function server(input) {
|
|
|
268
345
|
}
|
|
269
346
|
}
|
|
270
347
|
journal(type, p, event.id ?? null, sessionID)
|
|
348
|
+
if (type === 'session.compacted' && sessionID === state.sessionID) compactedAfter = seq
|
|
271
349
|
writeState()
|
|
272
350
|
},
|
|
273
351
|
dispose: async () => {
|
|
352
|
+
for (const c of approvals.values()) c.abort()
|
|
274
353
|
state.status = 'stopped'
|
|
275
354
|
journal('runtime.stopped', { reason: 'dispose' }, null, state.sessionID)
|
|
276
355
|
writeState()
|