@agfpd/iapeer 0.4.114 → 0.4.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ // Capability-plugin lifecycle (docs/21-capabilities.md).
2
+ //
3
+ // A capability plugin (statusline-injector, totp-presence, …) puts per-peer
4
+ // surfaces into a peer's runtime: hooks, a private config fragment, an MCP entry.
5
+ // Like the memory slot (ADR-009 inversion), the CORE owns WHEN and FOR WHOM —
6
+ // every agent endpoint of every peer, at birth, at a new runtime, at removal, and
7
+ // on registration — and the PLUGIN owns WHAT: it declares two commands and the
8
+ // core shells into them. The core knows nothing about surface forms.
9
+ //
10
+ // Declaration: <IAPEER_ROOT>/capabilities/<name>.json, written ONLY through
11
+ // `iapeer capability register` (validated), removed through `unregister`.
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
13
+ import { isAbsolute, join } from 'node:path'
14
+ import { isSupportedLocalRuntime, isValidName } from '../core/constants.ts'
15
+ import { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
16
+ import { runProvisionCommand, type ProvisionCommandOutcome, type ProvisionOccasion } from '../enable/provisionCommand.ts'
17
+
18
+ export const CAPABILITY_SCHEMA = 1
19
+
20
+ export interface CapabilityCommand {
21
+ /** Absolute path to the plugin's executable (launchd's minimal PATH resolves nothing). */
22
+ command: string
23
+ /** Argv tail; {cwd} {runtime} {personality} {occasion} are substituted per argument, no shell. */
24
+ args: string[]
25
+ }
26
+
27
+ export interface CapabilityDeclaration {
28
+ schema: 1
29
+ /** The capability name = the declaration file stem. */
30
+ capability: string
31
+ package?: string
32
+ version?: string
33
+ /** Agent runtimes the plugin provisions for. Endpoints of other runtimes are skipped. */
34
+ runtimes: string[]
35
+ provision: CapabilityCommand
36
+ unprovision: CapabilityCommand
37
+ }
38
+
39
+ export function capabilitiesDir(options: StorageOptions = {}): string {
40
+ return join(options.rootDir ?? resolveGlobalRoot(options.env), 'capabilities')
41
+ }
42
+
43
+ function command(value: unknown, where: string): CapabilityCommand {
44
+ const v = value as Partial<CapabilityCommand> | null
45
+ if (!v || typeof v !== 'object') throw new Error(`${where}: expected {command, args}`)
46
+ if (typeof v.command !== 'string' || !isAbsolute(v.command)) throw new Error(`${where}.command must be an absolute path`)
47
+ if (!Array.isArray(v.args) || v.args.some(a => typeof a !== 'string')) throw new Error(`${where}.args must be an array of strings`)
48
+ if (!v.args.some(a => a.includes('{personality}'))) {
49
+ // Several personalities can share one cwd: a command that cannot tell them apart
50
+ // would provision or tear down the wrong peer's surfaces.
51
+ throw new Error(`${where}.args must carry {personality}`)
52
+ }
53
+ return { command: v.command, args: [...v.args] }
54
+ }
55
+
56
+ /** Validate an untrusted declaration. Throws with the first violation. */
57
+ export function parseCapabilityDeclaration(raw: unknown): CapabilityDeclaration {
58
+ const d = raw as Record<string, unknown> | null
59
+ if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('declaration must be a JSON object')
60
+ if (d.schema !== CAPABILITY_SCHEMA) throw new Error(`unsupported schema ${JSON.stringify(d.schema)} (expected ${CAPABILITY_SCHEMA})`)
61
+ if (!isValidName(d.capability)) throw new Error(`invalid capability name ${JSON.stringify(d.capability)}`)
62
+ if (!Array.isArray(d.runtimes) || d.runtimes.length === 0 || d.runtimes.some(r => typeof r !== 'string')) {
63
+ throw new Error('runtimes must be a non-empty array of runtime names')
64
+ }
65
+ for (const k of ['package', 'version'] as const) {
66
+ if (d[k] !== undefined && typeof d[k] !== 'string') throw new Error(`${k} must be a string`)
67
+ }
68
+ return {
69
+ schema: CAPABILITY_SCHEMA,
70
+ capability: d.capability as string,
71
+ ...(d.package ? { package: d.package as string } : {}),
72
+ ...(d.version ? { version: d.version as string } : {}),
73
+ runtimes: [...new Set(d.runtimes as string[])],
74
+ provision: command(d.provision, 'provision'),
75
+ unprovision: command(d.unprovision, 'unprovision'),
76
+ }
77
+ }
78
+
79
+ export interface CapabilityListing {
80
+ declarations: CapabilityDeclaration[]
81
+ /** Files that exist but do not validate — reported, never silently skipped. */
82
+ invalid: { file: string; error: string }[]
83
+ }
84
+
85
+ export function listCapabilities(options: StorageOptions = {}): CapabilityListing {
86
+ const dir = capabilitiesDir(options)
87
+ const out: CapabilityListing = { declarations: [], invalid: [] }
88
+ if (!existsSync(dir)) return out
89
+ for (const f of readdirSync(dir).filter(f => f.endsWith('.json')).sort()) {
90
+ try {
91
+ const decl = parseCapabilityDeclaration(JSON.parse(readFileSync(join(dir, f), 'utf8')))
92
+ if (`${decl.capability}.json` !== f) throw new Error(`file name must be ${decl.capability}.json`)
93
+ out.declarations.push(decl)
94
+ } catch (e) {
95
+ out.invalid.push({ file: join(dir, f), error: e instanceof Error ? e.message : String(e) })
96
+ }
97
+ }
98
+ return out
99
+ }
100
+
101
+ export function writeCapabilityDeclaration(decl: CapabilityDeclaration, options: StorageOptions = {}): string {
102
+ const dir = capabilitiesDir(options)
103
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
104
+ const path = join(dir, `${decl.capability}.json`)
105
+ writeFileAtomic(path, JSON.stringify(decl, null, 2) + '\n', 0o600)
106
+ return path
107
+ }
108
+
109
+ export function removeCapabilityDeclaration(name: string, options: StorageOptions = {}): boolean {
110
+ if (!isValidName(name)) throw new Error(`invalid capability name ${JSON.stringify(name)}`)
111
+ const path = join(capabilitiesDir(options), `${name}.json`)
112
+ if (!existsSync(path)) return false
113
+ rmSync(path)
114
+ return true
115
+ }
116
+
117
+ /** The (runtime) endpoints of a peer a capability applies to: declared by the peer,
118
+ * hostable as an agent by this core, and listed by the capability. */
119
+ export function capabilityEndpoints(decl: CapabilityDeclaration, peerRuntimes: readonly string[]): string[] {
120
+ return peerRuntimes.filter(r => isSupportedLocalRuntime(r) && decl.runtimes.includes(r))
121
+ }
122
+
123
+ export interface CapabilityRun {
124
+ capability: string
125
+ runtime: string
126
+ outcome: ProvisionCommandOutcome
127
+ }
128
+
129
+ /**
130
+ * Run one lifecycle command of every applicable capability for ONE peer. Never
131
+ * throws; callers decide how loud a failure is (birth/remove: warn and continue).
132
+ * `only` narrows to one capability (register/unregister sweeps).
133
+ */
134
+ export function runCapabilityLifecycle(args: {
135
+ kind: 'provision' | 'unprovision'
136
+ personality: string
137
+ cwd: string
138
+ runtimes: readonly string[]
139
+ occasion: ProvisionOccasion
140
+ env?: NodeJS.ProcessEnv
141
+ only?: CapabilityDeclaration
142
+ rootDir?: string
143
+ }): CapabilityRun[] {
144
+ const decls = args.only ? [args.only] : listCapabilities({ env: args.env, rootDir: args.rootDir }).declarations
145
+ const runs: CapabilityRun[] = []
146
+ for (const decl of decls) {
147
+ for (const runtime of capabilityEndpoints(decl, args.runtimes)) {
148
+ const outcome = runProvisionCommand({
149
+ block: args.kind === 'provision' ? decl.provision : decl.unprovision,
150
+ cwd: args.cwd,
151
+ runtime,
152
+ personality: args.personality,
153
+ occasion: args.occasion,
154
+ env: args.env,
155
+ })
156
+ runs.push({ capability: decl.capability, runtime, outcome })
157
+ }
158
+ }
159
+ return runs
160
+ }
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
@@ -35,7 +35,9 @@
35
35
 
36
36
  import type { IncomingMessage, ServerResponse } from 'http'
37
37
  import { closeSync, openSync, readSync, statSync } from 'fs'
38
+ import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
38
39
  import { buildProcessAddress } from '../core/socket.ts'
40
+ import { endpointPrivateDir } from '../storage/index.ts'
39
41
  import { IAPEER_VERSION } from '../core/version.ts'
40
42
  import {
41
43
  ephemeralQueueDepth,
@@ -79,6 +81,14 @@ export const FLEET_API_VERSION = 1
79
81
  export interface FleetRuntimeStatus {
80
82
  runtime: string
81
83
  status: 'live' | 'asleep' | 'stopped'
84
+ /** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
85
+ * agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
86
+ * `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
87
+ * A declared runtime the running core cannot host (newer peer, older core) is `false`. */
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
82
92
  /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
83
93
  attached?: boolean
84
94
  /** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
@@ -125,6 +135,8 @@ export interface FleetHost {
125
135
  memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
126
136
  voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
127
137
  fda: boolean | null
138
+ /** The agent runtimes this core build can host (its agent adapters), in preference order. */
139
+ agent_runtimes: string[]
128
140
  }
129
141
 
130
142
  /** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
@@ -228,6 +240,10 @@ export function buildFleetSnapshot(
228
240
  * never emitted, which is exactly its "no claim" semantics. */
229
241
  activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
230
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
+ }
231
247
  const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
232
248
  const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
233
249
  const attached =
@@ -238,6 +254,8 @@ export function buildFleetSnapshot(
238
254
  return {
239
255
  runtime: s.runtime,
240
256
  status: s.status,
257
+ agent: isSupportedLocalRuntime(s.runtime),
258
+ ...(privateDirOf(s.runtime, r.personality)),
241
259
  ...(attached ? { attached: true } : {}),
242
260
  ...(activity ? { activity } : {}),
243
261
  }
@@ -289,6 +307,7 @@ export function buildFleetSnapshot(
289
307
  ? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
290
308
  : null,
291
309
  fda: probeFullDiskAccess(env),
310
+ agent_runtimes: [...SUPPORTED_LOCAL_RUNTIMES],
292
311
  },
293
312
  peers,
294
313
  // ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
@@ -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,