@agfpd/iapeer 0.4.131 → 0.4.133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/08-peer-management.md +1 -1
- package/docs/20-opencode.md +35 -4
- package/package.json +1 -1
- package/src/cli/index.ts +32 -0
- package/src/daemon/main.ts +6 -2
- package/src/launch/adapters/opencode.ts +11 -0
- package/src/lifecycle/index.ts +25 -4
- package/src/lifecycle/inflight.ts +30 -0
- package/src/mcp/peerServers.ts +236 -0
- package/src/opencode/config.ts +6 -0
- package/src/opencode/peerMcp.ts +88 -0
- package/src/transport/index.ts +13 -0
- package/src/verify/mcpParity.ts +49 -31
|
@@ -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]`).
|
|
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
|
|
package/docs/20-opencode.md
CHANGED
|
@@ -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
|
|
@@ -188,16 +212,23 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
|
|
|
188
212
|
session is gone or was replaced become orphans and are redelivered through the
|
|
189
213
|
normal route as their original sender, prefixed `[redelivered by iapeer: …]`
|
|
190
214
|
(wake-on-miss and the crash-loop guard apply). If redelivery fails, the sender
|
|
191
|
-
receives an explicit notice from `iapeer`. Logged as `ev=inflight-orphaned`
|
|
215
|
+
receives an explicit notice from the core identity `iapeer-system`. Logged as `ev=inflight-orphaned`
|
|
192
216
|
(lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
|
|
193
217
|
(delivery.log).
|
|
194
218
|
- A false «not processed» costs a visible duplicate, never a silent loss.
|
|
195
219
|
- Interrupted turns. «Processed» (a model response began) is not «finished»: a
|
|
196
220
|
turn killed mid-tool may already have had side effects, so it is never
|
|
197
221
|
redelivered. The ledger keeps an entry until its turn finishes (claude: an
|
|
198
|
-
assistant record with a terminal `stop_reason
|
|
199
|
-
|
|
200
|
-
`
|
|
222
|
+
assistant record with a terminal `stop_reason`, or claude's own interrupt
|
|
223
|
+
marker `[Request interrupted by user…]`; codex: `task_complete` or
|
|
224
|
+
`turn_aborted`; opencode: a completed assistant message for it with a finish
|
|
225
|
+
other than `tool-calls`); a finished later message covers earlier ones. An
|
|
226
|
+
operator interrupt is a finished turn, not a death (18.10: a turn the owner had
|
|
227
|
+
stopped was reported «INTERRUPTED» when the session later died for another
|
|
228
|
+
reason). A session that ends ITSELF (`self-fresh`) leaves a `<identity>.self-ended`
|
|
229
|
+
marker with its hosting token; the daemon's sweep settles that session's started
|
|
230
|
+
entries silently instead of notifying their senders (the peer declared the work
|
|
231
|
+
done); not-yet-started entries are redelivered to the fresh session as usual. If the session
|
|
201
232
|
dies with the turn unfinished, the sender gets an explicit notice from iapeer
|
|
202
233
|
(«the turn was INTERRUPTED and its result is not guaranteed»), logged as
|
|
203
234
|
`ev=inflight-interrupted`.
|
package/package.json
CHANGED
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 })) {
|
|
@@ -2984,6 +3006,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2984
3006
|
// see a dead session with no .new-eager → a plain reaped-gone (lazy fresh on the
|
|
2985
3007
|
// next message), not the eager relaunch — degrade gracefully, never lose the mark.
|
|
2986
3008
|
setNewEager(cfg, identity)
|
|
3009
|
+
// The peer ends ITS OWN turn: tell the daemon's OC07 sweep this session ended itself, so the
|
|
3010
|
+
// entries it had started are settled — not reported to their senders as INTERRUPTED.
|
|
3011
|
+
try {
|
|
3012
|
+
const { hostSessionToken } = await import('../launch/ptyHost.ts')
|
|
3013
|
+
const { markSelfEnded } = await import('../lifecycle/inflight.ts')
|
|
3014
|
+
const token = hostSessionToken(identity, env)
|
|
3015
|
+
if (token) markSelfEnded(cfg.stateDir, identity, token)
|
|
3016
|
+
} catch {
|
|
3017
|
+
/* best-effort: without it the sender merely gets a false INTERRUPTED notice */
|
|
3018
|
+
}
|
|
2987
3019
|
out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
|
|
2988
3020
|
const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
|
|
2989
3021
|
killSession(sock, identity, env)
|
package/src/daemon/main.ts
CHANGED
|
@@ -81,7 +81,7 @@ import { startGoalWatch } from './goalwatch.ts'
|
|
|
81
81
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
82
82
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
83
83
|
import { randomBytes } from 'crypto'
|
|
84
|
-
import { listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
84
|
+
import { clearSelfEnded, listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, readSelfEnded, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
85
85
|
import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
|
|
86
86
|
|
|
87
87
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -321,13 +321,17 @@ export async function processInflight(
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
for (const identity of listInflightIdentities(cfg.stateDir)) {
|
|
324
|
+
const selfEnded = readSelfEnded(cfg.stateDir, identity)
|
|
325
|
+
const current = hostSessionAlive(identity, env) ? hostSessionToken(identity, env) : null
|
|
324
326
|
const n = sweepInflight(cfg.stateDir, identity, {
|
|
325
|
-
sessionToken:
|
|
327
|
+
sessionToken: () => current,
|
|
328
|
+
selfEndedToken: selfEnded,
|
|
326
329
|
processed,
|
|
327
330
|
completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
|
|
328
331
|
landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
|
|
329
332
|
})
|
|
330
333
|
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
334
|
+
if (selfEnded && current !== selfEnded) clearSelfEnded(cfg.stateDir, identity) // that session is gone — settled
|
|
331
335
|
}
|
|
332
336
|
// A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
|
|
333
337
|
// effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
|
|
@@ -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,
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -1652,17 +1652,38 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1652
1652
|
export async function processEagerRelaunches(
|
|
1653
1653
|
cfg: LifecycleConfig,
|
|
1654
1654
|
outcomes: SuperviseOutcome[],
|
|
1655
|
-
deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult
|
|
1655
|
+
deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult>; sessionAliveFn?: (identity: string) => boolean } = {},
|
|
1656
1656
|
): Promise<WakeResult[]> {
|
|
1657
1657
|
const wake = deps.wakeFn ?? wakeOrSpawn
|
|
1658
|
+
const env = deps.env ?? process.env
|
|
1659
|
+
const alive = deps.sessionAliveFn ?? ((identity: string) => sessionAlive('', identity, env))
|
|
1658
1660
|
const results: WakeResult[] = []
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
+
const eager = outcomes.filter(o => o.action === 'needs-eager-fresh' && !!o.personality)
|
|
1662
|
+
for (const o of eager) {
|
|
1661
1663
|
clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
|
|
1664
|
+
// WHICH endpoint comes back. Two invariants meet here:
|
|
1665
|
+
// • H5 fleet-switch (12.06): a peer whose ONLY session self-freshes after `default-runtime` was
|
|
1666
|
+
// flipped must come back on the NEW default → re-resolve (no runtime pinned).
|
|
1667
|
+
// • Parallel endpoints (18.10): a peer live on several runtimes at once whose NON-default
|
|
1668
|
+
// endpoint self-freshes must get THAT endpoint back — re-resolving woke the default (already
|
|
1669
|
+
// live → no-op) and the claude endpoint of oc-test-nemo lay down.
|
|
1670
|
+
// So: pin the dead endpoint's runtime when it is the default, or when the peer keeps another
|
|
1671
|
+
// session (live now, or relaunched in this same batch); otherwise it was the peer's only
|
|
1672
|
+
// session → re-resolve to the registry default.
|
|
1673
|
+
let runtime: string | undefined
|
|
1674
|
+
try {
|
|
1675
|
+
const rec = findPeer(readPeersIndex({ env }), o.personality!)
|
|
1676
|
+
const others = (rec?.runtimes ?? []).filter(rt => rt !== o.runtime)
|
|
1677
|
+
const otherAlive = others.some(rt => alive(`${rt}-${o.personality}`))
|
|
1678
|
+
const otherInBatch = eager.some(x => x !== o && x.personality === o.personality)
|
|
1679
|
+
if (o.runtime && (o.runtime === rec?.runtime || otherAlive || otherInBatch)) runtime = o.runtime
|
|
1680
|
+
} catch {
|
|
1681
|
+
runtime = undefined // registry unreadable → the H5 default path
|
|
1682
|
+
}
|
|
1662
1683
|
try {
|
|
1663
1684
|
results.push(
|
|
1664
1685
|
await wake(
|
|
1665
|
-
{ personality: o.personality, task: '', resume: false },
|
|
1686
|
+
{ personality: o.personality!, ...(runtime ? { runtime } : {}), task: '', resume: false },
|
|
1666
1687
|
{ cfg, env: deps.env },
|
|
1667
1688
|
),
|
|
1668
1689
|
)
|
|
@@ -87,6 +87,9 @@ export function readInflight(stateDir: string, identity: string): InflightEntry[
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export interface InflightSweepDeps {
|
|
90
|
+
/** Hosting token of a session that ENDED ITSELF (self-fresh) — its started entries are settled, not
|
|
91
|
+
* reported interrupted. Absent → none. */
|
|
92
|
+
selfEndedToken?: string | null
|
|
90
93
|
/** Current hosting token of the identity's session (null = no live session). */
|
|
91
94
|
sessionToken: (identity: string) => string | null
|
|
92
95
|
/** Did the receiving session answer this entry? */
|
|
@@ -138,6 +141,7 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
138
141
|
// Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
|
|
139
142
|
// may have happened — never redelivered, the sender is told).
|
|
140
143
|
if (current !== null && current === e.sessionToken) keep.push(e)
|
|
144
|
+
else if (deps.selfEndedToken && e.sessionToken === deps.selfEndedToken) continue // the peer ended it itself
|
|
141
145
|
else interrupted.push(e)
|
|
142
146
|
continue
|
|
143
147
|
}
|
|
@@ -207,3 +211,29 @@ export function purgeInflight(stateDir: string, identity: string): void {
|
|
|
207
211
|
rmSync(orphansPath(stateDir, identity), { force: true })
|
|
208
212
|
rmSync(interruptedPath(stateDir, identity), { force: true })
|
|
209
213
|
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
const selfEndedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.self-ended`)
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* self-fresh: the peer's OWN turn declares its work done and ends its session. The CLI only drops this
|
|
220
|
+
* marker (the hosting token of the session that chose to end); the DAEMON's sweep — the single writer
|
|
221
|
+
* of the ledger — then closes that session's started entries instead of reporting them INTERRUPTED
|
|
222
|
+
* (false: the peer itself ended; live parity run 18.10). Not-yet-started entries are untouched and are
|
|
223
|
+
* redelivered to the fresh session as usual. No read-modify-write of the ledger outside the daemon.
|
|
224
|
+
*/
|
|
225
|
+
export function markSelfEnded(stateDir: string, identity: string, sessionToken: string): void {
|
|
226
|
+
writeFileSync(selfEndedPath(stateDir, identity), `${sessionToken}\n`, { mode: 0o600 })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function readSelfEnded(stateDir: string, identity: string): string | null {
|
|
230
|
+
try {
|
|
231
|
+
return readFileSync(selfEndedPath(stateDir, identity), 'utf8').trim() || null
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function clearSelfEnded(stateDir: string, identity: string): void {
|
|
238
|
+
rmSync(selfEndedPath(stateDir, identity), { force: true })
|
|
239
|
+
}
|
|
@@ -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
|
+
}
|
package/src/opencode/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -913,6 +913,13 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
913
913
|
* opencode — an assistant message for that user message with `time.completed` and a finish other
|
|
914
914
|
* than `tool-calls`.
|
|
915
915
|
*/
|
|
916
|
+
/** claude's own record of an interrupted turn (user line whose text is the interrupt marker). */
|
|
917
|
+
function isInterruptMarkerLine(o: { message?: unknown }): boolean {
|
|
918
|
+
const c = (o.message as { content?: unknown } | undefined)?.content
|
|
919
|
+
const texts = typeof c === 'string' ? [c] : Array.isArray(c) ? c.filter(b => b?.type === 'text').map(b => String(b.text ?? '')) : []
|
|
920
|
+
return texts.some(t => /^\[Request interrupted by user/.test(t.trim()))
|
|
921
|
+
}
|
|
922
|
+
|
|
916
923
|
export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
917
924
|
const needles = confirmNeedles(probe.rendered)
|
|
918
925
|
if (!needles.length || needles.every(n => !n)) return false
|
|
@@ -968,6 +975,12 @@ export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
968
975
|
}
|
|
969
976
|
if (o.type === 'assistant' && ['end_turn', 'stop_sequence', 'max_tokens'].includes(o.message?.stop_reason ?? '')) return true
|
|
970
977
|
if (o.type === 'event_msg' && o.payload?.type === 'task_complete') return true
|
|
978
|
+
// An operator INTERRUPT ends the turn too (claude: a `[Request interrupted by user…]` user line,
|
|
979
|
+
// codex: `turn_aborted`). Not counting it, a turn the owner had stopped was later reported to its
|
|
980
|
+
// sender as «INTERRUPTED by the session's death» once the session died for an unrelated reason
|
|
981
|
+
// (live parity run 18.10). OpenCode's abort already closes the assistant message, so it was right.
|
|
982
|
+
if (o.type === 'event_msg' && o.payload?.type === 'turn_aborted') return true
|
|
983
|
+
if (o.type === 'user' && isInterruptMarkerLine(o)) return true
|
|
971
984
|
}
|
|
972
985
|
}
|
|
973
986
|
return false
|
package/src/verify/mcpParity.ts
CHANGED
|
@@ -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
|
|
9
|
-
// claude — <cwd>/.mcp.json
|
|
10
|
-
// codex — <cwd>/.codex/config.toml
|
|
11
|
-
//
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
86
|
+
const views = new Map<string, RuntimeMcpView>()
|
|
69
87
|
for (const rt of runtimes) {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
88
|
+
const v = peerMcpView(rt, personality, cwd, options)
|
|
89
|
+
if (v) views.set(rt, v)
|
|
72
90
|
}
|
|
73
|
-
if (
|
|
74
|
-
const
|
|
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 [...
|
|
77
|
-
const presentIn = [...
|
|
78
|
-
const missingIn = [...
|
|
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
|