@agfpd/iapeer 0.4.123 → 0.4.125

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.123",
3
+ "version": "0.4.125",
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`)
@@ -78,6 +78,8 @@ import { join } from 'node:path'
78
78
  import type { LifecycleConfig } from '../lifecycle/index.ts'
79
79
  import type { NoticeBoard } from './notices.ts'
80
80
  import { readSessionSlices } from './sessionfiles.ts'
81
+ import { endpointPrivateDir } from '../storage/index.ts'
82
+ import { readOpencodeState } from '../opencode/journal.ts'
81
83
 
82
84
  /** How much of a session file's tail is parsed. The evidence is always at the end. */
83
85
  const TAIL_BYTES = 256 * 1024
@@ -89,7 +91,7 @@ const CODEX_WALK_DEPTH = 3
89
91
 
90
92
  export interface MuteDetection {
91
93
  personality: string
92
- runtime: 'claude' | 'codex'
94
+ runtime: 'claude' | 'codex' | 'opencode'
93
95
  /** The runtime's OWN taxonomy value — claude's `error`, codex's rate_limit_reached_type. */
94
96
  errorType: string
95
97
  model?: string
@@ -405,6 +407,41 @@ export interface MuteWatchPaths {
405
407
  export interface ScanDeps extends MuteWatchPaths {
406
408
  /** personality ← cwd. Built from the registry by the composition point. */
407
409
  peerByCwd: (cwd: string) => string | undefined
410
+ /** OpenCode endpoints: personality → its journal (core-owned private dir; docs/20). Optional:
411
+ * absent → no opencode sweep (tests of the claude/codex paths). */
412
+ opencodeJournals?: () => Array<{ personality: string; events: string; model?: string }>
413
+ }
414
+
415
+ /**
416
+ * OpenCode (docs/20): the core plugin journals native `session.error` events unchanged. That IS the
417
+ * structural fact — the error's own `name` (APIError, ProviderAuthError, …) and status are the
418
+ * runtime's taxonomy, the message is verbatim. Read only events newer than the floor (by the journal
419
+ * line's own `time`, so an overlapping sweep re-reads the same occurrence without recounting).
420
+ */
421
+ export function parseOpencodeJournal(tail: string, floorMs: number): Omit<RawDetection, 'cwd'> | null {
422
+ let last: Omit<RawDetection, 'cwd'> | null = null
423
+ for (const line of tail.split('\n')) {
424
+ if (!line.includes('"session.error"')) continue
425
+ let e: { type?: string; time?: number; sessionID?: string | null; properties?: { error?: { name?: string; data?: { message?: string; statusCode?: number } } } }
426
+ try {
427
+ e = JSON.parse(line)
428
+ } catch {
429
+ continue
430
+ }
431
+ if (e.type !== 'session.error' || typeof e.time !== 'number' || e.time < floorMs) continue
432
+ const err = e.properties?.error
433
+ if (!err) continue
434
+ const name = err.name ?? 'UnknownError'
435
+ if (name === 'MessageAbortedError') continue // an interrupt the operator asked for, not a failure
436
+ const status = err.data?.statusCode
437
+ last = {
438
+ errorType: status ? `${name}:${status}` : name,
439
+ content: err.data?.message ?? name,
440
+ ...(e.sessionID ? { sessionId: e.sessionID } : {}),
441
+ atMs: e.time,
442
+ }
443
+ }
444
+ return last
408
445
  }
409
446
 
410
447
  /**
@@ -442,6 +479,19 @@ export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[]
442
479
  const t = readSessionSlices(file, TAIL_BYTES)
443
480
  if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
444
481
  }
482
+ // opencode: the core-owned journal per endpoint (personality is known — no cwd mapping needed).
483
+ for (const j of deps.opencodeJournals?.() ?? []) {
484
+ let mt = 0
485
+ try {
486
+ mt = statSync(j.events).mtimeMs
487
+ } catch {
488
+ continue
489
+ }
490
+ if (mt < floor) continue
491
+ const t = readSessionSlices(j.events, TAIL_BYTES)
492
+ const d = t ? parseOpencodeJournal(t.tail, floor) : null
493
+ if (d) out.push({ ...d, ...(j.model ? { model: j.model } : {}), cwd: '', personality: j.personality, runtime: 'opencode' })
494
+ }
445
495
  return out
446
496
  }
447
497
 
@@ -490,7 +540,7 @@ export interface StartMuteWatchOptions {
490
540
  /** Override the runtime session roots (tests). Default: resolveMuteWatchPaths(env). */
491
541
  paths?: MuteWatchPaths
492
542
  /** Override the registry read (tests). Default: a lazy import of the CLI's listPeers. */
493
- listPeers?: () => Array<{ personality: string; cwd: string }>
543
+ listPeers?: () => Array<{ personality: string; cwd: string; runtimes?: string[] }>
494
544
  now?: () => number
495
545
  onError?: (err: unknown) => void
496
546
  }
@@ -516,7 +566,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
516
566
  try {
517
567
  if (!listPeers) {
518
568
  const cli = await import('../cli/index.ts')
519
- listPeers = () => cli.listPeers({ env }).map(p => ({ personality: p.personality, cwd: p.cwd }))
569
+ listPeers = () => cli.listPeers({ env }).map(p => ({ personality: p.personality, cwd: p.cwd, runtimes: p.runtimes.map(r => r.runtime) }))
520
570
  }
521
571
  const rows = listPeers()
522
572
  // cwd → personality. Compared case-insensitively: macOS paths are case-insensitive and
@@ -530,6 +580,15 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
530
580
  now,
531
581
  onError: opts.onError,
532
582
  peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
583
+ opencodeJournals: () =>
584
+ rows
585
+ .filter(r => (r.runtimes ?? []).includes('opencode'))
586
+ .flatMap(r => {
587
+ const dir = endpointPrivateDir('opencode', r.personality, { env })
588
+ if (!dir) return []
589
+ const model = readOpencodeState(join(dir, 'state.json'))?.model
590
+ return [{ personality: r.personality, events: join(dir, 'events.jsonl'), ...(model ? { model } : {}) }]
591
+ }),
533
592
  }
534
593
  since = muteWatchTick(deps, since).sinceMs
535
594
  } catch (e) {
@@ -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
+ }