@agfpd/iapeer 0.4.124 → 0.4.126

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 two things: profiles' conformance to the standard, and divergences between the registry and the local profiles. 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]`). 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.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.124",
3
+ "version": "0.4.126",
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
@@ -2336,6 +2336,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2336
2336
  legacyRuntimeProfiles.push({ personality: peer.personality, cwd: peer.cwd })
2337
2337
  }
2338
2338
  }
2339
+ // MCP parity across the peer's agent runtimes: a server on one endpoint and missing on another
2340
+ // leaves the SAME peer silently weaker there (incident 17.10 — domain MCPs only in codex config).
2341
+ const { mcpParity } = await import('../verify/mcpParity.ts')
2342
+ let mcpDrift = 0
2343
+ for (const peer of index.peers) {
2344
+ for (const issue of mcpParity(peer.personality, peer.cwd, peer.runtimes, { env })) {
2345
+ errors++
2346
+ mcpDrift++
2347
+ lines.push(`✗ ${peer.personality}: MCP server "${issue.server}" is in [${issue.presentIn.join(', ')}] but MISSING in [${issue.missingIn.join(', ')}]`)
2348
+ }
2349
+ }
2339
2350
  const reconcile = reconcileIndex({ env })
2340
2351
  for (const r of reconcile) {
2341
2352
  if (r.drift === null) {
@@ -2346,7 +2357,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2346
2357
  }
2347
2358
  }
2348
2359
  if (flags.json === true) {
2349
- out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, reconcile }, null, 2) + '\n')
2360
+ out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, mcpDrift, reconcile }, null, 2) + '\n')
2350
2361
  } else {
2351
2362
  for (const l of lines) out(l + '\n')
2352
2363
  out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
@@ -108,6 +108,9 @@ export const opencodeAdapter: RuntimeAdapter = {
108
108
  const warnings = hasExplicitModel(config)
109
109
  ? []
110
110
  : [`opencode-implicit-provider: no explicit "model" in ${p.config} — OpenCode's own default provider receives the doctrine and messages`]
111
+ // Handed to the plugin too, so they reach runtime.ready / state.json / fleet runtimes[].warnings like
112
+ // the self-check findings (not only lifecycle.log).
113
+ if (warnings.length) out.IAPEER_OPENCODE_WARNINGS = JSON.stringify(warnings)
111
114
  return { env: out, readyToken: launchID, warnings }
112
115
  },
113
116
 
@@ -194,13 +194,17 @@ export const launch: LaunchFn = async (
194
194
  // Structural runtimes (opencode) prepare launch artifacts + extra env BEFORE spawn. The identity
195
195
  // ABI stays authoritative: prepared env never overrides PEER_PERSONALITY/RUNTIME/IDENTITY.
196
196
  let readyToken: string | undefined
197
+ const loggedWarnings = new Set<string>()
197
198
  if (adapter.prepareLaunch) {
198
199
  try {
199
200
  const prepared = adapter.prepareLaunch(spec, invocation.env, cfg)
200
201
  const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
201
202
  invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
202
203
  readyToken = prepared.readyToken
203
- for (const w of prepared.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
204
+ for (const w of prepared.warnings ?? []) {
205
+ loggedWarnings.add(w)
206
+ appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
207
+ }
204
208
  } catch (e) {
205
209
  return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
206
210
  }
@@ -241,7 +245,9 @@ export const launch: LaunchFn = async (
241
245
  if (adapter.structuralReady && readyToken) {
242
246
  const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
243
247
  if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
244
- for (const w of r.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
248
+ for (const w of r.warnings ?? []) {
249
+ if (!loggedWarnings.has(w)) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
250
+ }
245
251
  if (firstMessage.trim().length === 0) return ready(identity)
246
252
  if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
247
253
  const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
@@ -119,17 +119,20 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
119
119
  }
120
120
  }
121
121
  // A session works its input in order: a FINISHED turn for a later message means every earlier one
122
- // was handled too (bounds the ledger when a turn-end record is missed).
123
- let finishedUpTo = -Infinity
122
+ // OF THE SAME SESSION was handled too (bounds the ledger when a turn-end record is missed). Strictly
123
+ // per session token: a turn finished in a NEW session says nothing about a dead session's queue —
124
+ // that cross-session shortcut silently dropped A and B in 0.4.122 (linus OC07 regression).
125
+ const finishedUpTo = new Map<string, number>()
124
126
  const finished = new Set<string>()
127
+ const sessionKey = (e: InflightEntry) => e.sessionToken ?? '∅'
125
128
  for (const e of entries) {
126
129
  if (safe(deps.completed, e, false) || (!deps.completed && safe(deps.processed, e, false))) {
127
130
  finished.add(e.id)
128
- finishedUpTo = Math.max(finishedUpTo, e.ts)
131
+ finishedUpTo.set(sessionKey(e), Math.max(finishedUpTo.get(sessionKey(e)) ?? -Infinity, e.ts))
129
132
  }
130
133
  }
131
134
  for (const e of entries) {
132
- if (finished.has(e.id) || e.ts < finishedUpTo) continue
135
+ if (finished.has(e.id) || e.ts < (finishedUpTo.get(sessionKey(e)) ?? -Infinity)) continue
133
136
  const done = safe(deps.processed, e, false)
134
137
  if (done) {
135
138
  // Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
@@ -320,6 +320,10 @@ async function server(input) {
320
320
  }
321
321
  async function selfCheck() {
322
322
  const warnings = []
323
+ try {
324
+ const fromCore = JSON.parse(env.IAPEER_OPENCODE_WARNINGS || '[]') // launch-time facts (e.g. implicit provider)
325
+ if (Array.isArray(fromCore)) for (const w of fromCore) if (typeof w === 'string') warnings.push(w)
326
+ } catch {}
323
327
  try {
324
328
  const st = await client.mcp.status()
325
329
  for (const [name, s] of Object.entries((st && st.data) || {})) {
@@ -0,0 +1,82 @@
1
+ // MCP parity across a peer's agent runtimes (verify). One personality is one agent with several
2
+ // endpoints; a tool server the peer has on one runtime but not on another makes the SAME peer silently
3
+ // weaker there (incident 17.10: after a claude-side redeploy impact-finder / mrmechanic /
4
+ // company-checker had their domain MCP servers only in .codex/config.toml — the claude session ran
5
+ // without its tools and nobody noticed). This reads each runtime's PEER-LEVEL MCP declarations and
6
+ // reports every server present on some runtime and missing on another.
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)
12
+ // The core's own `iapeer` server is excluded: it is wired by the core on every runtime by
13
+ // construction (host-wide for codex), so comparing it would only produce false alarms.
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { opencodeHostConfigPath, type StorageOptions } from '../storage/index.ts'
17
+ import { OPENCODE_MEMORY_FRAGMENT, opencodePaths } from '../opencode/config.ts'
18
+ import { listCapabilities } from '../capability/index.ts'
19
+
20
+ const CORE_SERVER = 'iapeer'
21
+
22
+ function jsonKeys(path: string, pick: (o: Record<string, unknown>) => unknown): string[] {
23
+ try {
24
+ const o = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
25
+ const v = pick(o)
26
+ return v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v as object) : []
27
+ } catch {
28
+ return []
29
+ }
30
+ }
31
+
32
+ /** `[mcp_servers.<name>]` / `[mcp_servers."<name>"]` table headers (sub-tables like `.env` ignored). */
33
+ export function codexMcpNames(toml: string): string[] {
34
+ const out = new Set<string>()
35
+ for (const m of toml.matchAll(/^\s*\[\s*mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\s*\]/gm)) out.add((m[1] ?? m[2])!)
36
+ return [...out]
37
+ }
38
+
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') {
47
+ 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
56
+ }
57
+ return [...new Set(names)].filter(n => n !== CORE_SERVER).sort()
58
+ }
59
+
60
+ export interface McpParityIssue {
61
+ server: string
62
+ presentIn: string[]
63
+ missingIn: string[]
64
+ }
65
+
66
+ /** Every server some agent runtime of the peer declares and another lacks. */
67
+ export function mcpParity(personality: string, cwd: string, runtimes: readonly string[], options: StorageOptions = {}): McpParityIssue[] {
68
+ const sets = new Map<string, Set<string>>()
69
+ for (const rt of runtimes) {
70
+ const names = peerMcpServers(rt, personality, cwd, options)
71
+ if (names) sets.set(rt, new Set(names))
72
+ }
73
+ if (sets.size < 2) return []
74
+ const all = new Set([...sets.values()].flatMap(s => [...s]))
75
+ 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)
79
+ if (missingIn.length) issues.push({ server, presentIn, missingIn })
80
+ }
81
+ return issues
82
+ }