@agfpd/iapeer 0.2.57 → 0.2.59

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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # iapeer
2
+
3
+ Ядро мультиагентной системы на одном хосте: реестр пиров, единый демон-роутер для обмена сообщениями между ними и команды управления — создание, запуск, усыпление, перезапуск, подключение к внешним каналам. Пиром может быть AI-агент (Claude, Codex), человек (через Telegram) или служебный процесс; все адресуются одинаково через один протокол.
4
+
5
+ ## Документация
6
+
7
+ Документация проекта — в [`docs/`](docs/README.md): продуктовая часть (что это и как пользоваться) и механика изнутри ([`docs/internals/`](docs/internals/README.md)). Этот репозиторий — кодовая реализация.
8
+
9
+ ## Статус
10
+
11
+ Версия — **0.2.58**. Платформа — macOS/launchd.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.57",
3
+ "version": "0.2.59",
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,9 @@ 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.
940
+ shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
941
+ shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
939
942
  `
940
943
 
941
944
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
@@ -1750,6 +1753,54 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1750
1753
  appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: a.status ?? -1 }, { env })
1751
1754
  return a.status ?? 0
1752
1755
  }
1756
+ case 'shadow': {
1757
+ // Read-only fidelity OBSERVER for the tmux→pty migration burn-in (track «b»). Compares
1758
+ // the pty-model verdicts (occupancy/ready-gate/liveness) vs live tmux verdicts on the
1759
+ // warm fleet, logs to <eventLogDir>/shadow-fidelity.{jsonl,json}. STRICTLY read-only —
1760
+ // nothing in delivery/lifecycle imports the shadow module; the dynamic import keeps
1761
+ // @xterm OUT of the hot path (the daemon never loads it). --once | --minutes <m> |
1762
+ // --interval <ms>; stop a long run with `touch <eventLogDir>/shadow-fidelity.STOP`.
1763
+ const cfg = loadLifecycleConfig(env)
1764
+ const { runShadowFidelity } = await import('../shadow/index.ts')
1765
+ await runShadowFidelity({
1766
+ logDir: cfg.logDir,
1767
+ eventLogDir: cfg.eventLogDir,
1768
+ sockDir: cfg.sockDir,
1769
+ intervalMs: flags.interval ? Number(flags.interval) : 5000,
1770
+ maxMinutes: flags.minutes ? Number(flags.minutes) : 0,
1771
+ once: flags.once === true,
1772
+ log: errOut,
1773
+ })
1774
+ out(`shadow-fidelity → ${cfg.eventLogDir}/shadow-fidelity.json\n`)
1775
+ return 0
1776
+ }
1777
+ case 'shadow-install': {
1778
+ // Code-managed install of the always-on shadow burn-in launchd job
1779
+ // (com.iapeer.shadow-fidelity) — reproducible, ownership-sentinel-guarded, and
1780
+ // re-cycled automatically by `iapeer update` / `rollback`. Replaces a hand-written
1781
+ // plist. Idempotent (no-op on an unchanged, loaded job). install.ts carries NO @xterm
1782
+ // — dynamic-imported anyway to keep the CLI's common path light.
1783
+ const { installShadowJob } = await import('../shadow/install.ts')
1784
+ const r = installShadowJob(env)
1785
+ if (r.action === 'failed' || r.action === 'refused-foreign') {
1786
+ errOut(`shadow-install: ${r.action}${r.detail ? ` — ${r.detail}` : ''}\n plist: ${r.path}\n`)
1787
+ return 1
1788
+ }
1789
+ out(`shadow burn-in job ${r.action} (plist ${r.changed ? 'written' : 'unchanged'}): ${r.path}\n`)
1790
+ return 0
1791
+ }
1792
+ case 'shadow-uninstall': {
1793
+ // Stop + remove the burn-in job (bootout + rm) — the CORRECT halt (KeepAlive would
1794
+ // respawn past the STOP sentinel). Refuses a non-foundation plist (ownership guard).
1795
+ const { uninstallShadowJob } = await import('../shadow/install.ts')
1796
+ const r = uninstallShadowJob(env)
1797
+ if (r.action === 'failed' || r.action === 'refused-foreign') {
1798
+ errOut(`shadow-uninstall: ${r.action}${r.detail ? ` — ${r.detail}` : ''}\n plist: ${r.path}\n`)
1799
+ return 1
1800
+ }
1801
+ out(`shadow burn-in job ${r.action}: ${r.path}\n`)
1802
+ return 0
1803
+ }
1753
1804
  default: {
1754
1805
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
1755
1806
  if (verb && isRuntime(verb)) {
@@ -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,153 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { isFoundationOwnedPlist } from '../launch/launchd.ts'
7
+ import {
8
+ SHADOW_PERSONALITY,
9
+ SHADOW_PLIST_LABEL,
10
+ buildShadowPlistSpec,
11
+ cycleShadowJob,
12
+ installShadowJob,
13
+ installShadowPlist,
14
+ shadowPlistPath,
15
+ uninstallShadowJob,
16
+ } from './install.ts'
17
+
18
+ // Hermetic: every plist write lands under IAPEER_LAUNCHAGENTS_DIR / IAPEER_ROOT overrides, and
19
+ // the suite runs with IAPEER_TEST_SANDBOX=1 (process.env) so the launchctl-touching paths
20
+ // (installShadowJob / cycleShadowJob / uninstallShadowJob) fail-CLOSED to skipped-sandbox — they
21
+ // NEVER load/unload a real launchd job. No tmux, no @xterm (install.ts is pure plumbing).
22
+
23
+ const tmpDirs: string[] = []
24
+ function mkEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
25
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-shadow-install-'))
26
+ tmpDirs.push(root)
27
+ return {
28
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
29
+ IAPEER_ROOT: join(root, 'iapeer'),
30
+ HOME: root,
31
+ PATH: '/usr/bin:/bin',
32
+ ...extra,
33
+ } as NodeJS.ProcessEnv
34
+ }
35
+ afterEach(() => {
36
+ while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
37
+ })
38
+ const plutilLint = (path: string): boolean => spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
39
+
40
+ describe('buildShadowPlistSpec', () => {
41
+ test('label is com.iapeer.shadow-fidelity and runs the INSTALLED `iapeer shadow`', () => {
42
+ const spec = buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv)
43
+ expect(spec.label).toBe(SHADOW_PLIST_LABEL)
44
+ expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
45
+ expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'shadow'])
46
+ })
47
+ test('launchd-minimal PATH includes /opt/homebrew/bin (tmux lives there — the observer shells read-only tmux)', () => {
48
+ expect(buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv).environment.PATH).toContain('/opt/homebrew/bin')
49
+ })
50
+ test('stdout/stderr land under ~/.iapeer/logs/iapeer', () => {
51
+ const spec = buildShadowPlistSpec(mkEnv())
52
+ expect(spec.stdoutPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stdout.log'))
53
+ expect(spec.stderrPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stderr.log'))
54
+ })
55
+ })
56
+
57
+ describe('installShadowPlist', () => {
58
+ test('writes a valid, foundation-owned (sentinel) always-on plist under the override dir', () => {
59
+ const env = mkEnv()
60
+ const r = installShadowPlist(env)
61
+ expect(r.path).toBe(shadowPlistPath(env))
62
+ expect(r.changed).toBe(true)
63
+ expect(existsSync(r.path)).toBe(true)
64
+ expect(isFoundationOwnedPlist(r.path)).toBe(true) // carries the ownership sentinel
65
+ expect(plutilLint(r.path)).toBe(true)
66
+ const xml = readFileSync(r.path, 'utf8')
67
+ expect(xml).toContain(`<string>${SHADOW_PLIST_LABEL}</string>`)
68
+ expect(xml).toContain('<string>shadow</string>')
69
+ expect(xml).toContain('<key>RunAtLoad</key>')
70
+ expect(xml).toContain('<key>KeepAlive</key>')
71
+ })
72
+
73
+ test('idempotent by content: an unchanged re-install does NOT rewrite (changed:false, mtime stable); a changed PATH DOES', () => {
74
+ const env = mkEnv()
75
+ const first = installShadowPlist(env)
76
+ expect(first.changed).toBe(true)
77
+ const mtime1 = statSync(first.path).mtimeMs
78
+ const second = installShadowPlist(env)
79
+ expect(second.changed).toBe(false)
80
+ expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched (no BTM spam)
81
+ // a different rendered plist (PATH override) rewrites
82
+ const third = installShadowPlist(mkEnv({ IAPEER_SHADOW_PATH: '/opt/homebrew/bin:/usr/bin:/bin', IAPEER_LAUNCHAGENTS_DIR: env.IAPEER_LAUNCHAGENTS_DIR!, IAPEER_ROOT: env.IAPEER_ROOT!, HOME: env.HOME! }))
83
+ expect(third.changed).toBe(true)
84
+ })
85
+
86
+ test('collision guard: REFUSES to overwrite a foreign (no-sentinel) plist at the label', () => {
87
+ const env = mkEnv()
88
+ const laDir = env.IAPEER_LAUNCHAGENTS_DIR!
89
+ mkdirSync(laDir, { recursive: true })
90
+ const path = shadowPlistPath(env)
91
+ const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.shadow-fidelity</string></dict></plist>\n'
92
+ writeFileSync(path, foreign)
93
+ expect(() => installShadowPlist(env)).toThrow(/foundation-managed|refus/i)
94
+ expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched — guard fires before any write
95
+ })
96
+ })
97
+
98
+ describe('installShadowJob (sandbox fail-closed)', () => {
99
+ test('writes the plist FILE but NEVER touches launchctl under IAPEER_TEST_SANDBOX → skipped-sandbox', () => {
100
+ const env = mkEnv()
101
+ const r = installShadowJob(env)
102
+ expect(r.action).toBe('skipped-sandbox')
103
+ expect(r.path).toBe(shadowPlistPath(env))
104
+ expect(r.changed).toBe(true)
105
+ expect(existsSync(r.path)).toBe(true) // file written to the override dir
106
+ expect(isFoundationOwnedPlist(r.path)).toBe(true)
107
+ })
108
+ })
109
+
110
+ describe('cycleShadowJob (deploy re-cycle hook)', () => {
111
+ test('returns null when no plist exists (nothing to recycle)', () => {
112
+ expect(cycleShadowJob(mkEnv())).toBeNull()
113
+ })
114
+ test('a written foundation plist recycles to skipped-sandbox under the test flag (never a real launchctl cycle)', () => {
115
+ const env = mkEnv()
116
+ installShadowPlist(env)
117
+ expect(cycleShadowJob(env)?.state).toBe('skipped-sandbox')
118
+ })
119
+ test('a foreign plist at the label is NOT recycled (ownership guard) → null', () => {
120
+ const env = mkEnv()
121
+ mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
122
+ writeFileSync(shadowPlistPath(env), '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
123
+ expect(cycleShadowJob(env)).toBeNull()
124
+ })
125
+ })
126
+
127
+ describe('uninstallShadowJob', () => {
128
+ test('absent plist → absent (no-op)', () => {
129
+ expect(uninstallShadowJob(mkEnv()).action).toBe('absent')
130
+ })
131
+ test('foundation plist under sandbox → skipped-sandbox, plist left in place (no real bootout)', () => {
132
+ const env = mkEnv()
133
+ installShadowPlist(env)
134
+ const r = uninstallShadowJob(env)
135
+ expect(r.action).toBe('skipped-sandbox')
136
+ expect(existsSync(shadowPlistPath(env))).toBe(true)
137
+ })
138
+ test('foreign plist → refused-foreign, untouched', () => {
139
+ const env = mkEnv()
140
+ mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
141
+ const path = shadowPlistPath(env)
142
+ writeFileSync(path, '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
143
+ expect(uninstallShadowJob(env).action).toBe('refused-foreign')
144
+ expect(existsSync(path)).toBe(true)
145
+ })
146
+ })
147
+
148
+ describe('exports wire to the running job', () => {
149
+ test('SHADOW_PERSONALITY/LABEL match the live burn-in job naming', () => {
150
+ expect(SHADOW_PERSONALITY).toBe('shadow-fidelity')
151
+ expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
152
+ })
153
+ })
@@ -0,0 +1,256 @@
1
+ // Code-managed install / teardown / update-recycle for the read-only tmux→pty fidelity
2
+ // BURN-IN job (`iapeer shadow`). The OBSERVER itself is src/shadow/index.ts (it loads
3
+ // @xterm/headless); THIS module is PURE launchd plumbing — it imports NO @xterm — so the
4
+ // deploy path (update/rollback) and the CLI can install/recycle the job without pulling the
5
+ // terminal emulator into their import graph (the daemon hot path stays @xterm-free).
6
+ //
7
+ // The job is a foundation-OWNED, always-on launchd LaunchAgent (com.iapeer.shadow-fidelity)
8
+ // running the INSTALLED binary `iapeer shadow`. It is NOT a registry peer — it receives no
9
+ // IAP and has no peer profile — so recycleFoundationOwnedInfraJobs' registry SCAN cannot see
10
+ // it; cycleShadowJob recycles it by LABEL instead. It carries the same ownership sentinel as
11
+ // every foundation plist, so the collision guard (install) and the recycle guard both
12
+ // recognize it as ours. KeepAlive owns its lifecycle; stop it with `iapeer shadow-uninstall`
13
+ // (bootout + rm), NEVER the STOP sentinel file (KeepAlive would just respawn straight past it).
14
+
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+ import { IAPEER_DIR } from '../core/constants.ts'
20
+ import { IapError } from '../core/errors.ts'
21
+ import { iapeerBinPath } from '../install/index.ts'
22
+ import { pluginLogsDir } from '../storage/index.ts'
23
+ import {
24
+ cycleLaunchdJob,
25
+ isFoundationOwnedPlist,
26
+ launchAgentsDir,
27
+ launchctlBootstrap,
28
+ launchdLabel,
29
+ renderLaunchdPlist,
30
+ type LaunchdJobCycleResult,
31
+ type LaunchdPlistSpec,
32
+ } from '../launch/launchd.ts'
33
+
34
+ /** The burn-in job's personality stem → label com.iapeer.shadow-fidelity (the running job). */
35
+ export const SHADOW_PERSONALITY = 'shadow-fidelity'
36
+ /** com.iapeer.shadow-fidelity — keyed through launchdLabel so the scheme has ONE source. */
37
+ export const SHADOW_PLIST_LABEL = launchdLabel(SHADOW_PERSONALITY)
38
+
39
+ export function shadowPlistPath(env: NodeJS.ProcessEnv = process.env): string {
40
+ return join(launchAgentsDir(env), `${SHADOW_PLIST_LABEL}.plist`)
41
+ }
42
+
43
+ /** The STOP sentinel the observer polls (under <eventLogDir>=~/.iapeer/logs/iapeer). Under
44
+ * KeepAlive a STALE one would respawn the observer straight into an immediate exit → a
45
+ * throttle-storm, so installShadowJob clears it before (re)loading. */
46
+ function shadowStopFile(env: NodeJS.ProcessEnv = process.env): string {
47
+ return join(pluginLogsDir('iapeer', { env }), 'shadow-fidelity.STOP')
48
+ }
49
+
50
+ function inSandbox(env: NodeJS.ProcessEnv): boolean {
51
+ return env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
52
+ }
53
+
54
+ /** Resolve the gui-domain uid, or null when `id -u` is non-numeric (never assume 0). */
55
+ function uidOrNull(): string | null {
56
+ const u = (spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout ?? '').trim()
57
+ return /^\d+$/.test(u) ? u : null
58
+ }
59
+
60
+ /** Is com.iapeer.shadow-fidelity currently loaded in the gui domain? */
61
+ function isShadowLoaded(): boolean {
62
+ const uid = uidOrNull()
63
+ if (!uid) return false
64
+ return spawnSync('launchctl', ['print', `gui/${uid}/${SHADOW_PLIST_LABEL}`], { stdio: 'ignore' }).status === 0
65
+ }
66
+
67
+ /**
68
+ * Build the burn-in job's launchd plist spec (PURE — render/lint-testable). Runs the
69
+ * INSTALLED `iapeer shadow` (prod decoupled from the src tree). The launchd-minimal PATH
70
+ * MUST include /opt/homebrew/bin: the observer shells READ-ONLY tmux (capture-pane /
71
+ * has-session / display), and tmux lives there — a bare PATH would make every tmux probe
72
+ * fail (no peers enumerated). Deterministic (curated PATH, not env.PATH) so the rendered
73
+ * plist is byte-stable across deploys → installShadowPlist's write-if-changed stays a no-op
74
+ * when nothing actually changed (no Background Task Management notification spam).
75
+ */
76
+ export function buildShadowPlistSpec(env: NodeJS.ProcessEnv = process.env): LaunchdPlistSpec {
77
+ const home = env.HOME?.trim() || homedir()
78
+ const defaultPath = `${home}/.local/bin:${home}/.bun/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`
79
+ const logDir = pluginLogsDir('iapeer', { env })
80
+ const environment: Record<string, string> = { PATH: env.IAPEER_SHADOW_PATH?.trim() || defaultPath }
81
+ // Sandbox/test parity (mirror of installDaemonPlist / installAlwaysOnPlist): bake the
82
+ // non-default root overrides so a SANDBOXED install resolves the SAME isolated tree the
83
+ // observer reads. In production these are unset → nothing baked, real ~/.iapeer + /tmp used.
84
+ for (const key of ['IAPEER_ROOT', 'IAPEER_SOCK_DIR', 'IAPEER_LAUNCHAGENTS_DIR'] as const) {
85
+ if (env[key]?.trim()) environment[key] = env[key]!.trim()
86
+ }
87
+ return {
88
+ label: SHADOW_PLIST_LABEL,
89
+ programArguments: [iapeerBinPath(env), 'shadow'],
90
+ workingDirectory: home, // cwd-agnostic (the observer resolves paths from IAPEER_ROOT/cfg)
91
+ environment,
92
+ stdoutPath: join(logDir, 'shadow-fidelity.launchd-stdout.log'),
93
+ stderrPath: join(logDir, 'shadow-fidelity.launchd-stderr.log'),
94
+ }
95
+ }
96
+
97
+ export interface InstallShadowPlistResult {
98
+ path: string
99
+ /** false when the on-disk plist already matched byte-for-byte → NO write (no BTM spam). */
100
+ changed: boolean
101
+ }
102
+
103
+ /**
104
+ * Write the burn-in plist (file only — does NOT load it). IDEMPOTENT BY CONTENT
105
+ * (write-if-changed, mirror of installDaemonPlist: a byte-identical rewrite trips the owner's
106
+ * Background Task Management notification for zero benefit). Collision guard: refuses to
107
+ * overwrite a com.iapeer.shadow-fidelity.plist that lacks the ownership sentinel (a foreign
108
+ * manager owns it) — the guard fires FIRST, before any mkdir/write, so a refusal leaves the
109
+ * filesystem untouched.
110
+ */
111
+ export function installShadowPlist(env: NodeJS.ProcessEnv = process.env): InstallShadowPlistResult {
112
+ const path = shadowPlistPath(env)
113
+ if (existsSync(path) && !isFoundationOwnedPlist(path)) {
114
+ throw new IapError(
115
+ `refusing to overwrite ${path}: ${SHADOW_PLIST_LABEL} exists but is not foundation-managed ` +
116
+ `(no ownership sentinel) — another manager owns it`,
117
+ )
118
+ }
119
+ const rendered = renderLaunchdPlist(buildShadowPlistSpec(env))
120
+ mkdirSync(launchAgentsDir(env), { recursive: true })
121
+ // Never mkdir the REAL ~/.iapeer log dir under a sandbox that forgot IAPEER_ROOT (mirror
122
+ // installAlwaysOnPlist's guard); a real run (no sandbox flag) always makes it.
123
+ const logDir = pluginLogsDir('iapeer', { env })
124
+ const realRoot = join(homedir(), IAPEER_DIR)
125
+ if (!(inSandbox(env) && logDir.startsWith(`${realRoot}/`))) {
126
+ mkdirSync(logDir, { recursive: true, mode: 0o700 })
127
+ }
128
+ let existing: string | null = null
129
+ try {
130
+ existing = readFileSync(path, 'utf8')
131
+ } catch {
132
+ existing = null
133
+ }
134
+ if (existing === rendered) return { path, changed: false }
135
+ writeFileSync(path, rendered, { mode: 0o644 })
136
+ return { path, changed: true }
137
+ }
138
+
139
+ export type ShadowJobAction =
140
+ | 'loaded' // bootstrapped now (was not loaded)
141
+ | 'reloaded' // plist changed on an already-loaded job → bootout+bootstrap applied the new one
142
+ | 'already-loaded' // loaded and the plist did not change → left running untouched
143
+ | 'refused-foreign' // a non-foundation plist sits at the label → never loaded
144
+ | 'failed' // launchctl bootstrap/cycle failed (detail carries the rescue recipe)
145
+ | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch real launchd
146
+
147
+ export interface InstallShadowJobResult extends InstallShadowPlistResult {
148
+ action: ShadowJobAction
149
+ detail?: string
150
+ }
151
+
152
+ /**
153
+ * Install the burn-in plist AND ensure the job is live: write-if-changed, clear a stale STOP
154
+ * sentinel, then load it (or RELOAD via bootout+bootstrap when the plist content changed on
155
+ * an already-loaded job — bootstrap alone is idempotent and would NOT pick up a changed
156
+ * plist). Idempotent: a repeat call on an unchanged, loaded job is a no-op ('already-loaded').
157
+ * Sandbox-safe: under IAPEER_TEST_SANDBOX it writes the (overridden-dir) plist FILE but never
158
+ * calls launchctl ('skipped-sandbox'). This is the code-managed replacement for a hand-written
159
+ * burn-in plist — reproducible, ownership-sentinel-guarded, fleet-deployable.
160
+ */
161
+ export function installShadowJob(env: NodeJS.ProcessEnv = process.env): InstallShadowJobResult {
162
+ const { path, changed } = installShadowPlist(env)
163
+ if (inSandbox(env)) return { path, changed, action: 'skipped-sandbox' }
164
+ // Clear a STALE STOP sentinel BEFORE (re)loading — else KeepAlive respawns the observer
165
+ // into an instant exit (it polls the STOP file) → 10s-throttled respawn storm.
166
+ try {
167
+ unlinkSync(shadowStopFile(env))
168
+ } catch {
169
+ /* none present */
170
+ }
171
+ if (isShadowLoaded()) {
172
+ if (!changed) return { path, changed, action: 'already-loaded' }
173
+ const c = cycleLaunchdJob(SHADOW_PLIST_LABEL, path, env)
174
+ return c.state === 'restarted'
175
+ ? { path, changed, action: 'reloaded' }
176
+ : { path, changed, action: 'failed', detail: c.detail }
177
+ }
178
+ const b = launchctlBootstrap(SHADOW_PERSONALITY, path, env)
179
+ const action: ShadowJobAction =
180
+ b.state === 'loaded'
181
+ ? 'loaded'
182
+ : b.state === 'already-loaded'
183
+ ? 'already-loaded'
184
+ : b.state === 'refused-foreign'
185
+ ? 'refused-foreign'
186
+ : b.state === 'skipped-sandbox'
187
+ ? 'skipped-sandbox'
188
+ : 'failed'
189
+ return { path, changed, action, detail: b.detail }
190
+ }
191
+
192
+ /**
193
+ * Recycle the burn-in job by LABEL after a binary swap (deploy path). The plist runs the
194
+ * just-replaced `iapeer shadow` off the same binary, so the SAME LWCR managed-launch-constraint
195
+ * bomb that bit the daemon (EX_CONFIG(78) on a KeepAlive respawn into the stale constraint)
196
+ * applies here too — only a full bootout→bootstrap clears it. It is NOT a registry peer, so
197
+ * recycleFoundationOwnedInfraJobs' registry scan misses it; this cycles it directly. Only
198
+ * touches a LOADED, foundation-owned job: absent / foreign / not-loaded → null (no-op).
199
+ * Sandbox-safe (cycleLaunchdJob returns skipped-sandbox under the test flag).
200
+ */
201
+ export function cycleShadowJob(env: NodeJS.ProcessEnv = process.env): LaunchdJobCycleResult | null {
202
+ const path = shadowPlistPath(env)
203
+ if (!existsSync(path) || !isFoundationOwnedPlist(path)) return null
204
+ return cycleLaunchdJob(SHADOW_PLIST_LABEL, path, env)
205
+ }
206
+
207
+ export type ShadowUninstallAction =
208
+ | 'booted-out' // job was loaded → booted out, plist removed
209
+ | 'removed-not-loaded' // plist removed, job was not loaded
210
+ | 'absent' // nothing to do (no plist)
211
+ | 'refused-foreign' // the plist is not foundation-owned → left untouched
212
+ | 'skipped-sandbox'
213
+ | 'failed'
214
+
215
+ export interface UninstallShadowJobResult {
216
+ path: string
217
+ action: ShadowUninstallAction
218
+ detail?: string
219
+ }
220
+
221
+ /**
222
+ * Stop and remove the burn-in job — the CORRECT way to halt it (the STOP sentinel would be
223
+ * respawned past by KeepAlive): bootout the loaded job, then delete the plist. Refuses a
224
+ * non-foundation plist (ownership guard). Sandbox-safe. Reversibility for the install verb.
225
+ */
226
+ export function uninstallShadowJob(env: NodeJS.ProcessEnv = process.env): UninstallShadowJobResult {
227
+ const path = shadowPlistPath(env)
228
+ if (!existsSync(path)) return { path, action: 'absent' }
229
+ if (!isFoundationOwnedPlist(path)) {
230
+ return { path, action: 'refused-foreign', detail: `${path} is not foundation-owned — not booting it out` }
231
+ }
232
+ if (inSandbox(env)) return { path, action: 'skipped-sandbox' }
233
+ let action: ShadowUninstallAction = 'removed-not-loaded'
234
+ if (isShadowLoaded()) {
235
+ const uid = uidOrNull()
236
+ const r = uid
237
+ ? spawnSync('launchctl', ['bootout', `gui/${uid}/${SHADOW_PLIST_LABEL}`], { encoding: 'utf8' })
238
+ : { status: 1, stderr: 'cannot resolve uid' }
239
+ if (r.status !== 0 && isShadowLoaded()) {
240
+ return { path, action: 'failed', detail: `launchctl bootout failed: ${(r.stderr ?? '').trim() || `exit ${r.status}`}` }
241
+ }
242
+ action = 'booted-out'
243
+ }
244
+ // Also clear any STOP sentinel so a later reinstall starts clean.
245
+ try {
246
+ unlinkSync(shadowStopFile(env))
247
+ } catch {
248
+ /* none */
249
+ }
250
+ try {
251
+ rmSync(path)
252
+ } catch {
253
+ /* best-effort */
254
+ }
255
+ return { path, action }
256
+ }
@@ -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
+ })
@@ -41,6 +41,7 @@ import {
41
41
  type DaemonRestartResult,
42
42
  type LaunchdJobCycleResult,
43
43
  } from '../launch/launchd.ts'
44
+ import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
44
45
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
45
46
 
46
47
  /** The npm package the foundation publishes / updates from. */
@@ -242,6 +243,14 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
242
243
  const r = cycleLaunchdJob(label, plist, env)
243
244
  out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
244
245
  }
246
+ // The tmux→pty fidelity BURN-IN job (com.iapeer.shadow-fidelity) is foundation-owned and
247
+ // runs the just-replaced `iapeer shadow` off the same binary, so it carries the same LWCR
248
+ // constraint — but it is NOT a registry peer, so the scan above cannot reach it. Cycle it
249
+ // by label too (no-op when absent / not-loaded / foreign).
250
+ const shadow = cycleShadowJob(env)
251
+ if (shadow) {
252
+ out.push({ personality: SHADOW_PERSONALITY, runtime: 'foundation', label: SHADOW_PLIST_LABEL, state: shadow.state, detail: shadow.detail })
253
+ }
245
254
  return out
246
255
  }
247
256