@agfpd/iapeer 0.4.115 → 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/src/cli/index.ts CHANGED
@@ -154,7 +154,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
154
154
  let bestMt = -1
155
155
  for (const rt of peer.runtimes) {
156
156
  try {
157
- const mt = getAdapter(rt).newestActivityMtime(peer.cwd)
157
+ const mt = getAdapter(rt).newestActivityMtime(peer.cwd, { personality: peer.personality, env })
158
158
  if (mt !== null && mt > bestMt) {
159
159
  bestMt = mt
160
160
  lastActive = rt
@@ -367,7 +367,7 @@ export interface RefreshOutcome {
367
367
 
368
368
  /** Runtimes that consume the layered system prompt (doctrine/fragments) — the only ones a soft-reload
369
369
  * affects. Routers (notifier/telegram) and absent runtimes carry no doctrine. */
370
- const AGENTIC_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
370
+ const AGENTIC_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex', 'opencode'])
371
371
 
372
372
  /**
373
373
  * refresh <peer> [runtime] (or --all): arm a LAZY soft-reload. Each agentic (claude/codex) runtime of the
@@ -813,6 +813,9 @@ export async function compactPeer(
813
813
  const control = opts.controlFn ?? routeControl
814
814
  const compactDone = opts.compactDoneFn ?? waitForCompactDone
815
815
  const waitDone = (rt: Runtime, baseline: ReturnType<typeof compactDoneBaseline>, woke: boolean): CompactPeerOutcome | null => {
816
+ // A structural runtime's control already returned only after the native compaction was recorded
817
+ // (docs/20) — there is no transcript marker to wait for.
818
+ if (getAdapter(rt).deliveryConfirm === 'structural') return null
816
819
  const done = compactDone(
817
820
  {
818
821
  personality,
@@ -928,6 +931,8 @@ export interface RemoveOutcome {
928
931
  /** Recipient-owned IAP attachment inbox cleanup. Completed attachment copies
929
932
  * are state for the peer lifetime, so explicit peer removal owns their purge. */
930
933
  attachmentInbox?: string
934
+ /** OpenCode private state dir (<root>/state/opencode/<p>) removed with the peer. */
935
+ opencodeState?: string
931
936
  }
932
937
 
933
938
  /**
@@ -1018,7 +1023,7 @@ export async function removePeerCli(
1018
1023
  const slot = readMemoryProvider(env)
1019
1024
  if (slot?.unprovision) {
1020
1025
  const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
1021
- const agentic = peer.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
1026
+ const agentic = peer.runtimes.filter(r => r === 'claude' || r === 'codex' || r === 'opencode')
1022
1027
  for (const rt of agentic) {
1023
1028
  const o = runProvisionCommand({
1024
1029
  block: slot.unprovision,
@@ -1041,11 +1046,26 @@ export async function removePeerCli(
1041
1046
  },
1042
1047
  { env },
1043
1048
  )
1044
- unprovisionOutcomes.push(`${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
1049
+ unprovisionOutcomes.push(`memory/${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
1045
1050
  }
1046
1051
  }
1047
1052
  } catch (e) {
1048
- unprovisionOutcomes.push(`failed: ${e instanceof Error ? e.message : String(e)}`)
1053
+ unprovisionOutcomes.push(`memory failed: ${e instanceof Error ? e.message : String(e)}`)
1054
+ }
1055
+ // Capability-plugin unprovision (docs/21) — same joint, same best-effort contract.
1056
+ try {
1057
+ const { runCapabilityLifecycle } = await import('../capability/index.ts')
1058
+ for (const run of runCapabilityLifecycle({ kind: 'unprovision', personality, cwd: peer.cwd, runtimes: peer.runtimes, occasion: 'remove', env })) {
1059
+ const o = run.outcome
1060
+ appendLifecycleEvent(
1061
+ pluginLogsDir('iapeer', { env }),
1062
+ { ev: 'capability-provision', capability: run.capability, identity: `${run.runtime}-${personality}`, occasion: 'remove', state: o.state, exit: o.exitCode ?? undefined, ms: o.durationMs, detail: o.detail },
1063
+ { env },
1064
+ )
1065
+ unprovisionOutcomes.push(`${run.capability}/${run.runtime}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
1066
+ }
1067
+ } catch (e) {
1068
+ unprovisionOutcomes.push(`capabilities failed: ${e instanceof Error ? e.message : String(e)}`)
1049
1069
  }
1050
1070
  // Codex pre-trust cleanup (reap-side counterpart of the birth-time
1051
1071
  // preTrustCodexCwd): a removed codex peer must
@@ -1086,12 +1106,27 @@ export async function removePeerCli(
1086
1106
  } catch (e) {
1087
1107
  attachmentInbox = `FAILED: ${e instanceof Error ? e.message : String(e)}`
1088
1108
  }
1109
+ // OpenCode private state (journal, state, plugin, control socket, provider fragments) lives under
1110
+ // <root>/state/opencode/<p>, outside the cwd — it must not outlive the peer and ambush a namesake.
1111
+ // After unprovision above, so the memory/capability owners removed their own files first.
1112
+ let opencodeState: string | undefined
1113
+ try {
1114
+ const { opencodePaths } = await import('../opencode/config.ts')
1115
+ const dir = opencodePaths(personality, { env }).dir
1116
+ if (existsSync(dir)) {
1117
+ rmSync(dir, { recursive: true, force: true })
1118
+ opencodeState = `removed ${dir}`
1119
+ }
1120
+ } catch (e) {
1121
+ opencodeState = `FAILED: ${e instanceof Error ? e.message : String(e)}`
1122
+ }
1089
1123
  return {
1090
1124
  personality,
1091
1125
  action: 'removed',
1092
1126
  cwd: peer.cwd,
1093
1127
  purgedState,
1094
1128
  ...(attachmentInbox ? { attachmentInbox } : {}),
1129
+ ...(opencodeState ? { opencodeState } : {}),
1095
1130
  ...(plistTeardown ? { plistTeardown } : {}),
1096
1131
  ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
1097
1132
  ...(trustCleaned ? { codexTrust: trustCleaned } : {}),
@@ -1554,6 +1589,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1554
1589
  { sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
1555
1590
  { sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
1556
1591
  { sig: 'self-done', desc: '(agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one' },
1592
+ { sig: 'capability <list | register <decl.json> | sync <name> | unregister <name>>', desc: 'capability-plugin lifecycle: per-peer provision/unprovision on every agent endpoint (docs/21)' },
1557
1593
  { sig: 'native-memory <off|on> (--peer <p> | --all)', desc: "gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)" },
1558
1594
  { sig: 'trust-hooks <hooks.json> [--check]', desc: 'pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report' },
1559
1595
  {
@@ -1564,6 +1600,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1564
1600
  sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
1565
1601
  desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
1566
1602
  },
1603
+ { sig: 'private-dir <peer> [--runtime r]', desc: 'print the core-owned private directory of a peer endpoint (docs/20: opencode journal/state/fragments). The ONLY way a process not launched by the core gets the path — never derive the layout. Without --runtime: the single declared runtime that has one. Exit 1 when none' },
1567
1604
  { sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
1568
1605
  { sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
1569
1606
  { sig: 'approve <id>', desc: 'approve a pending request — the decision clears it in every channel' },
@@ -1839,6 +1876,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1839
1876
  if (tcc) out(tcc)
1840
1877
  return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
1841
1878
  }
1879
+ case 'capability': {
1880
+ // Capability-plugin lifecycle (docs/21): register / sync / unregister / list.
1881
+ const { capabilityCli } = await import('../capability/cli.ts')
1882
+ return capabilityCli(positionals, env, out, errOut)
1883
+ }
1842
1884
  case 'native-memory': {
1843
1885
  // The canonized runtime-memory lever (контракт «Слот памяти» §Native-память):
1844
1886
  // off = explicit disable merged into the peer's runtime config files; on =
@@ -2237,6 +2279,32 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2237
2279
  for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
2238
2280
  return outcomes.some(o => o.action === 'failed') ? 1 : 0
2239
2281
  }
2282
+ case 'private-dir': {
2283
+ const name = positionals[0]
2284
+ if (!name) return argErr(errOut, 'private-dir needs a peer — usage: iapeer private-dir <peer> [--runtime r]')
2285
+ const peer = findPeer(readPeersIndex({ env }), name)
2286
+ if (!peer) {
2287
+ errOut(`peer "${name}" is not registered\n`)
2288
+ return 1
2289
+ }
2290
+ const { endpointPrivateDir } = await import('../storage/index.ts')
2291
+ const wanted = typeof flags.runtime === 'string' ? flags.runtime : undefined
2292
+ if (wanted && !peer.runtimes.includes(wanted)) {
2293
+ errOut(`peer "${name}" does not declare runtime "${wanted}" (declared: ${peer.runtimes.join(', ')})\n`)
2294
+ return 1
2295
+ }
2296
+ const candidates = (wanted ? [wanted] : peer.runtimes).filter(rt => endpointPrivateDir(rt, name, { env }))
2297
+ if (candidates.length !== 1) {
2298
+ errOut(
2299
+ candidates.length === 0
2300
+ ? `no private directory for "${name}"${wanted ? ` (${wanted})` : ''} — only opencode endpoints have one\n`
2301
+ : `"${name}" has several endpoints with a private directory (${candidates.join(', ')}) — pass --runtime\n`,
2302
+ )
2303
+ return 1
2304
+ }
2305
+ out(`${endpointPrivateDir(candidates[0]!, name, { env })}\n`)
2306
+ return 0
2307
+ }
2240
2308
  case 'default-runtime': {
2241
2309
  // The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
2242
2310
  const rt = positionals[0]
@@ -2313,7 +2381,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2313
2381
  }
2314
2382
  // v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
2315
2383
  if (o.unprovision?.length) {
2316
- out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
2384
+ out(`unprovision: ${o.unprovision.join(', ')}\n`)
2317
2385
  }
2318
2386
  // Codex pre-trust cleanup — the cwd's trust entry must die with the peer.
2319
2387
  if (o.codexTrust) {
@@ -2328,6 +2396,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2328
2396
  if (o.purgedState?.length) {
2329
2397
  out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
2330
2398
  }
2399
+ if (o.opencodeState) out(`opencode state: ${o.opencodeState}\n`)
2331
2400
  if (o.attachmentInbox) {
2332
2401
  out(`attachment inbox: ${o.attachmentInbox}\n`)
2333
2402
  }
@@ -11,7 +11,7 @@ export const RUNTIME_RE_SOURCE = '^[a-z][a-z0-9]{0,31}$'
11
11
 
12
12
  export type Runtime = string
13
13
  export type TmuxRuntime = Runtime
14
- export const SUPPORTED_LOCAL_RUNTIMES = ['claude', 'codex'] as const
14
+ export const SUPPORTED_LOCAL_RUNTIMES = ['claude', 'codex', 'opencode'] as const
15
15
  export type SupportedLocalRuntime = (typeof SUPPORTED_LOCAL_RUNTIMES)[number]
16
16
 
17
17
  export const PEERS_SCHEMA_VERSION = 2
@@ -37,6 +37,7 @@ import type { IncomingMessage, ServerResponse } from 'http'
37
37
  import { closeSync, openSync, readSync, statSync } from 'fs'
38
38
  import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
39
39
  import { buildProcessAddress } from '../core/socket.ts'
40
+ import { endpointPrivateDir } from '../storage/index.ts'
40
41
  import { IAPEER_VERSION } from '../core/version.ts'
41
42
  import {
42
43
  ephemeralQueueDepth,
@@ -85,6 +86,9 @@ export interface FleetRuntimeStatus {
85
86
  * `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
86
87
  * A declared runtime the running core cannot host (newer peer, older core) is `false`. */
87
88
  agent: boolean
89
+ /** Core-owned private directory of this endpoint (docs/20: opencode journal/state). Absent when the
90
+ * runtime has none. Observers read it from here; they never derive the layout. */
91
+ private_dir?: string
88
92
  /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
89
93
  attached?: boolean
90
94
  /** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
@@ -236,6 +240,10 @@ export function buildFleetSnapshot(
236
240
  * never emitted, which is exactly its "no claim" semantics. */
237
241
  activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
238
242
  ): FleetSnapshot {
243
+ const privateDirOf = (runtime: string, personality: string): { private_dir?: string } => {
244
+ const d = endpointPrivateDir(runtime, personality, { env })
245
+ return d ? { private_dir: d } : {}
246
+ }
239
247
  const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
240
248
  const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
241
249
  const attached =
@@ -247,6 +255,7 @@ export function buildFleetSnapshot(
247
255
  runtime: s.runtime,
248
256
  status: s.status,
249
257
  agent: isSupportedLocalRuntime(s.runtime),
258
+ ...(privateDirOf(s.runtime, r.personality)),
250
259
  ...(attached ? { attached: true } : {}),
251
260
  ...(activity ? { activity } : {}),
252
261
  }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { spawnSync } from 'child_process'
20
20
  import { accessSync, constants as FS } from 'fs'
21
+ import { endpointPrivateDir } from '../storage/index.ts'
21
22
  import type { MemoryProviderProvision } from '../status/index.ts'
22
23
 
23
24
  /** The agreed occasion vocabulary (финален). */
@@ -48,8 +49,9 @@ export interface ProvisionCommandOutcome {
48
49
  argv: string[]
49
50
  }
50
51
 
51
- function substitute(arg: string, s: ProvisionCommandSpec): string {
52
+ function substitute(arg: string, s: ProvisionCommandSpec, privateDir: string | undefined): string {
52
53
  return arg
54
+ .replaceAll('{privateDir}', privateDir ?? '{privateDir}')
53
55
  .replaceAll('{cwd}', s.cwd)
54
56
  .replaceAll('{runtime}', s.runtime)
55
57
  .replaceAll('{personality}', s.personality)
@@ -70,9 +72,16 @@ function tail(text: string, max = 400): string | undefined {
70
72
  * must not kill a birth or a remove).
71
73
  */
72
74
  export function runProvisionCommand(spec: ProvisionCommandSpec): ProvisionCommandOutcome {
73
- const env = spec.env ?? process.env
74
- const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec))]
75
+ const baseEnv = spec.env ?? process.env
76
+ const privateDir = endpointPrivateDir(spec.runtime, spec.personality, { env: baseEnv })
77
+ const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec, privateDir))]
75
78
  const started = Date.now()
79
+ // {privateDir} for a runtime that has none is a declaration error — refuse loudly rather than run
80
+ // the command with a guessed or empty path.
81
+ if (!privateDir && spec.block.args.some(a => a.includes('{privateDir}'))) {
82
+ return { state: 'failed', exitCode: null, detail: `{privateDir} is undefined for runtime "${spec.runtime}"`, durationMs: 0, argv }
83
+ }
84
+ const env: NodeJS.ProcessEnv = privateDir ? { ...baseEnv, IAPEER_PRIVATE_DIR: privateDir } : baseEnv
76
85
  try {
77
86
  accessSync(spec.block.command, FS.X_OK)
78
87
  } catch {
@@ -215,7 +215,7 @@ export function discoverPeerRuntimes(cwd: string, currentRuntime: Runtime): Runt
215
215
 
216
216
  /** Agentic runtimes only an ARTIFICIAL peer can drive. A natural (human) or absent
217
217
  * (no mind) peer has nobody to sit in a claude/codex session under its identity. */
218
- const AGENTIC_GATED_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
218
+ const AGENTIC_GATED_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex', 'opencode'])
219
219
 
220
220
  /**
221
221
  * DISCOVERY GATE (хвост-2, boris 15.07): drop agentic runtimes a NON-artificial peer
package/src/init/index.ts CHANGED
@@ -32,41 +32,19 @@ import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
32
32
  import { claudeSettingsPath } from '../launch/nativeMemory.ts'
33
33
  import { bootstrapFailed, launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
34
34
  import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
35
+ import { defaultEnsureDepsDeps, ensureOpencodePluginDependency, type EnsureDepsDeps } from '../opencode/deps.ts'
35
36
  import type { Intelligence } from '../core/constants.ts'
36
37
 
37
38
  /** The MCP-server name the peer's `.mcp.json` uses for the foundation daemon. */
38
39
  export const IAPEER_MCP_SERVER_NAME = 'iapeer'
39
40
 
40
- /** Fallback HTTP-MCP daemon port when no router.json is published yet (the daemon
41
- * binds this stable loopback port by default; init before daemon-start uses it). */
42
- export const DEFAULT_DAEMON_MCP_PORT = 8765
41
+ // Daemon URL resolution lives in storage (shared with the launch adapters without an
42
+ // init↔launch import cycle); re-exported here for existing callers.
43
+ import { DEFAULT_DAEMON_MCP_PORT, resolveDaemonMcpUrl } from '../storage/index.ts'
44
+ export { DEFAULT_DAEMON_MCP_PORT, resolveDaemonMcpUrl }
43
45
 
44
46
  const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
45
47
 
46
- // ─────────────────────────────────────────────────────────────────────────────
47
- // Daemon URL resolution (router.json published by the daemon, else the default)
48
- // ─────────────────────────────────────────────────────────────────────────────
49
-
50
- /**
51
- * Resolve the daemon HTTP-MCP URL to write into a peer's `.mcp.json`. Primary: the
52
- * `tcp` field of the daemon's discovery file `~/.iapeer/state/iapeer/router.json`
53
- * (published live by the daemon). Fallback: the well-known default loopback URL —
54
- * so `iapeer init` works even before the daemon is started (the daemon binds the
55
- * same stable port; IAPEER_PORT overrides it at the daemon, not here).
56
- */
57
- export function resolveDaemonMcpUrl(options: StorageOptions = {}): string {
58
- const routerJson = join(pluginStateDir('iapeer', options), 'router.json')
59
- try {
60
- const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
61
- if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp
62
- } catch {
63
- /* no router.json (daemon not started) → the well-known default below */
64
- }
65
- const env = options.env ?? process.env
66
- const port = env.IAPEER_PORT?.trim() || String(DEFAULT_DAEMON_MCP_PORT)
67
- return `http://127.0.0.1:${port}/mcp`
68
- }
69
-
70
48
  // ─────────────────────────────────────────────────────────────────────────────
71
49
  // claude `.mcp.json` wiring (project-scope; auto-enables on cold-start)
72
50
  // ─────────────────────────────────────────────────────────────────────────────
@@ -394,18 +372,33 @@ export function ensureGlobalDoctrineTemplate(env: NodeJS.ProcessEnv = process.en
394
372
  */
395
373
  export function isRuntimeInstalled(runtime: Runtime, env: NodeJS.ProcessEnv = process.env): boolean {
396
374
  if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return true
397
- const override = runtime === 'claude' ? env.IAPEER_CLAUDE_BIN : runtime === 'codex' ? env.IAPEER_CODEX_BIN : undefined
375
+ const override =
376
+ runtime === 'claude' ? env.IAPEER_CLAUDE_BIN
377
+ : runtime === 'codex' ? env.IAPEER_CODEX_BIN
378
+ : runtime === 'opencode' ? env.IAPEER_OPENCODE_BIN
379
+ : undefined
398
380
  if (override?.trim()) return true
399
381
  const home = env.HOME?.trim() || homedir()
400
382
  if (runtime === 'claude' && existsSync(join(home, '.local', 'bin', 'claude'))) return true
401
383
  if (Bun.which(runtime)) return true
402
384
  if (runtime === 'claude' && existsSync(join(home, '.claude'))) return true
403
385
  if (runtime === 'codex' && existsSync(join(home, '.codex'))) return true
386
+ // OpenCode's official installer puts the binary in ~/.opencode/bin (not on a launchd PATH).
387
+ if (runtime === 'opencode' && existsSync(join(home, '.opencode', 'bin', 'opencode'))) return true
404
388
  return false
405
389
  }
406
390
 
407
391
  const AGENTIC_RUNTIMES: Runtime[] = ['claude', 'codex']
408
392
 
393
+ /** The OpenCode binary the core launches: IAPEER_OPENCODE_BIN, else the official installer's
394
+ * ~/.opencode/bin/opencode, else `opencode` on PATH (mirrors loadLifecycleConfig). */
395
+ export function resolveOpencodeBin(env: NodeJS.ProcessEnv = process.env): string {
396
+ const override = env.IAPEER_OPENCODE_BIN?.trim()
397
+ if (override) return override
398
+ const installed = join(env.HOME?.trim() || homedir(), '.opencode', 'bin', 'opencode')
399
+ return existsSync(installed) ? installed : 'opencode'
400
+ }
401
+
409
402
  /**
410
403
  * Resolve a peer's primary AGENTIC runtime — install-aware:
411
404
  * 1. explicit (`--runtime`) → use it, but VALIDATE it is installed (clear error instead
@@ -469,6 +462,9 @@ export interface InitPeerOptions {
469
462
  bootstrap?: boolean
470
463
  env?: NodeJS.ProcessEnv
471
464
  warn?: (message: string) => void
465
+ /** OpenCode plugin-dependency preinstall seam (tests). Absent → the real binary + npm; skipped
466
+ * entirely under IAPEER_TEST_SANDBOX unless injected. */
467
+ opencodeDeps?: EnsureDepsDeps
472
468
  }
473
469
 
474
470
  export interface InitPeerResult extends ProvisionResult {
@@ -505,6 +501,22 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
505
501
  // decide the primary (resolvePrimaryRuntime), so the primary always matches the
506
502
  // config that gets written (no `.codex`-only-but-primary-claude mismatch).
507
503
  const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime, rt => isRuntimeInstalled(rt, env))
504
+ // OpenCode: satisfy the native plugin-dependency no-op BEFORE the runtime is registered, so a wake
505
+ // never waits on npm (docs/20). Fail-closed: a failure leaves the peer without the opencode runtime.
506
+ if (runtime === 'opencode') {
507
+ const sandboxed = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
508
+ if (opts.opencodeDeps || !sandboxed) {
509
+ const deps = opts.opencodeDeps ?? defaultEnsureDepsDeps(resolveOpencodeBin(env), env)
510
+ const outcomes = ensureOpencodePluginDependency(opts.cwd, env, deps)
511
+ const failed = outcomes.filter(o => o.state === 'failed')
512
+ if (failed.length > 0) {
513
+ throw new IapError(
514
+ `opencode plugin dependency preinstall failed — ${failed.map(f => `${f.dir}: ${f.detail ?? 'failed'}`).join('; ')}. ` +
515
+ `Without it OpenCode runs npm on every cold start. Fix npm access and re-run.`,
516
+ )
517
+ }
518
+ }
519
+ }
508
520
  const provisioned = await provisionPeer({
509
521
  cwd: opts.cwd,
510
522
  runtime,
@@ -0,0 +1,192 @@
1
+ // opencode RuntimeAdapter — the "HOW to launch / observe ONE OpenCode session" half of the
2
+ // launch contract (docs/20-opencode.md). Unlike claude/codex, NOTHING here reads the screen:
3
+ // readiness, delivery and control go through the core-owned in-process plugin
4
+ // (src/opencode/iapeer-plugin.mjs) — its runtime.ready journal line, its control socket and its
5
+ // per-message persistence ack. The native TUI is still hosted by the pty supervisor (the human
6
+ // attach surface), but the core never pastes into it.
7
+ //
8
+ // Per-launch preparation (prepareLaunch):
9
+ // • write the plugin source into <root>/state/opencode/<p>/ (re-written every launch — the
10
+ // installed copy is never trusted to be current);
11
+ // • compose OPENCODE_CONFIG_CONTENT: the operator's opencode.json + inline env config, the memory
12
+ // and capability fragments, then the core-required fields (agent.iapeer.prompt = the freshly
13
+ // composed doctrine, the iapeer MCP, the plugin) — doctrine is re-read on EVERY process launch,
14
+ // including resume;
15
+ // • the plugin ABI env: IAPEER_OPENCODE_DIR / _LAUNCH_ID / _SESSION (resume only).
16
+ //
17
+ // State is personality-scoped (not cwd-scoped: two personalities may share a cwd), so the
18
+ // observation methods take the optional ObserveContext; without it they answer null/not-ok.
19
+
20
+ import { randomBytes } from 'node:crypto'
21
+ import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import { composeOpencodeConfig, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
23
+ import { opencodeControl } from '../../opencode/control.ts'
24
+ import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
25
+ import { resolveDaemonMcpUrl } from '../../storage/index.ts'
26
+ import { resolveFleetBase } from '../../approval/brokerClient.ts'
27
+ import type {
28
+ ApprovalMode,
29
+ ControlCommand,
30
+ ControlPlan,
31
+ LaunchAdapterConfig,
32
+ LaunchSpec,
33
+ ObserveContext,
34
+ PreparedLaunch,
35
+ RuntimeAdapter,
36
+ } from '../types.ts'
37
+
38
+ /** Compaction is a model turn; bound it like a long turn, not like a socket round-trip. */
39
+ export const OPENCODE_COMPACT_TIMEOUT_MS = 600_000
40
+
41
+ /** Journal byte offset at prepare time, per launchID — structuralReady scans only THIS launch's lines. */
42
+ const readyOffsets = new Map<string, number>()
43
+
44
+ function newLaunchID(): string {
45
+ return `lch_${Date.now().toString(36)}${randomBytes(6).toString('hex')}`
46
+ }
47
+
48
+ function paths(personality: string, env?: NodeJS.ProcessEnv) {
49
+ return opencodePaths(personality, { env })
50
+ }
51
+
52
+ function controlSocket(personality: string, env?: NodeJS.ProcessEnv): string {
53
+ return `${paths(personality, env).dir}/control.sock`
54
+ }
55
+
56
+ export const opencodeAdapter: RuntimeAdapter = {
57
+ runtime: 'opencode',
58
+ kind: 'tui',
59
+ usesDoctrine: true,
60
+ // No pty submit surface: the core never pastes into the OpenCode TUI.
61
+ deliveryMarkers: { promptGlyphs: [] },
62
+ deliveryConfirm: 'structural',
63
+
64
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
65
+ const argv = [cfg.opencodeBin ?? 'opencode']
66
+ if (spec.resume && spec.resumeRef) argv.push('--session', spec.resumeRef)
67
+ argv.push('--agent', OPENCODE_AGENT)
68
+ // yolo (default) = the fleet's bypass posture; gated launches without it so native
69
+ // permission requests reach the approval path (docs/17, OC10).
70
+ if ((spec.approvalMode ?? 'yolo') === 'yolo') argv.push('--auto')
71
+ if (spec.extraArgs?.length) argv.push(...spec.extraArgs)
72
+ return argv
73
+ },
74
+
75
+ prepareLaunch(spec, baseEnv, cfg): PreparedLaunch {
76
+ const env = cfg.env ?? process.env
77
+ if (!spec.systemPromptFile || !existsSync(spec.systemPromptFile)) {
78
+ throw new Error(`opencode: composed system prompt missing (${spec.systemPromptFile ?? 'none'})`)
79
+ }
80
+ const doctrine = readFileSync(spec.systemPromptFile, 'utf8')
81
+ const p = paths(spec.personality, env)
82
+ writeOpencodePlugin(spec.personality, OPENCODE_PLUGIN_SOURCE, { env })
83
+ const config = composeOpencodeConfig(
84
+ {
85
+ personality: spec.personality,
86
+ doctrine,
87
+ daemonUrl: resolveDaemonMcpUrl({ env }),
88
+ inline: baseEnv.OPENCODE_CONFIG_CONTENT,
89
+ approvalMode: spec.approvalMode ?? 'yolo',
90
+ },
91
+ { env },
92
+ )
93
+ const launchID = newLaunchID()
94
+ readyOffsets.set(launchID, existsSync(p.events) ? statSync(p.events).size : 0)
95
+ const out: Record<string, string> = {
96
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
97
+ IAPEER_OPENCODE_DIR: p.dir,
98
+ IAPEER_OPENCODE_LAUNCH_ID: launchID,
99
+ IAPEER_IDENTITY: spec.identity,
100
+ // A fleet peer never self-updates (config autoupdate:false says the same; the flag also covers
101
+ // native paths that read only the env). Updates are an operator action.
102
+ OPENCODE_DISABLE_AUTOUPDATE: '1',
103
+ // docs/17: the plugin bridges native permission asks to the daemon broker only when gated.
104
+ IAPEER_APPROVAL_MODE: spec.approvalMode ?? 'yolo',
105
+ IAPEER_FLEET_URL: resolveFleetBase(env),
106
+ }
107
+ if (spec.resume && spec.resumeRef) out.IAPEER_OPENCODE_SESSION = spec.resumeRef
108
+ return { env: out, readyToken: launchID }
109
+ },
110
+
111
+ async structuralReady(spec, launchID, deadlineMs, alive, env) {
112
+ const p = paths(spec.personality, env)
113
+ let offset = readyOffsets.get(launchID) ?? 0
114
+ try {
115
+ while (Date.now() < deadlineMs) {
116
+ const { lines, end } = readJournalFrom(p.events, offset)
117
+ offset = end
118
+ for (const l of lines) {
119
+ if (l.launchID !== launchID) continue
120
+ if (l.type === 'runtime.ready') {
121
+ // The control socket is up before readiness is journaled; one ping proves it serves.
122
+ const ping = await opencodeControl(controlSocket(spec.personality, env), { op: 'ping' }, 5000)
123
+ if (!ping.ok) return { ok: false, reason: `runtime.ready seen but control ping failed: ${ping.error}` }
124
+ if (ping.launchID !== launchID) return { ok: false, reason: `control socket answers launch ${ping.launchID}, expected ${launchID}` }
125
+ return { ok: true }
126
+ }
127
+ if (l.type === 'runtime.error') {
128
+ const e = (l.properties as { error?: unknown } | null)?.error
129
+ return { ok: false, reason: `runtime.error: ${typeof e === 'string' ? e : JSON.stringify(e)}` }
130
+ }
131
+ }
132
+ if (!alive()) return { ok: false, reason: 'process exited before runtime.ready' }
133
+ await new Promise(r => setTimeout(r, 500))
134
+ }
135
+ return { ok: false, reason: 'no runtime.ready before the boot deadline (plugin not loaded?)' }
136
+ } finally {
137
+ readyOffsets.delete(launchID)
138
+ }
139
+ },
140
+
141
+ async deliver(target, text, env) {
142
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'deliver', text })
143
+ return r.ok ? { ok: true, messageID: r.messageID } : { ok: false, error: r.error ?? 'deliver failed' }
144
+ },
145
+
146
+ async control(target, command, env) {
147
+ if (command.name === 'interrupt') {
148
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'interrupt' })
149
+ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'interrupt failed' }
150
+ }
151
+ if (command.name === 'compact') {
152
+ // Blocks until the native summary turn completed and session.compacted is recorded.
153
+ const r = await opencodeControl(controlSocket(target.personality, env), { op: 'compact' }, OPENCODE_COMPACT_TIMEOUT_MS)
154
+ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'compact failed' }
155
+ }
156
+ return { ok: false, error: `opencode does not support control command "${command.name}"` }
157
+ },
158
+
159
+ // ── Screen predicates: deliberately inert (structural runtime). ──────────────
160
+ bootDialogKeys(): string[] | null {
161
+ return null
162
+ },
163
+ isInputReady(_pane: string, _mode?: ApprovalMode): boolean {
164
+ return false
165
+ },
166
+
167
+ newestActivityMtime(_cwd: string, ctx?: ObserveContext): number | null {
168
+ return ctx ? fileMtimeMs(paths(ctx.personality, ctx.env).events) : null
169
+ },
170
+
171
+ /** Busy/idle come from the plugin's state, not from a file mtime: lastActivityMs advances only on
172
+ * journaled native events. A busy/approval status counts as activity NOW (a long tool call). */
173
+ lastTurnMtime(_cwd: string, ctx?: ObserveContext): number | null {
174
+ if (!ctx) return null
175
+ const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
176
+ if (!s) return null
177
+ if (s.status === 'busy' || s.status === 'approval') return Date.now()
178
+ return s.lastActivityMs
179
+ },
180
+
181
+ resolveResume(_cwd: string, ctx?: ObserveContext): { ok: boolean; ref?: string; reason?: string } {
182
+ if (!ctx) return { ok: false, reason: 'opencode resume needs the personality (no observation context)' }
183
+ const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
184
+ if (!s?.sessionID) return { ok: false, reason: `no recorded OpenCode session for "${ctx.personality}"` }
185
+ return { ok: true, ref: s.sessionID }
186
+ },
187
+
188
+ // Control goes through `control` above; there is no pty key plan.
189
+ executeControl(_command: ControlCommand): ControlPlan | null {
190
+ return null
191
+ },
192
+ }
@@ -25,6 +25,7 @@ import { telegramAdapter } from './adapters/telegram.ts'
25
25
  import { notifierAdapter } from './adapters/notifier.ts'
26
26
  import { voicetalkAdapter } from './adapters/voicetalk.ts'
27
27
  import { webAdapter } from './adapters/web.ts'
28
+ import { opencodeAdapter } from './adapters/opencode.ts'
28
29
  import type {
29
30
  LaunchConfig,
30
31
  LaunchFn,
@@ -97,6 +98,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
97
98
  if (runtime === 'notifier') return notifierAdapter
98
99
  if (runtime === 'voicetalk') return voicetalkAdapter
99
100
  if (runtime === 'web') return webAdapter
101
+ if (runtime === 'opencode') return opencodeAdapter
100
102
  throw new Error(`no launch adapter for runtime "${runtime}"`)
101
103
  }
102
104
 
@@ -189,6 +191,19 @@ export const launch: LaunchFn = async (
189
191
  // loads here, default-on — and the router branch never reaches it).
190
192
  if (adapter.kind === 'tui' || adapter.kind === 'router') {
191
193
  const invocation = buildLaunchInvocation(spec, adapter, cfg)
194
+ // Structural runtimes (opencode) prepare launch artifacts + extra env BEFORE spawn. The identity
195
+ // ABI stays authoritative: prepared env never overrides PEER_PERSONALITY/RUNTIME/IDENTITY.
196
+ let readyToken: string | undefined
197
+ if (adapter.prepareLaunch) {
198
+ try {
199
+ const prepared = adapter.prepareLaunch(spec, invocation.env, cfg)
200
+ const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
201
+ invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
202
+ readyToken = prepared.readyToken
203
+ } catch (e) {
204
+ return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
205
+ }
206
+ }
192
207
  mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
193
208
  const paneLogPath = `${cfg.logDir}/${identity}.log`
194
209
  // SESSION-START BOUNDARY (premature-ready fix): the pane-log is a SHARED append-only file across a
@@ -219,6 +234,18 @@ export const launch: LaunchFn = async (
219
234
  if (hostSessionAlive(identity, env)) return ready(identity)
220
235
  return fail(identity, 'hosted router session not alive immediately after bringup')
221
236
  }
237
+ // STRUCTURAL runtime (opencode): readiness = the runtime's own ready record for THIS launch, and
238
+ // the first message goes through the adapter's deliver, whose ack means THIS message is persisted
239
+ // natively (a durable native queue) — so there is no pane gate and no activity ready-gate.
240
+ if (adapter.structuralReady && readyToken) {
241
+ const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
242
+ if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
243
+ if (firstMessage.trim().length === 0) return ready(identity)
244
+ if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
245
+ const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
246
+ if (!d.ok) return fail(identity, `structural deliver failed: ${d.error}`)
247
+ return ready(identity)
248
+ }
222
249
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
223
250
  // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
224
251
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
@@ -234,7 +261,7 @@ export const launch: LaunchFn = async (
234
261
  // alone push mtime past the baseline, so the gate below fires on the boot write — NOT on the
235
262
  // model PROCESSING firstMessage — returning ready/taskDelivered while the message is unseen
236
263
  // (silent loss). Snapped here — post-boot, pre-deliver — only the model's own turn advances it.
237
- const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
264
+ const baselineMtime = adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0
238
265
  // INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
239
266
  // session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
240
267
  // paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
@@ -255,7 +282,7 @@ export const launch: LaunchFn = async (
255
282
  while (Date.now() < readyDeadline) {
256
283
  await sleep(2000)
257
284
  if (!hostSessionAlive(identity, env)) return fail(identity, 'hosted session vanished during ready-gate')
258
- if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
285
+ if ((adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0) > baselineMtime) return ready(identity)
259
286
  }
260
287
  return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
261
288
  }