@agfpd/iapeer 0.2.56 → 0.2.58
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/package.json +2 -1
- package/src/cli/index.ts +22 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/shadow/index.ts +170 -0
- package/src/shadow/shadow.test.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.58",
|
|
4
4
|
"description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
|
+
"@xterm/headless": "6.0.0",
|
|
36
37
|
"proper-lockfile": "^4.1.2",
|
|
37
38
|
"transliteration": "^2.6.1"
|
|
38
39
|
},
|
package/src/cli/index.ts
CHANGED
|
@@ -936,6 +936,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
936
936
|
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
937
937
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
938
938
|
trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
|
|
939
|
+
shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
|
|
939
940
|
`
|
|
940
941
|
|
|
941
942
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -1750,6 +1751,27 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1750
1751
|
appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: a.status ?? -1 }, { env })
|
|
1751
1752
|
return a.status ?? 0
|
|
1752
1753
|
}
|
|
1754
|
+
case 'shadow': {
|
|
1755
|
+
// Read-only fidelity OBSERVER for the tmux→pty migration burn-in (track «b»). Compares
|
|
1756
|
+
// the pty-model verdicts (occupancy/ready-gate/liveness) vs live tmux verdicts on the
|
|
1757
|
+
// warm fleet, logs to <eventLogDir>/shadow-fidelity.{jsonl,json}. STRICTLY read-only —
|
|
1758
|
+
// nothing in delivery/lifecycle imports the shadow module; the dynamic import keeps
|
|
1759
|
+
// @xterm OUT of the hot path (the daemon never loads it). --once | --minutes <m> |
|
|
1760
|
+
// --interval <ms>; stop a long run with `touch <eventLogDir>/shadow-fidelity.STOP`.
|
|
1761
|
+
const cfg = loadLifecycleConfig(env)
|
|
1762
|
+
const { runShadowFidelity } = await import('../shadow/index.ts')
|
|
1763
|
+
await runShadowFidelity({
|
|
1764
|
+
logDir: cfg.logDir,
|
|
1765
|
+
eventLogDir: cfg.eventLogDir,
|
|
1766
|
+
sockDir: cfg.sockDir,
|
|
1767
|
+
intervalMs: flags.interval ? Number(flags.interval) : 5000,
|
|
1768
|
+
maxMinutes: flags.minutes ? Number(flags.minutes) : 0,
|
|
1769
|
+
once: flags.once === true,
|
|
1770
|
+
log: errOut,
|
|
1771
|
+
})
|
|
1772
|
+
out(`shadow-fidelity → ${cfg.eventLogDir}/shadow-fidelity.json\n`)
|
|
1773
|
+
return 0
|
|
1774
|
+
}
|
|
1753
1775
|
default: {
|
|
1754
1776
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
|
|
1755
1777
|
if (verb && isRuntime(verb)) {
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -1001,6 +1001,22 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1001
1001
|
const firstMessage = composeFirstMessage(cwd, args.task, fresh)
|
|
1002
1002
|
const result = await launch(spec, adapter, firstMessage, launchCfg)
|
|
1003
1003
|
if (result.status === 'FAILED') {
|
|
1004
|
+
// enroll-on-FAILED (0.2.57): launch can return FAILED on a ready-gate false-negative
|
|
1005
|
+
// (model-did-not-process-task) while the tmux session + its process actually SURVIVE —
|
|
1006
|
+
// observed live (ttlverify, 13.06). Since 0.2.55 removed the self-TTL, such a remnant
|
|
1007
|
+
// would be an UNBOUNDED orphan: no .session → invisible to superviseTick's idle-reap,
|
|
1008
|
+
// and no wall-clock backstop. If the session is in fact ALIVE, enroll it so the
|
|
1009
|
+
// activity-aware idle-reap stays the UNIVERSAL bound (a genuinely wedged session is
|
|
1010
|
+
// idle-reaped after idleSecs; a false-negative-but-working one keeps working and is
|
|
1011
|
+
// reaped only when truly idle, and its next message reaches it via the has-session fast
|
|
1012
|
+
// path above). We do NOT flip to READY — the wake DID fail its ready-gate, the caller
|
|
1013
|
+
// must still hear FAILED; we only make the live remnant supervised. Best-effort: a write
|
|
1014
|
+
// hiccup must not mask the original FAILED reason.
|
|
1015
|
+
if (sessionAlive(sock, identity)) {
|
|
1016
|
+
try {
|
|
1017
|
+
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1018
|
+
} catch { /* keep the original FAILED reason — enroll is best-effort */ }
|
|
1019
|
+
}
|
|
1004
1020
|
return { status: 'FAILED', woke: false, runtime, reason: result.reason }
|
|
1005
1021
|
}
|
|
1006
1022
|
// The session is up and the message delivered. Recording supervise-state must not
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Read-only fidelity OBSERVER for the tmux→pty migration (prod burn-in track «b»).
|
|
2
|
+
//
|
|
3
|
+
// For each warm claude/codex peer it builds an @xterm/headless model from the daemon's
|
|
4
|
+
// EXISTING pipe-pane log (`<identity>.log` under cfg.logDir) — the same raw child bytes
|
|
5
|
+
// tmux's emulator gets — and compares the PTY-model verdicts (composer-occupancy / ready-gate
|
|
6
|
+
// / liveness) to the live AUTHORITATIVE tmux verdicts, logging divergences + rolling fidelity
|
|
7
|
+
// to `<eventLogDir>/shadow-fidelity.{jsonl,json}`.
|
|
8
|
+
//
|
|
9
|
+
// STRICTLY READ-ONLY: it tails log FILES and runs read-only tmux (`capture-pane`/`display`/
|
|
10
|
+
// `has-session`). It NEVER delivers, sends keys, spawns, or touches delivery/lifecycle/
|
|
11
|
+
// registry/launch. Nothing in the delivery/lifecycle hot paths imports this module — it runs
|
|
12
|
+
// ONLY via the `iapeer shadow` CLI verb (dynamic-imported, so @xterm is never loaded in the
|
|
13
|
+
// daemon). With it off, the daemon's behavior is byte-identical. This is the prod burn-in:
|
|
14
|
+
// the supervisor's model proven alongside tmux on real traffic BEFORE any delivery cutover.
|
|
15
|
+
import { Terminal } from '@xterm/headless'
|
|
16
|
+
import { openSync, closeSync, fstatSync, readSync, existsSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transport/index.ts'
|
|
19
|
+
import { getAdapter } from '../launch/index.ts'
|
|
20
|
+
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
|
+
import type { Runtime } from '../core/constants.ts'
|
|
22
|
+
|
|
23
|
+
const SEED_BYTES = 4 * 1024 * 1024
|
|
24
|
+
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
25
|
+
const pidAlive = (pid: number): boolean => {
|
|
26
|
+
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose
|
|
30
|
+
* first visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246)
|
|
31
|
+
* visible cell after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it. */
|
|
32
|
+
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
33
|
+
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|
|
34
|
+
let py = -1, px = -1
|
|
35
|
+
for (let y = top; y < bot; y++) {
|
|
36
|
+
const l = b.getLine(y); if (!l) continue
|
|
37
|
+
let fx = -1
|
|
38
|
+
for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
|
|
39
|
+
if (fx < 0) continue
|
|
40
|
+
if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
|
|
41
|
+
}
|
|
42
|
+
if (py < 0) return false
|
|
43
|
+
const l = b.getLine(py)!
|
|
44
|
+
for (let x = px + 1; x < cols; x++) {
|
|
45
|
+
const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
|
|
46
|
+
if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
|
|
47
|
+
}
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The model's viewport as PLAIN text (mimics `capture-pane -p`, the input isInputReady takes). */
|
|
52
|
+
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
53
|
+
const b = term.buffer.active, lines: string[] = []
|
|
54
|
+
for (let y = b.baseY; y < b.baseY + rows; y++) {
|
|
55
|
+
const l = b.getLine(y); if (!l) { lines.push(''); continue }
|
|
56
|
+
let s = ''
|
|
57
|
+
for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
|
|
58
|
+
lines.push(s.replace(/\s+$/, ''))
|
|
59
|
+
}
|
|
60
|
+
return lines.join('\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Obs { addr: string; sock: string; runtime: Runtime; cols: number; rows: number; term: Terminal; fd: number; offset: number }
|
|
64
|
+
|
|
65
|
+
export interface ShadowOptions {
|
|
66
|
+
logDir: string // daemon pane-logs live here (<identity>.log)
|
|
67
|
+
eventLogDir: string // shadow-fidelity.{jsonl,json} written here
|
|
68
|
+
sockDir: string
|
|
69
|
+
intervalMs?: number
|
|
70
|
+
maxMinutes?: number // 0/undefined + once=false → run until <eventLogDir>/shadow-fidelity.STOP
|
|
71
|
+
once?: boolean
|
|
72
|
+
log?: (s: string) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function feed(o: Obs): Promise<void> {
|
|
76
|
+
const sz = fstatSync(o.fd).size
|
|
77
|
+
if (sz <= o.offset) return Promise.resolve()
|
|
78
|
+
const buf = Buffer.alloc(sz - o.offset)
|
|
79
|
+
readSync(o.fd, buf, 0, buf.length, o.offset)
|
|
80
|
+
o.offset = sz
|
|
81
|
+
return new Promise(res => o.term.write(buf, () => res()))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
|
|
85
|
+
let peers: { runtime: Runtime; personality: string }[] = []
|
|
86
|
+
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
87
|
+
const live = new Set<string>()
|
|
88
|
+
for (const p of peers) {
|
|
89
|
+
const addr = buildProcessAddress(p.runtime, p.personality); live.add(addr)
|
|
90
|
+
if (models.has(addr)) continue
|
|
91
|
+
try {
|
|
92
|
+
const sock = buildSocketPath(p.runtime, p.personality, opts.sockDir)
|
|
93
|
+
const log = join(opts.logDir, `${addr}.log`); if (!existsSync(log)) continue
|
|
94
|
+
const geo = tmux(sock, 'display', '-p', '-t', addr, '#{pane_width}x#{pane_height}'); if (!geo.ok) continue
|
|
95
|
+
const [cols, rows] = geo.out.trim().split('x').map(Number); if (!cols || !rows) continue
|
|
96
|
+
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
97
|
+
const fd = openSync(log, 'r'); const offset = Math.max(0, fstatSync(fd).size - SEED_BYTES)
|
|
98
|
+
models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
|
|
99
|
+
} catch { /* a peer racing spawn/teardown — skip this round */ }
|
|
100
|
+
}
|
|
101
|
+
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; models.delete(addr) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
|
|
105
|
+
export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
106
|
+
mkdirSync(opts.eventLogDir, { recursive: true })
|
|
107
|
+
const jsonl = join(opts.eventLogDir, 'shadow-fidelity.jsonl')
|
|
108
|
+
const statsFile = join(opts.eventLogDir, 'shadow-fidelity.json')
|
|
109
|
+
const stopFile = join(opts.eventLogDir, 'shadow-fidelity.STOP')
|
|
110
|
+
const interval = opts.intervalMs ?? 5000
|
|
111
|
+
const log = opts.log ?? (() => { /* */ })
|
|
112
|
+
const models = new Map<string, Obs>()
|
|
113
|
+
const tot = { samples: 0, occDiv: 0, occNt: 0, rgDiv: 0, livDiv: 0 }
|
|
114
|
+
const startedISO = new Date().toISOString(), t0 = Date.now()
|
|
115
|
+
enumerate(models, opts)
|
|
116
|
+
for (const o of models.values()) await feed(o) // seed each model
|
|
117
|
+
log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
|
|
118
|
+
let i = 0
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
|
|
121
|
+
if (existsSync(stopFile)) break
|
|
122
|
+
if (i > 0 && i % 15 === 0) enumerate(models, opts)
|
|
123
|
+
for (const o of models.values()) {
|
|
124
|
+
try {
|
|
125
|
+
await feed(o)
|
|
126
|
+
const adapter = getAdapter(o.runtime)
|
|
127
|
+
const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
|
|
128
|
+
const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
|
|
129
|
+
const occPty = composerOccupancyFromModel(o.term, o.cols, o.rows, o.runtime)
|
|
130
|
+
const capP = tmux(o.sock, 'capture-pane', '-p', '-t', o.addr)
|
|
131
|
+
const rgTmux = capP.ok ? adapter.isInputReady(capP.out) : null
|
|
132
|
+
const rgPty = adapter.isInputReady(modelToPlainText(o.term, o.cols, o.rows))
|
|
133
|
+
const livTmux = tmux(o.sock, 'has-session', '-t', o.addr).ok
|
|
134
|
+
const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
|
|
135
|
+
const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
|
|
136
|
+
const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
|
|
137
|
+
|
|
138
|
+
tot.samples++
|
|
139
|
+
const occDiv = occPty !== occTmux, rgDiv = rgTmux !== null && rgPty !== rgTmux, livDiv = livPty !== livTmux
|
|
140
|
+
const occNt = occPty || occTmux
|
|
141
|
+
if (occNt) tot.occNt++
|
|
142
|
+
if (occDiv) tot.occDiv++
|
|
143
|
+
if (rgDiv) tot.rgDiv++
|
|
144
|
+
if (livDiv) tot.livDiv++
|
|
145
|
+
if (occDiv || rgDiv || livDiv) {
|
|
146
|
+
appendFileSync(jsonl, JSON.stringify({
|
|
147
|
+
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`,
|
|
148
|
+
occupancy: { pty: occPty, tmux: occTmux, diverged: occDiv, nontrivial: occNt },
|
|
149
|
+
readyGate: { pty: rgPty, tmux: rgTmux, diverged: rgDiv },
|
|
150
|
+
liveness: { pty: livPty, tmux: livTmux, diverged: livDiv },
|
|
151
|
+
...(occDiv ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
|
|
152
|
+
}) + '\n')
|
|
153
|
+
log(`DIVERGE ${o.addr} occ=${occDiv} rg=${rgDiv} liv=${livDiv}${occNt && occDiv ? ' *** NON-TRIVIAL occ ***' : ''}\n`)
|
|
154
|
+
}
|
|
155
|
+
} catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
|
|
156
|
+
}
|
|
157
|
+
i++
|
|
158
|
+
writeFileSync(statsFile, JSON.stringify({
|
|
159
|
+
startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
|
|
160
|
+
peers: models.size, samples: tot.samples,
|
|
161
|
+
occupancy: { divergences: tot.occDiv, nonTrivialSamples: tot.occNt },
|
|
162
|
+
readyGate: { divergences: tot.rgDiv },
|
|
163
|
+
liveness: { divergences: tot.livDiv },
|
|
164
|
+
fidelity: tot.samples ? +(1 - (tot.occDiv + tot.rgDiv + tot.livDiv) / (tot.samples * 3)).toFixed(6) : 1,
|
|
165
|
+
}, null, 2))
|
|
166
|
+
if (opts.once) break
|
|
167
|
+
await new Promise(r => setTimeout(r, interval))
|
|
168
|
+
}
|
|
169
|
+
for (const o of models.values()) { try { closeSync(o.fd) } catch { /* */ } }
|
|
170
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Terminal } from '@xterm/headless'
|
|
3
|
+
import { composerOccupancyFromModel, modelToPlainText } from './index.ts'
|
|
4
|
+
|
|
5
|
+
// The observer's pty-side verdicts are pure functions of the @xterm model — hermetic, no tmux.
|
|
6
|
+
// These mirror the verdicts validated 0-divergence vs the prod tmux verdicts in the migration
|
|
7
|
+
// fidelity shadows (occupancy: getCell dim/ghost; ready-gate: isInputReady on plain text).
|
|
8
|
+
const build = (frame: string, cols = 80, rows = 24): Promise<Terminal> =>
|
|
9
|
+
new Promise(res => { const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 200 }); t.write(frame, () => res(t)) })
|
|
10
|
+
|
|
11
|
+
describe('composerOccupancyFromModel (pty occupancy verdict)', () => {
|
|
12
|
+
test('human non-dim text after the prompt glyph → TRUE', async () => {
|
|
13
|
+
const t = await build(`\x1b[24;1H❯ the quick brown fox`)
|
|
14
|
+
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
test('dim/ghost placeholder after the glyph → FALSE (the critical discriminator)', async () => {
|
|
17
|
+
const t = await build(`\x1b[24;1H❯ \x1b[2m\x1b[38;5;246mTry "fix typecheck errors"\x1b[0m`)
|
|
18
|
+
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
test('empty composer (glyph alone) → FALSE', async () => {
|
|
21
|
+
const t = await build(`\x1b[24;1H❯ `)
|
|
22
|
+
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
test('codex glyph › with human text → TRUE', async () => {
|
|
25
|
+
const t = await build(`\x1b[24;1H› reviewer note`)
|
|
26
|
+
expect(composerOccupancyFromModel(t, 80, 24, 'codex')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
test('no prompt glyph at all → FALSE', async () => {
|
|
29
|
+
const t = await build(`\x1b[24;1Hjust some output line`)
|
|
30
|
+
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('modelToPlainText (feeds isInputReady)', () => {
|
|
35
|
+
test('renders the viewport as plain text (no SGR), trailing-trimmed', async () => {
|
|
36
|
+
const t = await build(`\x1b[1;1Hhello\x1b[2;1H\x1b[1mbold\x1b[0m world`)
|
|
37
|
+
const plain = modelToPlainText(t, 80, 24)
|
|
38
|
+
expect(plain.split('\n')[0]).toBe('hello')
|
|
39
|
+
expect(plain).toContain('bold world')
|
|
40
|
+
expect(plain).not.toContain('\x1b')
|
|
41
|
+
})
|
|
42
|
+
})
|