@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.
- package/docs/08-peer-management.md +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +12 -1
- package/src/daemon/mutewatch.ts +62 -3
- package/src/verify/mcpParity.ts +82 -0
|
@@ -53,7 +53,7 @@ iapeer verify # report divergences
|
|
|
53
53
|
iapeer verify --fix # repair
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
`verify` checks
|
|
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
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`)
|
package/src/daemon/mutewatch.ts
CHANGED
|
@@ -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
|
+
}
|