@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.
@@ -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
@@ -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 { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
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
- export const OPENCODE_EXTENSION_FRAGMENTS = ['iapeer-memory', 'statusline-injector'] as const
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 = join(options.rootDir ?? resolveGlobalRoot(options.env), 'peers', personality, 'opencode')
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
- for (const stem of OPENCODE_EXTENSION_FRAGMENTS) {
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: { [OPENCODE_AGENT]: { mode: 'primary', prompt: input.doctrine } },
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
  })
@@ -8,6 +8,7 @@ export type OpencodeControlRequest =
8
8
  | { op: 'deliver'; text: string }
9
9
  | { op: 'interrupt' }
10
10
  | { op: 'select' }
11
+ | { op: 'compact' }
11
12
 
12
13
  export interface OpencodeControlResponse {
13
14
  ok: boolean
@@ -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()