@agfpd/iapeer 0.4.130 → 0.4.132

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.
@@ -53,7 +53,7 @@ iapeer verify # report divergences
53
53
  iapeer verify --fix # repair
54
54
  ```
55
55
 
56
- `verify` checks three things: profiles' conformance to the standard, divergences between the registry and the local profiles, and **MCP parity across a peer's agent runtimes** — a server declared for one runtime of the peer and missing for another is an error naming the peer, the server and both sides (`✗ p: MCP server "x" is in [codex] but MISSING in [claude]`). Peer-level sources: claude `.mcp.json`, codex `<cwd>/.codex/config.toml`, opencode host-default + per-peer `opencode.json` + fragments; the core `iapeer` server is excluded (wired on every runtime by construction). `--fix` does not copy MCP servers between runtimes — their configs differ in form; the report says what to add where. With `--fix` the command self-heals the registry from the profiles (and migrates profiles from the old runtime-field format to the current one along the way). It's the standard way to put the registry in order if it's gone out of sync.
56
+ `verify` checks three things: profiles' conformance to the standard, divergences between the registry and the local profiles, and **MCP parity across a peer's agent runtimes** — a server declared for one runtime of the peer and missing for another is an error naming the peer, the server and both sides (`✗ p: MCP server "x" is in [codex] but MISSING in [claude]`). It compares what each runtime ACTUALLY sees at launch: claude `.mcp.json` + servers of enabled plugins; codex `<cwd>/.codex/config.toml` + servers of enabled codex plugins (host `~/.codex/config.toml`, overridden by the peer's); opencode host-default + per-peer `opencode.json` + the core's peer-MCP projection + fragments. A server that only a HOST-wide plugin supplies counts as present (it satisfies a declaration on another runtime — voice-connect's server reaches codex through the host plugin) but is never reported on its own: that is a host decision, not peer drift. The core `iapeer` server is excluded. `--fix` re-projects every opencode endpoint's MCP set from the peer's claude/codex declarations (docs/20 §Peer MCP) — claude and codex sources are never edited; a claude↔codex gap is still only reported (their owners align them). With `--fix` the command self-heals the registry from the profiles (and migrates profiles from the old runtime-field format to the current one along the way). It's the standard way to put the registry in order if it's gone out of sync.
57
57
 
58
58
  **The daemon reconciles continuously (0.4.104).** The local `peer-profile.json` is the source of truth and the registry only its projection — but until 0.4.104 a direct edit of the source (the owner's legitimate move) propagated only when some reindexing verb happened to run afterwards; the router reads the INDEX per-request, so a stale projection mis-routed deliveries (measured live 17.07.2026: a peer's local profile said `default_runtime: claude` for days while the index kept `codex`, and deliveries went to a mute codex session). The daemon now runs a profile-sync sweep (`daemon/profilesync.ts`, 60 s, mtime/set-gated — steady state is stats only, zero writes): any change to a registered peer's local profile triggers the same locked `reindexFromLocals` the verbs use, and a boot sweep heals drift accumulated while the daemon was down. Heals are logged to `lifecycle.log` as `profile-reindex` events. `verify` remains the read-only report and the manual lever; the daemon closes the propagation gap.
59
59
 
@@ -126,7 +126,8 @@ mtime, never a reconstruction:
126
126
 
127
127
  - **codex** — the rollout's `event_msg` payloads `task_started` → `working`, `task_complete` →
128
128
  `idle`; last one wins. Codex emits `task_complete` for a turn that ends on an error too, so
129
- the terminal-error path needs no separate marker.
129
+ the terminal-error path needs no separate marker. An INTERRUPTED turn ends on `turn_aborted`
130
+ (no `task_complete` follows) → `idle`.
130
131
  - **claude** — the transcript's assistant `stop_reason`. **Only `tool_use` means the turn
131
132
  continues**; every other terminal reason (`end_turn`, `stop_sequence`, `max_tokens`, a future
132
133
  value, `null`) means it ended. The polarity is deliberately asymmetric: an unrecognised reason
@@ -134,7 +135,11 @@ mtime, never a reconstruction:
134
135
  tool_result to consume), but compaction summaries (`isCompactSummary`), sub-agent sidechains
135
136
  (`isSidechain`) and injected meta lines (`isMeta`) are excluded — each is a measured
136
137
  false-`working` vector, the worst being a `/compact` run while idle leaving a phantom
137
- `working` forever.
138
+ `working` forever. An operator interrupt ends the turn with a user line whose text is
139
+ `[Request interrupted by user]` / `[Request interrupted by user for tool use]` → `idle` (it
140
+ used to read `working` forever on a stopped session, 18.10.2026).
141
+ - **Attribution** — a session is the peer whose registered cwd is the session's cwd or its
142
+ nearest ancestor (a peer that `cd`-ed into a subdirectory stays itself).
138
143
 
139
144
  ### Bounds a client can rely on
140
145
 
@@ -123,6 +123,30 @@ Open core contracts this ABI depends on:
123
123
  provision/unprovision per peer and runtime, like the memory slot. Until it
124
124
  exists, a manual install is not reported as final.
125
125
 
126
+ ## Peer MCP — OpenCode sees the same servers as the peer's other runtimes
127
+
128
+ Invariant (18.10.2026): after `add-runtime opencode` the peer on OpenCode has the same MCP set as on
129
+ claude/codex, and `verify` is clean with no hand edit. The source of truth stays the peer's own
130
+ declarations — claude `.mcp.json` + plugins enabled in the peer's `.claude/settings*.json`, codex
131
+ `<cwd>/.codex/config.toml` `[mcp_servers]` + plugins enabled in the peer's codex config
132
+ (`src/mcp/peerServers.ts`). OpenCode gets a core-owned **projection** in
133
+ `<private dir>/peer-mcp.json` (0600 — env carries the servers' own secrets, as the sources do):
134
+
135
+ - claude stdio `{command,args,env}` / codex `command,args,env` → `{type:"local", command:[…],
136
+ environment, enabled:true, timeout:30000}` (a first `npx -y` start blows OpenCode's 5 s default);
137
+ - claude `http|sse` `{url,headers}` / codex `url, http_headers, env_http_headers,
138
+ bearer_token_env_var` → `{type:"remote", url, headers, enabled:true, oauth:false}`;
139
+ - `${VAR}` / `${VAR:-default}` → `{env:VAR}` (OpenCode has no default syntax — the default is
140
+ dropped); `${CLAUDE_PLUGIN_ROOT}` → the plugin's install root.
141
+
142
+ Regenerated on `add-runtime opencode`, on every OpenCode launch (follows its sources — a server added
143
+ later reaches OpenCode on the next launch) and by `verify --fix` (one-shot alignment). Merged in
144
+ `composeOpencodeConfig` right after the host default and BEFORE the peer's `opencode.json`, so an
145
+ operator entry always wins. Not projected: the core `iapeer`; any name another OpenCode source
146
+ already supplies (host default, peer config, memory/capability fragments — a deep merge of two
147
+ definitions would mix their fields); servers a HOST-wide plugin gives every peer of one runtime
148
+ (host decision, not the peer's). A live OpenCode session keeps its tools until its next launch.
149
+
126
150
  ## Launch self-check (OC04)
127
151
 
128
152
  OpenCode tolerates some broken surfaces silently: a fragment plugin whose file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.130",
3
+ "version": "0.4.132",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -482,6 +482,13 @@ export async function addRuntime(
482
482
  const warns: string[] = []
483
483
  const repair = p.runtimes.includes(runtime as Runtime)
484
484
  await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env: cleanEnv, warn: m => warns.push(m) })
485
+ // OpenCode inherits the peer's MCP set from its claude/codex declarations (+ enabled plugins) —
486
+ // the same set, verify-clean, no hand edit (docs/20 §Peer MCP).
487
+ if (runtime === 'opencode') {
488
+ const { syncOpencodePeerMcp } = await import('../opencode/peerMcp.ts')
489
+ const r = syncOpencodePeerMcp(p.personality, p.cwd, [...p.runtimes, 'opencode'], { env: cleanEnv })
490
+ if (r.projected.length) warns.push(`MCP projected: ${r.projected.join(', ')}`)
491
+ }
485
492
  out.push({
486
493
  personality: p.personality,
487
494
  action: 'added',
@@ -2354,6 +2361,21 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2354
2361
  // MCP parity across the peer's agent runtimes: a server on one endpoint and missing on another
2355
2362
  // leaves the SAME peer silently weaker there (incident 17.10 — domain MCPs only in codex config).
2356
2363
  const { mcpParity } = await import('../verify/mcpParity.ts')
2364
+ // --fix: re-project every OpenCode endpoint's peer MCP from its claude/codex sources FIRST, so
2365
+ // the parity report below is the post-fix truth (the one-shot alignment of peers whose opencode
2366
+ // runtime predates the projection — boris 18.10). Sources are never edited.
2367
+ if (flags.fix === true) {
2368
+ const { syncOpencodePeerMcp } = await import('../opencode/peerMcp.ts')
2369
+ for (const peer of index.peers) {
2370
+ if (!peer.runtimes.includes('opencode')) continue
2371
+ try {
2372
+ const r = syncOpencodePeerMcp(peer.personality, peer.cwd, peer.runtimes, { env })
2373
+ if (r.changed) out(`opencode MCP projected for ${peer.personality}: [${r.projected.join(', ')}] (takes effect on the next opencode launch)\n`)
2374
+ } catch (e) {
2375
+ errOut(`opencode MCP projection failed for ${peer.personality}: ${e instanceof Error ? e.message : String(e)}\n`)
2376
+ }
2377
+ }
2378
+ }
2357
2379
  let mcpDrift = 0
2358
2380
  for (const peer of index.peers) {
2359
2381
  for (const issue of mcpParity(peer.personality, peer.cwd, peer.runtimes, { env })) {
@@ -56,6 +56,7 @@
56
56
  // IDLE codex pane-log freezes (39s→46s). The signal means opposite things per runtime; one
57
57
  // clock that lies differently on each runtime is worse than no clock.
58
58
 
59
+ import { peerByCwdPrefix } from './mutewatch.ts'
59
60
  import { existsSync, readdirSync, statSync } from 'node:fs'
60
61
  import { join } from 'node:path'
61
62
  import type { LifecycleConfig } from '../lifecycle/index.ts'
@@ -101,6 +102,13 @@ function toMs(ts: unknown): number | null {
101
102
  return Number.isFinite(ms) ? ms : null
102
103
  }
103
104
 
105
+ /** Claude's own record of an interrupted turn: a user line whose text is the interrupt marker. */
106
+ export function isClaudeInterruptMarker(d: Record<string, unknown>): boolean {
107
+ const c = (d.message as { content?: unknown } | undefined)?.content
108
+ const texts = typeof c === 'string' ? [c] : Array.isArray(c) ? c.filter(b => b?.type === 'text').map(b => String(b.text ?? '')) : []
109
+ return texts.some(t => /^\[Request interrupted by user/.test(t.trim()))
110
+ }
111
+
104
112
  /**
105
113
  * The claude transcript's turn state, or null if the file states nothing usable.
106
114
  *
@@ -136,8 +144,12 @@ export function parseClaudeTurn(text: string): TurnState | null {
136
144
  state = msg?.stop_reason === 'tool_use' ? 'working' : 'idle'
137
145
  } else {
138
146
  // A real user line: a prompt to answer or a tool_result to consume — either way the
139
- // model is on the hook. (The non-model user lines were filtered above.)
140
- state = 'working'
147
+ // model is on the hook. (The non-model user lines were filtered above.) EXCEPT the
148
+ // interrupt marker: an operator interrupt (Escape / `iapeer interrupt`) ENDS the turn, and
149
+ // claude records that end as a user line `[Request interrupted by user]` / `… for tool
150
+ // use]` (real bytes 18.10.2026) — no assistant stop_reason follows. Read as working, it was
151
+ // a permanent phantom «working» on a stopped session.
152
+ state = isClaudeInterruptMarker(d) ? 'idle' : 'working'
141
153
  }
142
154
  found = { state, atMs, cwd }
143
155
  }
@@ -171,7 +183,7 @@ export function parseCodexTurn(text: string, head = ''): TurnState | null {
171
183
  let found: TurnState | null = null
172
184
  for (const line of text.split('\n')) {
173
185
  const s = line.trim()
174
- if (!s.startsWith('{') || !s.includes('task_')) continue
186
+ if (!s.startsWith('{') || (!s.includes('task_') && !s.includes('turn_aborted'))) continue
175
187
  let d: { timestamp?: unknown; type?: unknown; payload?: { type?: unknown } }
176
188
  try {
177
189
  d = JSON.parse(s)
@@ -180,7 +192,9 @@ export function parseCodexTurn(text: string, head = ''): TurnState | null {
180
192
  }
181
193
  if (d.type !== 'event_msg') continue
182
194
  const p = d.payload?.type
183
- if (p !== 'task_started' && p !== 'task_complete') continue
195
+ // `turn_aborted` (reason "interrupted") is how codex ends a turn the operator interrupted — NO
196
+ // task_complete follows (real rollouts). Without it an interrupted codex turn stayed «working».
197
+ if (p !== 'task_started' && p !== 'task_complete' && p !== 'turn_aborted') continue
184
198
  const atMs = toMs(d.timestamp)
185
199
  if (atMs === null) continue
186
200
  found = { state: p === 'task_started' ? 'working' : 'idle', atMs, cwd }
@@ -407,7 +421,7 @@ export function startTurnWatch(cfg: LifecycleConfig, board: ActivityBoard, opts:
407
421
  env,
408
422
  now,
409
423
  onError: opts.onError,
410
- peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
424
+ peerByCwd: (cwd: string) => peerByCwdPrefix(byCwd, cwd),
411
425
  },
412
426
  since,
413
427
  ).sinceMs
@@ -21,6 +21,8 @@ import { randomBytes } from 'node:crypto'
21
21
  import { existsSync, readFileSync, statSync } from 'node:fs'
22
22
  import { composeOpencodeConfig, hasExplicitModel, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
23
23
  import { opencodeControl } from '../../opencode/control.ts'
24
+ import { syncOpencodePeerMcp } from '../../opencode/peerMcp.ts'
25
+ import { findPeer, readPeersIndex } from '../../registry/index.ts'
24
26
  import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
25
27
  import { resolveDaemonMcpUrl } from '../../storage/index.ts'
26
28
  import { resolveFleetBase } from '../../approval/brokerClient.ts'
@@ -80,6 +82,15 @@ export const opencodeAdapter: RuntimeAdapter = {
80
82
  const doctrine = readFileSync(spec.systemPromptFile, 'utf8')
81
83
  const p = paths(spec.personality, env)
82
84
  writeOpencodePlugin(spec.personality, OPENCODE_PLUGIN_SOURCE, { env })
85
+ // Re-project the peer's claude/codex MCP servers on every launch: the projection follows its
86
+ // sources (a server added to .mcp.json later reaches OpenCode on the next launch, no drift).
87
+ // Best-effort — a malformed source must not block the launch (verify reports the gap).
88
+ try {
89
+ const rec = findPeer(readPeersIndex({ env }), spec.personality)
90
+ syncOpencodePeerMcp(spec.personality, spec.cwd, rec?.runtimes ?? ['opencode'], { env })
91
+ } catch {
92
+ /* verify surfaces any resulting MCP gap */
93
+ }
83
94
  const config = composeOpencodeConfig(
84
95
  {
85
96
  personality: spec.personality,
@@ -0,0 +1,236 @@
1
+ // The MCP servers a peer's runtime ACTUALLY sees at launch — the peer's own declarations PLUS the
2
+ // servers its enabled plugins bring (boris 18.10: verify read only <cwd>/.codex/config.toml, so
3
+ // voice-connect, whose server reaches codex through the enabled host plugin voice-connect@agfpd,
4
+ // was a false ✗ — and a false ✗ is worse than no check: owners start duplicating servers).
5
+ //
6
+ // Normalized to one shape so the two consumers agree by construction:
7
+ // • verify's MCP parity compares the NAMES per runtime;
8
+ // • add-runtime / launch / `verify --fix` project the SPECS into OpenCode (docs/20 §Peer MCP).
9
+ //
10
+ // Sources (read-only; nothing here writes):
11
+ // claude — <cwd>/.mcp.json `mcpServers`; plugins enabled in ~/.claude/settings.json,
12
+ // <cwd>/.claude/settings.json, <cwd>/.claude/settings.local.json (`enabledPlugins`,
13
+ // later wins), installed per ~/.claude/plugins/installed_plugins.json (fallback: newest
14
+ // ~/.claude/plugins/cache/<market>/<name>/<version>); servers from the plugin's
15
+ // .claude-plugin/plugin.json `mcpServers` (object or relative path) else <root>/.mcp.json.
16
+ // `${CLAUDE_PLUGIN_ROOT}` resolves to the plugin root.
17
+ // codex — <cwd>/.codex/config.toml `[mcp_servers.*]`; plugins `[plugins."<name>@<market>"]
18
+ // enabled` in $CODEX_HOME/config.toml, overridden by the peer's own config; installed at
19
+ // $CODEX_HOME/plugins/cache/<market>/<name>/<newest version>/; servers from
20
+ // .codex-plugin/plugin.json `mcpServers` else <root>/.mcp.json.
21
+ // `codex mcp list` would be the authoritative read, but it was measured to hang > 2 min on this
22
+ // host — unusable inside verify. The same files it reads are read here instead.
23
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { isAbsolute, join, resolve } from 'node:path'
26
+
27
+ /** `peer` — declared by the peer itself (its own files, or a plugin enabled in the peer's own
28
+ * settings); `host` — reaches every peer of that runtime through a HOST-wide plugin enable. A host
29
+ * server is real for the runtime (it satisfies a peer declaration) but is not a fact about the
30
+ * peer: its absence on another runtime is a host decision, not peer drift, and it is not projected. */
31
+ export type McpScope = 'peer' | 'host'
32
+
33
+ export type McpSpec = { scope: McpScope } & (
34
+ | { kind: 'local'; command: string; args: string[]; env: Record<string, string>; source: string }
35
+ | { kind: 'remote'; url: string; headers: Record<string, string>; source: string }
36
+ )
37
+
38
+ type Obj = Record<string, unknown>
39
+
40
+ function readJson(path: string): Obj | null {
41
+ try {
42
+ const v = JSON.parse(readFileSync(path, 'utf8'))
43
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : null
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ function readToml(path: string): Obj | null {
50
+ try {
51
+ return Bun.TOML.parse(readFileSync(path, 'utf8')) as Obj
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ function strMap(v: unknown): Record<string, string> {
58
+ const out: Record<string, string> = {}
59
+ if (v && typeof v === 'object' && !Array.isArray(v)) for (const [k, x] of Object.entries(v)) if (typeof x === 'string' || typeof x === 'number') out[k] = String(x)
60
+ return out
61
+ }
62
+
63
+ /** A claude-style `${VAR}` / `${VAR:-default}` reference → OpenCode's `{env:VAR}`. The default is
64
+ * dropped (OpenCode has no default syntax) — the caller records it as a lossy conversion. */
65
+ export function claudeVarsToOpencode(s: string, pluginRoot?: string): string {
66
+ let out = pluginRoot ? s.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) : s
67
+ out = out.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g, (_m, v: string) => `{env:${v}}`)
68
+ return out
69
+ }
70
+
71
+ /** One claude `.mcp.json` server entry → spec. */
72
+ export function specFromClaude(entry: unknown, source: string, pluginRoot?: string, scope: McpScope = 'peer'): McpSpec | null {
73
+ if (!entry || typeof entry !== 'object') return null
74
+ const e = entry as Obj
75
+ const sub = (s: string): string => claudeVarsToOpencode(s, pluginRoot)
76
+ if (typeof e.url === 'string' || e.type === 'http' || e.type === 'sse') {
77
+ if (typeof e.url !== 'string') return null
78
+ const headers = strMap(e.headers)
79
+ for (const k of Object.keys(headers)) headers[k] = sub(headers[k]!)
80
+ return { scope, kind: 'remote', url: sub(e.url), headers, source }
81
+ }
82
+ if (typeof e.command !== 'string' || !e.command) return null
83
+ const env = strMap(e.env)
84
+ for (const k of Object.keys(env)) env[k] = sub(env[k]!)
85
+ return { scope, kind: 'local', command: sub(e.command), args: Array.isArray(e.args) ? e.args.map(a => sub(String(a))) : [], env, source }
86
+ }
87
+
88
+ /** One codex `[mcp_servers.<name>]` table → spec. */
89
+ export function specFromCodex(entry: unknown, source: string, scope: McpScope = 'peer'): McpSpec | null {
90
+ if (!entry || typeof entry !== 'object') return null
91
+ const e = entry as Obj
92
+ if (typeof e.url === 'string') {
93
+ const headers = strMap(e.http_headers)
94
+ for (const [h, v] of Object.entries(strMap(e.env_http_headers))) headers[h] = `{env:${v}}`
95
+ if (typeof e.bearer_token_env_var === 'string' && e.bearer_token_env_var) headers.Authorization = `Bearer {env:${e.bearer_token_env_var}}`
96
+ return { scope, kind: 'remote', url: e.url, headers, source }
97
+ }
98
+ if (typeof e.command !== 'string' || !e.command) return null
99
+ return { scope, kind: 'local', command: e.command, args: Array.isArray(e.args) ? e.args.map(String) : [], env: strMap(e.env), source }
100
+ }
101
+
102
+ function newestDir(dir: string): string | null {
103
+ try {
104
+ const subs = readdirSync(dir)
105
+ .map(n => join(dir, n))
106
+ .filter(p => {
107
+ try {
108
+ return statSync(p).isDirectory()
109
+ } catch {
110
+ return false
111
+ }
112
+ })
113
+ if (!subs.length) return null
114
+ return subs.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]!
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** A plugin's declared servers: manifest `mcpServers` (object, or a path relative to the root),
121
+ * else <root>/.mcp.json. */
122
+ function pluginServers(root: string, manifestRel: string): Obj {
123
+ const manifest = readJson(join(root, manifestRel))
124
+ const m = manifest?.mcpServers
125
+ if (m && typeof m === 'object' && !Array.isArray(m)) return m as Obj
126
+ const file = typeof m === 'string' ? (isAbsolute(m) ? m : resolve(root, m)) : join(root, '.mcp.json')
127
+ const o = readJson(file)
128
+ const servers = o?.mcpServers ?? o
129
+ return servers && typeof servers === 'object' && !Array.isArray(servers) ? (servers as Obj) : {}
130
+ }
131
+
132
+ function splitKey(key: string): { name: string; market: string } | null {
133
+ const i = key.lastIndexOf('@')
134
+ return i > 0 ? { name: key.slice(0, i), market: key.slice(i + 1) } : null
135
+ }
136
+
137
+ export interface PeerServerOptions {
138
+ env?: NodeJS.ProcessEnv
139
+ }
140
+
141
+ function homeOf(env: NodeJS.ProcessEnv): string {
142
+ return env.HOME?.trim() || homedir()
143
+ }
144
+
145
+ export function claudeServers(cwd: string, opts: PeerServerOptions = {}): Map<string, McpSpec> {
146
+ const env = opts.env ?? process.env
147
+ const home = homeOf(env)
148
+ const claudeDir = env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude')
149
+ const out = new Map<string, McpSpec>()
150
+ const direct = readJson(join(cwd, '.mcp.json'))?.mcpServers
151
+ if (direct && typeof direct === 'object') {
152
+ for (const [n, e] of Object.entries(direct as Obj)) {
153
+ const s = specFromClaude(e, '.mcp.json')
154
+ if (s) out.set(n, s)
155
+ }
156
+ }
157
+ const enabled = new Map<string, { on: boolean; scope: McpScope }>()
158
+ const layers: Array<[string, McpScope]> = [
159
+ [join(claudeDir, 'settings.json'), 'host'],
160
+ [join(cwd, '.claude', 'settings.json'), 'peer'],
161
+ [join(cwd, '.claude', 'settings.local.json'), 'peer'],
162
+ ]
163
+ for (const [f, scope] of layers) {
164
+ const ep = readJson(f)?.enabledPlugins
165
+ if (ep && typeof ep === 'object') for (const [k, v] of Object.entries(ep as Obj)) enabled.set(k, { on: v === true, scope })
166
+ }
167
+ const installed = (readJson(join(claudeDir, 'plugins', 'installed_plugins.json'))?.plugins ?? {}) as Obj
168
+ for (const [key, { on, scope }] of enabled) {
169
+ if (!on) continue
170
+ const kv = splitKey(key)
171
+ if (!kv) continue
172
+ const rec = installed[key]
173
+ const first = Array.isArray(rec) ? (rec[0] as Obj | undefined) : (rec as Obj | undefined)
174
+ const root =
175
+ (typeof first?.installPath === 'string' && existsSync(first.installPath) ? first.installPath : null) ??
176
+ newestDir(join(claudeDir, 'plugins', 'cache', kv.market, kv.name))
177
+ if (!root) continue
178
+ for (const [n, e] of Object.entries(pluginServers(root, join('.claude-plugin', 'plugin.json')))) {
179
+ if (out.has(n)) continue // the peer's own declaration wins
180
+ const s = specFromClaude(e, `plugin ${key}`, root, scope)
181
+ if (s) out.set(n, s)
182
+ }
183
+ }
184
+ return out
185
+ }
186
+
187
+ export function codexServers(cwd: string, opts: PeerServerOptions = {}): Map<string, McpSpec> {
188
+ const env = opts.env ?? process.env
189
+ const codexHome = env.CODEX_HOME?.trim() || join(homeOf(env), '.codex')
190
+ const out = new Map<string, McpSpec>()
191
+ const peer = readToml(join(cwd, '.codex', 'config.toml'))
192
+ const direct = peer?.mcp_servers
193
+ if (direct && typeof direct === 'object') {
194
+ for (const [n, e] of Object.entries(direct as Obj)) {
195
+ if ((e as Obj | undefined)?.enabled === false) continue
196
+ const s = specFromCodex(e, '.codex/config.toml')
197
+ if (s) out.set(n, s)
198
+ }
199
+ }
200
+ const enabled = new Map<string, { on: boolean; scope: McpScope }>()
201
+ const layers: Array<[Obj | null, McpScope]> = [[readToml(join(codexHome, 'config.toml')), 'host'], [peer, 'peer']]
202
+ for (const [cfg, scope] of layers) {
203
+ const pl = cfg?.plugins
204
+ if (pl && typeof pl === 'object') for (const [k, v] of Object.entries(pl as Obj)) enabled.set(k, { on: (v as Obj | undefined)?.enabled === true, scope })
205
+ }
206
+ for (const [key, { on, scope }] of enabled) {
207
+ if (!on) continue
208
+ const kv = splitKey(key)
209
+ if (!kv) continue
210
+ const root = newestDir(join(codexHome, 'plugins', 'cache', kv.market, kv.name))
211
+ if (!root) continue
212
+ for (const [n, e] of Object.entries(pluginServers(root, join('.codex-plugin', 'plugin.json')))) {
213
+ if (out.has(n)) continue
214
+ // Plugin manifests use the claude-style JSON shape even on the codex side.
215
+ const s = specFromClaude(e, `plugin ${key}`, root, scope)
216
+ if (s) out.set(n, s)
217
+ }
218
+ }
219
+ return out
220
+ }
221
+
222
+ /** OpenCode's own MCP entry for a spec (docs: `type local|remote`). */
223
+ export function toOpencodeMcp(s: McpSpec): Obj {
224
+ // (scope is ours, not OpenCode's — never emitted)
225
+ // oauth:false — a headless peer must never stall in an interactive OAuth flow (the core's own
226
+ // iapeer entry does the same). timeout 30 s on local servers: a first `npx -y` start fetches the
227
+ // package and blows OpenCode's 5 s tool-listing default.
228
+ if (s.kind === 'remote') return { type: 'remote', url: s.url, enabled: true, oauth: false, ...(Object.keys(s.headers).length ? { headers: s.headers } : {}) }
229
+ return {
230
+ type: 'local',
231
+ command: [s.command, ...s.args],
232
+ enabled: true,
233
+ timeout: 30_000,
234
+ ...(Object.keys(s.env).length ? { environment: s.env } : {}),
235
+ }
236
+ }
@@ -11,6 +11,8 @@ import { listCapabilities } from '../capability/index.ts'
11
11
  export const OPENCODE_AGENT = 'iapeer'
12
12
  /** The memory provider's private fragment stem (docs/20 §Memory-provider ABI). */
13
13
  export const OPENCODE_MEMORY_FRAGMENT = 'iapeer-memory'
14
+ /** The core-owned projection of the peer's claude/codex MCP servers (docs/20 §Peer MCP). */
15
+ export const OPENCODE_PEER_MCP_FRAGMENT = 'peer-mcp'
14
16
  type JsonObject = Record<string, unknown>
15
17
 
16
18
  export function opencodePaths(personality: string, options: StorageOptions = {}) {
@@ -22,6 +24,7 @@ export function opencodePaths(personality: string, options: StorageOptions = {})
22
24
  state: join(dir, 'state.json'),
23
25
  events: join(dir, 'events.jsonl'),
24
26
  config: join(dir, 'opencode.json'), // optional operator-owned provider/model config
27
+ peerMcp: join(dir, `${OPENCODE_PEER_MCP_FRAGMENT}.json`), // core-owned projection, regenerated
25
28
  }
26
29
  }
27
30
 
@@ -121,6 +124,9 @@ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options
121
124
  // Host default first (docs/20 §Host-default provider), then the peer's own config overrides it.
122
125
  const hostPath = opencodeHostConfigPath(options)
123
126
  let result: JsonObject = existsSync(hostPath) ? object(JSON.parse(readFileSync(hostPath, 'utf8')), hostPath) : {}
127
+ // The peer's own MCP servers projected from its claude/codex declarations — BEFORE the peer's
128
+ // opencode config, so an operator's explicit entry always wins over the projection.
129
+ if (existsSync(paths.peerMcp)) result = mergeOpencodeConfig(result, readOpencodeExtension(paths.peerMcp))
124
130
  if (existsSync(paths.config)) result = mergeOpencodeConfig(result, object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config))
125
131
  if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
126
132
  // Fragment owners: the memory provider + every REGISTERED capability (docs/21) — never a
@@ -0,0 +1,88 @@
1
+ // Peer MCP projection into OpenCode (docs/20 §Peer MCP). Invariant (boris 18.10): after add-runtime
2
+ // the peer on its new runtime sees the SAME MCP set as on its other runtimes, and verify is clean
3
+ // with no hand edit. The source of truth stays the peer's claude/codex declarations (+ their enabled
4
+ // plugins, src/mcp/peerServers.ts); OpenCode gets a core-owned PROJECTION of them — never a hand-kept
5
+ // copy. It is regenerated on add-runtime, on every OpenCode launch and by `verify --fix`, so a server
6
+ // added to `.mcp.json` later reaches OpenCode on the next launch instead of drifting.
7
+ //
8
+ // Ownership: `<private dir>/peer-mcp.json` is the core's; operator entries live in the peer's own
9
+ // opencode.json and win over the projection (merge order in composeOpencodeConfig). Names already
10
+ // supplied by another OpenCode source (host default, peer config, memory/capability fragments) are NOT
11
+ // projected — a deep merge of two definitions of one server would mix their fields. The core's own
12
+ // `iapeer` server is never projected.
13
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { opencodeHostConfigPath, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
16
+ import { OPENCODE_MEMORY_FRAGMENT, opencodePaths } from './config.ts'
17
+ import { listCapabilities } from '../capability/index.ts'
18
+ import { claudeServers, codexServers, toOpencodeMcp, type McpSpec } from '../mcp/peerServers.ts'
19
+
20
+ const CORE_SERVER = 'iapeer'
21
+
22
+ function mcpKeys(path: string): string[] {
23
+ try {
24
+ const o = JSON.parse(readFileSync(path, 'utf8')) as { mcp?: unknown }
25
+ return o.mcp && typeof o.mcp === 'object' && !Array.isArray(o.mcp) ? Object.keys(o.mcp) : []
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ /** Server names OpenCode already gets from a source OTHER than the projection. */
32
+ export function opencodeOwnServers(personality: string, options: StorageOptions = {}): Set<string> {
33
+ const paths = opencodePaths(personality, options)
34
+ const stems = [OPENCODE_MEMORY_FRAGMENT, ...listCapabilities(options).declarations.map(d => d.capability)]
35
+ return new Set([
36
+ ...mcpKeys(opencodeHostConfigPath(options)),
37
+ ...mcpKeys(paths.config),
38
+ ...stems.flatMap(s => mcpKeys(join(paths.dir, `${s}.json`))),
39
+ ])
40
+ }
41
+
42
+ export interface PeerMcpSync {
43
+ /** Servers now projected into OpenCode. */
44
+ projected: string[]
45
+ /** Servers deliberately not projected (already supplied by another OpenCode source). */
46
+ ownedElsewhere: string[]
47
+ changed: boolean
48
+ path: string
49
+ }
50
+
51
+ /** The specs the peer declares on its claude/codex runtimes (claude's definition wins a name clash). */
52
+ export function peerSourceServers(cwd: string, runtimes: readonly string[], env: NodeJS.ProcessEnv): Map<string, McpSpec> {
53
+ const out = new Map<string, McpSpec>()
54
+ // PEER-scope only: a server a host-wide plugin gives every peer of one runtime is a host decision,
55
+ // not this peer's — projecting it would silently widen OpenCode beyond what the peer declared.
56
+ if (runtimes.includes('claude')) for (const [n, s] of claudeServers(cwd, { env })) if (s.scope === 'peer') out.set(n, s)
57
+ if (runtimes.includes('codex')) for (const [n, s] of codexServers(cwd, { env })) if (s.scope === 'peer' && !out.has(n)) out.set(n, s)
58
+ out.delete(CORE_SERVER)
59
+ return out
60
+ }
61
+
62
+ /** Regenerate the projection. Idempotent: an unchanged result is not rewritten. */
63
+ export function syncOpencodePeerMcp(personality: string, cwd: string, runtimes: readonly string[], options: StorageOptions = {}): PeerMcpSync {
64
+ const env = options.env ?? process.env
65
+ const paths = opencodePaths(personality, options)
66
+ const own = opencodeOwnServers(personality, options)
67
+ const mcp: Record<string, unknown> = {}
68
+ const ownedElsewhere: string[] = []
69
+ for (const [name, spec] of [...peerSourceServers(cwd, runtimes, env)].sort(([a], [b]) => a.localeCompare(b))) {
70
+ if (own.has(name)) {
71
+ ownedElsewhere.push(name)
72
+ continue
73
+ }
74
+ mcp[name] = toOpencodeMcp(spec)
75
+ }
76
+ const projected = Object.keys(mcp)
77
+ const prior = existsSync(paths.peerMcp) ? readFileSync(paths.peerMcp, 'utf8') : null
78
+ if (projected.length === 0) {
79
+ if (prior !== null) rmSync(paths.peerMcp, { force: true })
80
+ return { projected, ownedElsewhere, changed: prior !== null, path: paths.peerMcp }
81
+ }
82
+ const next = `${JSON.stringify({ mcp }, null, 2)}\n`
83
+ if (prior === next) return { projected, ownedElsewhere, changed: false, path: paths.peerMcp }
84
+ mkdirSync(paths.dir, { recursive: true, mode: 0o700 })
85
+ // 0600: the projected env carries the servers' own secrets, exactly as the source files do.
86
+ writeFileAtomic(paths.peerMcp, next, 0o600)
87
+ return { projected, ownedElsewhere, changed: true, path: paths.peerMcp }
88
+ }
@@ -5,16 +5,20 @@
5
5
  // without its tools and nobody noticed). This reads each runtime's PEER-LEVEL MCP declarations and
6
6
  // reports every server present on some runtime and missing on another.
7
7
  //
8
- // Sources (peer level only — host-wide runtime config is shared by all peers and not a peer fact):
9
- // claude — <cwd>/.mcp.json mcpServers.<name>
10
- // codex — <cwd>/.codex/config.toml [mcp_servers.<name>]
11
- // opencode — host-default + per-peer opencode.json `mcp` and the registered fragments (docs/20)
8
+ // Sources — what the runtime ACTUALLY sees at launch (src/mcp/peerServers.ts):
9
+ // claude — <cwd>/.mcp.json mcpServers + servers of the plugins enabled for the peer
10
+ // codex — <cwd>/.codex/config.toml [mcp_servers] + servers of the enabled codex plugins
11
+ // (host config.toml, overridden by the peer's) — a plugin-delivered server is real
12
+ // (boris 18.10: voice-connect was a false ✗ because only the peer file was read)
13
+ // opencode — host-default + per-peer opencode.json `mcp`, the core's peer-mcp projection and the
14
+ // registered fragments (docs/20)
12
15
  // The core's own `iapeer` server is excluded: it is wired by the core on every runtime by
13
16
  // construction (host-wide for codex), so comparing it would only produce false alarms.
14
17
  import { existsSync, readFileSync } from 'node:fs'
15
18
  import { join } from 'node:path'
16
19
  import { opencodeHostConfigPath, type StorageOptions } from '../storage/index.ts'
17
- import { OPENCODE_MEMORY_FRAGMENT, opencodePaths } from '../opencode/config.ts'
20
+ import { OPENCODE_MEMORY_FRAGMENT, OPENCODE_PEER_MCP_FRAGMENT, opencodePaths } from '../opencode/config.ts'
21
+ import { claudeServers, codexServers } from '../mcp/peerServers.ts'
18
22
  import { listCapabilities } from '../capability/index.ts'
19
23
 
20
24
  const CORE_SERVER = 'iapeer'
@@ -36,25 +40,34 @@ export function codexMcpNames(toml: string): string[] {
36
40
  return [...out]
37
41
  }
38
42
 
39
- export function peerMcpServers(runtime: string, personality: string, cwd: string, options: StorageOptions = {}): string[] | null {
40
- let names: string[]
41
- if (runtime === 'claude') {
42
- names = jsonKeys(join(cwd, '.mcp.json'), o => o.mcpServers)
43
- } else if (runtime === 'codex') {
44
- const p = join(cwd, '.codex', 'config.toml')
45
- names = existsSync(p) ? codexMcpNames(readFileSync(p, 'utf8')) : []
46
- } else if (runtime === 'opencode') {
43
+ export interface RuntimeMcpView {
44
+ /** Servers the PEER declares on this runtime (its own files / peer-level plugin enables). */
45
+ declared: Set<string>
46
+ /** Everything the runtime actually sees: declared ∪ host-wide plugin servers. */
47
+ available: Set<string>
48
+ }
49
+
50
+ export function peerMcpView(runtime: string, personality: string, cwd: string, options: StorageOptions = {}): RuntimeMcpView | null {
51
+ const env = options.env ?? process.env
52
+ const drop = (names: Iterable<string>): Set<string> => new Set([...names].filter(n => n !== CORE_SERVER))
53
+ if (runtime === 'claude' || runtime === 'codex') {
54
+ const m = runtime === 'claude' ? claudeServers(cwd, { env }) : codexServers(cwd, { env })
55
+ return { declared: drop([...m].filter(([, s]) => s.scope === 'peer').map(([n]) => n)), available: drop(m.keys()) }
56
+ }
57
+ if (runtime === 'opencode') {
47
58
  const paths = opencodePaths(personality, options)
48
- const stems = [OPENCODE_MEMORY_FRAGMENT, ...listCapabilities(options).declarations.map(d => d.capability)]
49
- names = [
50
- ...jsonKeys(opencodeHostConfigPath(options), o => o.mcp),
51
- ...jsonKeys(paths.config, o => o.mcp),
52
- ...stems.flatMap(s => jsonKeys(join(paths.dir, `${s}.json`), o => o.mcp)),
53
- ]
54
- } else {
55
- return null // not an agent runtime with an MCP client
59
+ const stems = [OPENCODE_MEMORY_FRAGMENT, OPENCODE_PEER_MCP_FRAGMENT, ...listCapabilities(options).declarations.map(d => d.capability)]
60
+ const peerLevel = [...jsonKeys(paths.config, o => o.mcp), ...stems.flatMap(s => jsonKeys(join(paths.dir, `${s}.json`), o => o.mcp))]
61
+ const host = jsonKeys(opencodeHostConfigPath(options), o => o.mcp)
62
+ return { declared: drop(peerLevel), available: drop([...peerLevel, ...host]) }
56
63
  }
57
- return [...new Set(names)].filter(n => n !== CORE_SERVER).sort()
64
+ return null // not an agent runtime with an MCP client
65
+ }
66
+
67
+ /** Back-compat: the names a runtime sees (available set), sorted. */
68
+ export function peerMcpServers(runtime: string, personality: string, cwd: string, options: StorageOptions = {}): string[] | null {
69
+ const v = peerMcpView(runtime, personality, cwd, options)
70
+ return v ? [...v.available].sort() : null
58
71
  }
59
72
 
60
73
  export interface McpParityIssue {
@@ -63,19 +76,24 @@ export interface McpParityIssue {
63
76
  missingIn: string[]
64
77
  }
65
78
 
66
- /** Every server some agent runtime of the peer declares and another lacks. */
79
+ /**
80
+ * Every server some agent runtime of the peer DECLARES and another runtime does not SEE. A server that
81
+ * only a host-wide plugin supplies is never an issue on its own (host decision, not peer drift), but it
82
+ * does satisfy a declaration elsewhere — voice-connect declares its server in .mcp.json and codex gets
83
+ * the same server from the enabled host plugin: present on both, no ✗ (boris 18.10).
84
+ */
67
85
  export function mcpParity(personality: string, cwd: string, runtimes: readonly string[], options: StorageOptions = {}): McpParityIssue[] {
68
- const sets = new Map<string, Set<string>>()
86
+ const views = new Map<string, RuntimeMcpView>()
69
87
  for (const rt of runtimes) {
70
- const names = peerMcpServers(rt, personality, cwd, options)
71
- if (names) sets.set(rt, new Set(names))
88
+ const v = peerMcpView(rt, personality, cwd, options)
89
+ if (v) views.set(rt, v)
72
90
  }
73
- if (sets.size < 2) return []
74
- const all = new Set([...sets.values()].flatMap(s => [...s]))
91
+ if (views.size < 2) return []
92
+ const declaredAnywhere = new Set([...views.values()].flatMap(v => [...v.declared]))
75
93
  const issues: McpParityIssue[] = []
76
- for (const server of [...all].sort()) {
77
- const presentIn = [...sets].filter(([, s]) => s.has(server)).map(([rt]) => rt)
78
- const missingIn = [...sets].filter(([, s]) => !s.has(server)).map(([rt]) => rt)
94
+ for (const server of [...declaredAnywhere].sort()) {
95
+ const presentIn = [...views].filter(([, v]) => v.available.has(server)).map(([rt]) => rt)
96
+ const missingIn = [...views].filter(([, v]) => !v.available.has(server)).map(([rt]) => rt)
79
97
  if (missingIn.length) issues.push({ server, presentIn, missingIn })
80
98
  }
81
99
  return issues