@agfpd/iapeer 0.2.63 → 0.2.65

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.
@@ -0,0 +1,148 @@
1
+ // Launch-side hosted-session orchestration (cutover Block 2 spawn-flip, Ф0a) — the supervisor-HOST
2
+ // equivalent of the tmux bring-up half of launch(): start the session under the supervisor, wait for
3
+ // readiness off its pane-log model (the supervisor's own boot-driver dismisses startup dialogs), and
4
+ // deliver the first message over its socket. Flag-gated per-peer (.pty-host); when the flag is off
5
+ // this module is never reached and launch is byte-identical (golden).
6
+ //
7
+ // @xterm STAYS OUT of the launch process by construction:
8
+ // - startSupervisorDaemon is dynamic-imported and @xterm-free (daemon.ts is deferred to the detached
9
+ // 'daemon' subprocess) — it only SPAWNS the daemon, @xterm runs there;
10
+ // - the deliver client is a pure-protocol leaf (no @xterm);
11
+ // - sessionAlive comes from client.ts (no @xterm);
12
+ // - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
15
+ import { attachedPath, defaultRunDir, pidPath } from '../supervisor/paths.ts'
16
+ import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
17
+ import { killSession as killSupervisorSession } from '../supervisor/index.ts'
18
+ import { paneLogViewport } from './readyGateModel.ts'
19
+ import type { LaunchInvocation } from './invocation.ts'
20
+ import type { RuntimeAdapter } from './types.ts'
21
+
22
+ /** Match the tmux launch geometry (index.ts new-session -x 220 -y 50) so the served child + the
23
+ * pane-log model render at the same size launch reads readiness at. */
24
+ export const HOST_COLS = 220
25
+ export const HOST_ROWS = 50
26
+
27
+ /** Per-peer spawn-flip flag: marker `<logDir>/<identity>.pty-host`. Present → launch hosts the session
28
+ * under the supervisor instead of `tmux new-session`. DISTINCT from `.pty-readygate` (the ready-gate
29
+ * viewport-source flip): a readygate-flipped peer is still tmux-HOSTED and stays under observer
30
+ * cross-check; only a `.pty-host` peer is supervisor-hosted (cross-check retires for it). */
31
+ export function ptyHostEnabled(logDir: string, identity: string): boolean {
32
+ return existsSync(`${logDir}/${identity}.pty-host`)
33
+ }
34
+
35
+ /** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
36
+ export function hostRunDir(): string {
37
+ return defaultRunDir()
38
+ }
39
+
40
+ /**
41
+ * Is there a LIVE supervisor session for this identity? This is the RUNTIME-STATE host detector the
42
+ * warm surfaces (liveness / deliver / reap / attach / observer) route on — NOT the `.pty-host` marker.
43
+ * The marker is spawn-INTENT (read by launch at spawn); deriving warm routing from the live session
44
+ * means rolling the marker back (rm) on a STILL-RUNNING hosted session never tears it (rm affects only
45
+ * the NEXT spawn). Rollback of a RUNNING hosted session is therefore kill-then-rm, NOT rm-alone.
46
+ */
47
+ export function hostSessionAlive(identity: string): boolean {
48
+ return hostDaemonAlive(hostRunDir(), identity)
49
+ }
50
+
51
+ /** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up). */
52
+ export function killPtyHost(identity: string): void {
53
+ killSupervisorSession(hostRunDir(), identity)
54
+ }
55
+
56
+ /**
57
+ * Is a HUMAN operator attached to this hosted session right now? (spawn-flip Ф0b-3 slice 3c). The
58
+ * supervisor daemon maintains a `.attached` marker present iff ≥1 client is connected (synced on every
59
+ * client add/detach incl. abnormal close/error/drain, and cleared on daemon start so a crashed
60
+ * predecessor's stale marker never lies). The warm-deliver busy-composer hold gates on THIS: hold for
61
+ * a human's unfinished composer input, but NEVER for the AI's own composer output when no one is
62
+ * attached. The host equivalent of `hasAttachedTmuxClient`.
63
+ */
64
+ export function hasAttachedSupervisorClient(identity: string): boolean {
65
+ return existsSync(attachedPath(hostRunDir(), identity))
66
+ }
67
+
68
+ /**
69
+ * A session-REPLACEMENT token for a hosted session (spawn-flip Ф0b-3 slice 3c): the supervisor
70
+ * daemon's pid. A `/new` or restart spawns a NEW daemon (new pid), so a message queued against the old
71
+ * session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
72
+ * `session_id:pane_id` token. null when no daemon pid is readable.
73
+ */
74
+ export function hostSessionToken(identity: string): string | null {
75
+ try {
76
+ const pid = readFileSync(pidPath(hostRunDir(), identity), 'utf8').trim()
77
+ return pid ? `host:${pid}` : null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ export interface HostStartResult {
84
+ ok: boolean
85
+ error?: string
86
+ }
87
+
88
+ /**
89
+ * Bring up the hosted session: start a detached supervisor daemon serving the composed invocation
90
+ * (argv+env from buildLaunchInvocation), writing the pane-log at `paneLogPath`. Returns ok/fail so
91
+ * launch can fall back to `tmux new-session` on failure (the unconditional spawn-fallback — a flipped
92
+ * peer is never worse than tmux ON SPAWN).
93
+ */
94
+ export async function startPtyHost(b: {
95
+ identity: string
96
+ runtime: string
97
+ cwd: string
98
+ paneLogPath: string
99
+ exitLogPath?: string
100
+ invocation: LaunchInvocation
101
+ }): Promise<HostStartResult> {
102
+ try {
103
+ const { startSupervisorDaemon } = await import('../supervisor/index.ts')
104
+ const r = startSupervisorDaemon({
105
+ session: b.identity,
106
+ runtime: b.runtime,
107
+ runDir: hostRunDir(),
108
+ serve: {
109
+ argv: b.invocation.argv,
110
+ env: b.invocation.env as Record<string, string>,
111
+ cwd: b.cwd,
112
+ paneLogPath: b.paneLogPath,
113
+ exitLogPath: b.exitLogPath,
114
+ cols: HOST_COLS,
115
+ rows: HOST_ROWS,
116
+ },
117
+ })
118
+ return r.state === 'failed' ? { ok: false, error: r.detail ?? 'supervisor host failed to come up' } : { ok: true }
119
+ } catch (e) {
120
+ return { ok: false, error: `pty-host bringup threw: ${(e as Error).message}` }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Wait for the hosted input surface to be ready: poll the pane-log model and run the adapter's
126
+ * isInputReady (the supervisor's boot-driver answers dialogs internally, so launch does NOT drive
127
+ * them). Returns true when ready, false on deadline / session death. Mirrors the tmux boot loop's
128
+ * 2 s cadence + bootDeadline.
129
+ */
130
+ export async function waitHostReady(
131
+ b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number },
132
+ sleep: (ms: number) => Promise<void>,
133
+ ): Promise<boolean> {
134
+ const log = `${b.logDir}/${b.identity}.log`
135
+ const iters = Math.max(1, Math.ceil(b.bootDeadlineSecs / 2))
136
+ for (let i = 0; i < iters; i++) {
137
+ await sleep(2000)
138
+ if (!hostSessionAlive(b.identity)) return false
139
+ const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS)
140
+ if (view && b.adapter.isInputReady(view)) return true
141
+ }
142
+ return false
143
+ }
144
+
145
+ /** Deliver the first/next message to the hosted session over its supervisor socket. */
146
+ export function deliverHosted(identity: string, message: string): Promise<DeliverResult> {
147
+ return deliverToHost(hostRunDir(), identity, message)
148
+ }
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { logReadyGateCrossCheck, readyGatePtyFlipEnabled, readyGateViewport } from './readyGateModel.ts'
6
+
7
+ // The Ф1 ready-gate source swap is DARK by default and falls back to capture-pane UNCONDITIONALLY,
8
+ // so the flip can never make readiness worse than tmux today. These prove exactly that contract.
9
+ const tmps: string[] = []
10
+ const mkLogDir = (): string => {
11
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-rgm-'))
12
+ tmps.push(d)
13
+ return d
14
+ }
15
+ afterEach(() => {
16
+ while (tmps.length) rmSync(tmps.pop()!, { recursive: true, force: true })
17
+ })
18
+ const FALLBACK = '<<capture-pane-fallback>>'
19
+
20
+ describe('readyGatePtyFlipEnabled (per-peer flip flag, canary-first)', () => {
21
+ test('absent marker → off; present marker → on', () => {
22
+ const d = mkLogDir()
23
+ expect(readyGatePtyFlipEnabled(d, 'codex-x')).toBe(false)
24
+ writeFileSync(join(d, 'codex-x.pty-readygate'), '')
25
+ expect(readyGatePtyFlipEnabled(d, 'codex-x')).toBe(true)
26
+ })
27
+ })
28
+
29
+ describe('readyGateViewport (Ф1 viewport-source swap, tmux-fallback)', () => {
30
+ test('flag OFF → the capture-pane fallback VERBATIM (byte-identical to today, no model built)', async () => {
31
+ const d = mkLogDir()
32
+ expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })).toBe(FALLBACK)
33
+ })
34
+
35
+ test('flag ON + a pane-log → the MODEL viewport (source swapped), not the fallback', async () => {
36
+ const d = mkLogDir()
37
+ writeFileSync(join(d, 'codex-x.pty-readygate'), '')
38
+ writeFileSync(join(d, 'codex-x.log'), '\x1b[24;1H› ready prompt here') // render a codex '›' prompt row
39
+ const v = await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })
40
+ expect(v).not.toBe(FALLBACK) // the source was swapped to the model
41
+ expect(v).toContain('› ready prompt here') // the model rendered the prompt the predicate keys on
42
+ })
43
+
44
+ test('flag ON but the pane-log is MISSING → fallback (never worse than tmux)', async () => {
45
+ const d = mkLogDir()
46
+ writeFileSync(join(d, 'codex-x.pty-readygate'), '')
47
+ expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })).toBe(FALLBACK)
48
+ })
49
+
50
+ test('flag ON but geometry is unknown (0x0) → fallback', async () => {
51
+ const d = mkLogDir()
52
+ writeFileSync(join(d, 'codex-x.pty-readygate'), '')
53
+ writeFileSync(join(d, 'codex-x.log'), '\x1b[24;1H› ready')
54
+ expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 0, rows: 0 })).toBe(FALLBACK)
55
+ })
56
+ })
57
+
58
+ describe('logReadyGateCrossCheck (serving-path soak observability)', () => {
59
+ test('appends a record with diverged computed; agreement → false, mismatch → true', () => {
60
+ const d = mkLogDir()
61
+ logReadyGateCrossCheck(d, { identity: 'codex-x', modelReady: true, captureReady: false, geom: '220x50', nowISO: '2026-06-14T00:00:00Z' })
62
+ logReadyGateCrossCheck(d, { identity: 'codex-x', modelReady: true, captureReady: true, geom: '220x50', nowISO: '2026-06-14T00:00:05Z' })
63
+ const lines = readFileSync(join(d, 'readygate-flip.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l))
64
+ expect(lines[0].diverged).toBe(true) // model ahead of capture
65
+ expect(lines[0].modelReady).toBe(true)
66
+ expect(lines[0].captureReady).toBe(false)
67
+ expect(lines[1].diverged).toBe(false) // agreement
68
+ })
69
+ test('no event-log dir → no-op (never blocks the ready-gate)', () => {
70
+ expect(() => logReadyGateCrossCheck(undefined, { identity: 'x', modelReady: true, captureReady: true, geom: '1x1', nowISO: 't' })).not.toThrow()
71
+ })
72
+ })
@@ -0,0 +1,140 @@
1
+ // Ф1 READY-GATE viewport-source FLIP (cutover Block 3) — DARK / flag-gated, canary-first.
2
+ //
3
+ // The cold-wake ready-gate (launch index.ts step 6) decides "is the input surface ready?" by
4
+ // running adapter.isInputReady on a VIEWPORT. Today that viewport is a `tmux capture-pane` scrape.
5
+ // This is the FIRST migration mutation: swap the viewport SOURCE to the pty-MODEL (an @xterm/headless
6
+ // emulation of the daemon's pane-log — the SAME raw child bytes tmux renders), keeping the SAME
7
+ // production predicate (adapter.isInputReady) and an UNCONDITIONAL capture-pane fallback.
8
+ //
9
+ // Why this is low-radius (boris's frame): it is a viewport-SOURCE swap, NOT a predicate change.
10
+ // The model verdict was validated 0-divergence vs capture-pane on the readiness predicate by the
11
+ // burn-in gate (incl. the codex resume-with-history splash-off-screen center cell) before any peer
12
+ // is flipped. It touches ONLY the isInputReady INPUT — dialog detection upstream and the delivery +
13
+ // delivery-confirm downstream (submitIntoTui's submit-landed + deliverViaTmux's transcript-mtime
14
+ // advance, the "real model advancement" final-arbiter) are untouched, so the canonical
15
+ // harmless-false-glyph property is preserved by construction.
16
+ //
17
+ // FLAG: a per-peer marker `<logDir>/<identity>.pty-readygate`. Absent → capture-pane (byte-identical
18
+ // to today). Present → the model viewport. Per-peer so the first flip is a single canary with the
19
+ // whole fleet still on tmux; dynamic (touch/rm, no daemon restart). @xterm is dynamic-imported ONLY
20
+ // when the flag is set, so the launch hot path stays @xterm-free with the flip off.
21
+
22
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import type { Terminal } from '@xterm/headless' // TYPE-ONLY (erased) — the runtime @xterm load is the dynamic import below
25
+
26
+ const SEED_BYTES = 4 * 1024 * 1024
27
+
28
+ /**
29
+ * SERVING-path cross-check log (the post-flip soak gate). When a peer is flipped, the MODEL verdict
30
+ * is AUTHORITATIVE in the cold-wake ready-gate; this records, per evaluation, what capture-pane WOULD
31
+ * have said — so a soak can prove 0 model-vs-capture divergence IN THE SERVING PATH (the read-only
32
+ * burn-in measures continuously but never drove the actual ready decision). Append-only jsonl under
33
+ * the event-log dir. Best-effort: a log hiccup never affects the ready decision. No-op without a dir.
34
+ */
35
+ export function logReadyGateCrossCheck(
36
+ eventLogDir: string | undefined,
37
+ rec: { identity: string; modelReady: boolean; captureReady: boolean; geom: string; nowISO: string },
38
+ ): void {
39
+ if (!eventLogDir) return
40
+ try {
41
+ appendFileSync(
42
+ join(eventLogDir, 'readygate-flip.jsonl'),
43
+ JSON.stringify({
44
+ ts: rec.nowISO,
45
+ identity: rec.identity,
46
+ modelReady: rec.modelReady,
47
+ captureReady: rec.captureReady,
48
+ diverged: rec.modelReady !== rec.captureReady,
49
+ geom: rec.geom,
50
+ }) + '\n',
51
+ )
52
+ } catch {
53
+ /* observability only — never block the ready-gate */
54
+ }
55
+ }
56
+
57
+ /** Per-peer ready-gate flip flag: the marker `<logDir>/<identity>.pty-readygate`. */
58
+ export function readyGatePtyFlipEnabled(logDir: string, identity: string): boolean {
59
+ return existsSync(`${logDir}/${identity}.pty-readygate`)
60
+ }
61
+
62
+ export interface ReadyGateViewportOpts {
63
+ logDir: string
64
+ identity: string
65
+ /** The capture-pane scrape — returned verbatim when the flag is off OR any model build fails. */
66
+ fallback: string
67
+ cols: number
68
+ rows: number
69
+ }
70
+
71
+ /**
72
+ * Resolve the ready-gate viewport. Flag OFF → `fallback` (capture-pane) verbatim — zero work, the
73
+ * caller never even reaches here unless it pre-checked the flag, but this stays safe regardless.
74
+ * Flag ON → the pty-model viewport as plain text. ANY failure (unreadable/short log, @xterm hiccup)
75
+ * → `fallback`: the flip can never make readiness WORSE than capture-pane today (tmux is the floor).
76
+ */
77
+ export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<string> {
78
+ if (!readyGatePtyFlipEnabled(opts.logDir, opts.identity)) return opts.fallback
79
+ if (!(opts.cols > 0 && opts.rows > 0)) return opts.fallback
80
+ const view = await paneLogViewport(`${opts.logDir}/${opts.identity}.log`, opts.cols, opts.rows)
81
+ return view ?? opts.fallback // tmux-fallback is unconditional — never worse than today
82
+ }
83
+
84
+ /**
85
+ * Read a pane-log's tail into a headless @xterm and render the viewport as plain text — the SAME
86
+ * 0-divergence-validated model the ready-gate flip uses, shared with the spawn-flip hosted path (a
87
+ * supervisor-HOSTED session has no capture-pane, so its pane-log model is the ONLY readiness source).
88
+ * @xterm is dynamic-imported here so a flag-off launch never loads it. Returns null on any failure
89
+ * (unreadable/short log, @xterm hiccup) — the caller decides the fallback (capture-pane for the
90
+ * readygate flip; a retry/fail for hosted).
91
+ */
92
+ async function loadPaneLogModel(log: string, cols: number, rows: number): Promise<Terminal | null> {
93
+ if (!(cols > 0 && rows > 0) || !existsSync(log)) return null
94
+ try {
95
+ const { Terminal } = await import('@xterm/headless')
96
+ const fd = openSync(log, 'r')
97
+ try {
98
+ const sz = fstatSync(fd).size
99
+ const off = Math.max(0, sz - SEED_BYTES)
100
+ const buf = Buffer.alloc(sz - off)
101
+ readSync(fd, buf, 0, buf.length, off)
102
+ const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
103
+ await new Promise<void>(res => term.write(buf, () => res()))
104
+ return term
105
+ } finally {
106
+ closeSync(fd)
107
+ }
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ export async function paneLogViewport(log: string, cols: number, rows: number): Promise<string | null> {
114
+ const term = await loadPaneLogModel(log, cols, rows)
115
+ if (!term) return null
116
+ const { modelToPlainText } = await import('../shadow/render.ts')
117
+ return modelToPlainText(term, cols, rows)
118
+ }
119
+
120
+ /**
121
+ * Spawn-flip Ф0b-2 hosted composer-occupancy guard (slice 2): does the hosted session's composer hold
122
+ * human input? Reads the pane-log model — the SAME raw child bytes tmux renders — and runs the burn-in-
123
+ * validated `composerOccupancyFromModel` (REUSED, not reimplemented). The hosted equivalent of
124
+ * `targetComposerBlocksDelivery`'s capture-pane occupancy. @xterm loads dynamically here (flag-on
125
+ * hosted only; the leaf `composerOccupancyFromModel` import is type-only at runtime, so the warm path
126
+ * stays @xterm-free with the flip off). UNCERTAINTY → BUSY (true): a model-build failure mirrors the
127
+ * tmux predicate's capture-fail-is-busy stance — prefer a false-busy hold over a false-free paste into
128
+ * a human composer.
129
+ *
130
+ * NB (sequence, boris): this is the DETECTION primitive. The end-to-end gate (attached-supervisor-
131
+ * client + enqueue + host-aware drain) lands with the attach client in Ф0b-3 — until a human can
132
+ * attach to a hosted session there is no human composer to protect. The caller composes the pane-log
133
+ * path from logDir+identity (the threaded `logDir` finding), mirroring `waitHostReady`.
134
+ */
135
+ export async function paneLogComposerOccupied(log: string, cols: number, rows: number, runtime: string): Promise<boolean> {
136
+ const term = await loadPaneLogModel(log, cols, rows)
137
+ if (!term) return true // uncertainty → busy: never paste into a maybe-occupied composer
138
+ const { composerOccupancyFromModel } = await import('../shadow/render.ts')
139
+ return composerOccupancyFromModel(term, cols, rows, runtime)
140
+ }
@@ -48,6 +48,7 @@ import {
48
48
  } from '../launch/index.ts'
49
49
  import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
50
50
  import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
51
+ import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
51
52
  import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
52
53
 
53
54
  // ─────────────────────────────────────────────────────────────────────────────
@@ -650,7 +651,12 @@ function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err:
650
651
  return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
651
652
  }
652
653
  function sessionAlive(sock: string, identity: string): boolean {
653
- return tmux(sock, 'has-session', '-t', identity).ok
654
+ // RUNTIME-STATE host detection (spawn-flip Ф0b), NOT the `.pty-host` marker: a peer with a LIVE
655
+ // supervisor session is hosted-and-alive; otherwise fall to tmux has-session. Routing liveness on
656
+ // the live supervisor session (not the spawn-intent marker) makes a marker rollback safe on a
657
+ // still-running hosted session — rm-marker only changes the NEXT spawn, never the running routing.
658
+ // Non-hosted peers: hostSessionAlive is a cheap pid-file miss → identical result to today (tmux).
659
+ return hostSessionAlive(identity) || tmux(sock, 'has-session', '-t', identity).ok
654
660
  }
655
661
 
656
662
  /** Death-class tag for a gone session (live case: iapeer-memory 10.06 — the WHOLE
@@ -1049,6 +1055,10 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1049
1055
  // ─────────────────────────────────────────────────────────────────────────────
1050
1056
 
1051
1057
  export function killSession(sock: string, identity: string): void {
1058
+ // Hosted peer (spawn-flip Ф0b): SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and
1059
+ // removes the sock/pid/serve-spec. The tmux teardown below is then a harmless no-op (no tmux session
1060
+ // on this sock); for a non-hosted peer it is the real reap, byte-identical to today.
1061
+ if (hostSessionAlive(identity)) killPtyHost(identity)
1052
1062
  tmux(sock, 'kill-session', '-t', identity)
1053
1063
  const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
1054
1064
  if (!sessions.trim()) {
@@ -0,0 +1,65 @@
1
+ // Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
2
+ //
3
+ // The observer compares the pty-model vs LIVE TMUX. A `.pty-host`-designated peer has no tmux to
4
+ // compare against, and during a flip a lingering tmux socket for it would surface FALSE divergences.
5
+ // This proves the explicit skip fires IN enumerate even in that race (a live tmux session + a pane-log
6
+ // present): the marked peer is NOT enrolled, while an unmarked sibling IS. Real-tmux, guarded (a clean
7
+ // CI runner has no tmux; the release machine has it — same pattern as routesend-wake).
8
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
9
+ import { spawnSync } from 'node:child_process'
10
+ import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { buildSocketPath } from '../core/socket.ts'
14
+ import { enumerate } from './index.ts'
15
+
16
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
17
+
18
+ let sockDir: string
19
+ let logDir: string
20
+ const KEEP = 'keepobs' // unmarked → observed
21
+ const SKIP = 'skipobs' // .pty-host marker → skipped
22
+ const addrOf = (p: string) => `codex-${p}`
23
+ const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
24
+
25
+ function startTmux(personality: string): void {
26
+ const r = spawnSync(
27
+ 'tmux',
28
+ ['-S', sockOf(personality), 'new-session', '-d', '-x', '80', '-y', '24', '-s', addrOf(personality), 'cat'],
29
+ { encoding: 'utf8' },
30
+ )
31
+ if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
32
+ }
33
+ function killTmux(personality: string): void {
34
+ spawnSync('tmux', ['-S', sockOf(personality), 'kill-server'], { stdio: 'ignore' })
35
+ }
36
+
37
+ beforeAll(() => {
38
+ sockDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-sock-'))
39
+ logDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-log-'))
40
+ if (!tmuxAvailable) return
41
+ // Both peers: a live tmux session + a pane-log (so enrolment is otherwise satisfied).
42
+ for (const p of [KEEP, SKIP]) {
43
+ startTmux(p)
44
+ writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
45
+ }
46
+ // SKIP additionally carries the `.pty-host` designation — even WITH a live tmux session, the observer
47
+ // must not enroll it (the flip-transition race the skip guards against).
48
+ writeFileSync(join(logDir, `${addrOf(SKIP)}.pty-host`), '')
49
+ })
50
+
51
+ afterAll(() => {
52
+ if (tmuxAvailable) for (const p of [KEEP, SKIP]) killTmux(p)
53
+ rmSync(sockDir, { recursive: true, force: true })
54
+ rmSync(logDir, { recursive: true, force: true })
55
+ })
56
+
57
+ describe.if(tmuxAvailable)('observer enumerate skips supervisor-hosted (.pty-host) peers', () => {
58
+ test('a .pty-host peer is NOT enrolled even with a live tmux session + pane-log; an unmarked one IS', () => {
59
+ const models = new Map<string, any>()
60
+ enumerate(models, { sockDir, logDir, eventLogDir: logDir })
61
+ expect(models.has(addrOf(KEEP))).toBe(true) // unmarked → observed (live tmux + log present)
62
+ expect(models.has(addrOf(SKIP))).toBe(false) // .pty-host designation → skipped despite live tmux
63
+ for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
64
+ })
65
+ })
@@ -17,50 +17,20 @@ import { openSync, closeSync, fstatSync, readSync, existsSync, appendFileSync, w
17
17
  import { join } from 'node:path'
18
18
  import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transport/index.ts'
19
19
  import { getAdapter } from '../launch/index.ts'
20
+ import { ptyHostEnabled } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: the observer skips supervisor-hosted peers
20
21
  import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
21
22
  import type { Runtime } from '../core/constants.ts'
22
23
  import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
24
+ // modelToPlainText lives in the leaf render.ts (shared with readyGateModel + the supervisor
25
+ // boot-driver); re-exported here so existing importers of './index.ts' keep resolving.
26
+ import { composerOccupancyFromModel, modelToPlainText } from './render.ts'
27
+ export { composerOccupancyFromModel, modelToPlainText }
23
28
 
24
29
  const SEED_BYTES = 4 * 1024 * 1024
25
- const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
26
30
  const pidAlive = (pid: number): boolean => {
27
31
  try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
28
32
  }
29
33
 
30
- /** PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose
31
- * first visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246)
32
- * visible cell after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it. */
33
- export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
34
- const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
35
- let py = -1, px = -1
36
- for (let y = top; y < bot; y++) {
37
- const l = b.getLine(y); if (!l) continue
38
- let fx = -1
39
- for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
40
- if (fx < 0) continue
41
- if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
42
- }
43
- if (py < 0) return false
44
- const l = b.getLine(py)!
45
- for (let x = px + 1; x < cols; x++) {
46
- const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
47
- if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
48
- }
49
- return false
50
- }
51
-
52
- /** The model's viewport as PLAIN text (mimics `capture-pane -p`, the input isInputReady takes). */
53
- export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
54
- const b = term.buffer.active, lines: string[] = []
55
- for (let y = b.baseY; y < b.baseY + rows; y++) {
56
- const l = b.getLine(y); if (!l) { lines.push(''); continue }
57
- let s = ''
58
- for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
59
- lines.push(s.replace(/\s+$/, ''))
60
- }
61
- return lines.join('\n')
62
- }
63
-
64
34
  interface Obs { addr: string; sock: string; runtime: Runtime; cols: number; rows: number; term: Terminal; fd: number; offset: number }
65
35
 
66
36
  export interface ShadowOptions {
@@ -84,12 +54,18 @@ function feed(o: Obs): Promise<number> {
84
54
  return new Promise(res => o.term.write(buf, () => res(buf.length)))
85
55
  }
86
56
 
87
- function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
57
+ export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
88
58
  let peers: { runtime: Runtime; personality: string }[] = []
89
59
  try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
90
60
  const live = new Set<string>()
91
61
  for (const p of peers) {
92
- const addr = buildProcessAddress(p.runtime, p.personality); live.add(addr)
62
+ const addr = buildProcessAddress(p.runtime, p.personality)
63
+ // SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
64
+ // LIVE TMUX; a `.pty-host`-designated peer has no tmux to compare against, and during a flip a
65
+ // lingering tmux socket for it would surface false divergences. Skip by the migration DESIGNATION
66
+ // (marker), BEFORE `live` — a peer flipped while enrolled then falls out of `live` and is reaped.
67
+ if (ptyHostEnabled(opts.logDir, addr)) continue
68
+ live.add(addr)
93
69
  if (models.has(addr)) continue
94
70
  try {
95
71
  const sock = buildSocketPath(p.runtime, p.personality, opts.sockDir)
@@ -0,0 +1,60 @@
1
+ // Leaf render helper: @xterm model → plain text. Extracted from shadow/index.ts so consumers
2
+ // (the shadow observer, the Ф1 ready-gate flip, and the Block-2 supervisor boot-driver) share ONE
3
+ // definition without importing the observer's transport/launch graph. Type-only @xterm import →
4
+ // no runtime dependency beyond the Terminal instance the caller already holds; hermetically testable.
5
+ import type { Terminal } from '@xterm/headless'
6
+
7
+ /**
8
+ * The model's VISIBLE viewport (baseY..baseY+rows) as PLAIN text — mimics `tmux capture-pane -p`,
9
+ * the surface the runtime adapters' pane predicates (isInputReady / bootDialogKeys) take. Trailing
10
+ * whitespace per line is trimmed (capture-pane does the same), cells coalesced left-to-right.
11
+ *
12
+ * 0-divergence-validated vs capture-pane on READY screens by the ready-gate burn-in; the supervisor
13
+ * boot-driver additionally validates it on BOOT-DIALOG screens (a different screen state the
14
+ * ready-gate observation never saw).
15
+ */
16
+ export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
17
+ const b = term.buffer.active,
18
+ lines: string[] = []
19
+ for (let y = b.baseY; y < b.baseY + rows; y++) {
20
+ const l = b.getLine(y)
21
+ if (!l) {
22
+ lines.push('')
23
+ continue
24
+ }
25
+ let s = ''
26
+ for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
27
+ lines.push(s.replace(/\s+$/, ''))
28
+ }
29
+ return lines.join('\n')
30
+ }
31
+
32
+ const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
33
+
34
+ /**
35
+ * PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose first
36
+ * visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246) visible cell
37
+ * after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it by the burn-in. Lives
38
+ * in this leaf (type-only @xterm) so the spawn-flip hosted occupancy guard can reuse it WITHOUT
39
+ * pulling the @xterm-loading observer graph into the warm-deliver path; the Terminal instance is
40
+ * built by the caller (paneLogComposerOccupied dynamic-imports @xterm). Re-exported from
41
+ * shadow/index.ts so the observer's existing importers keep resolving.
42
+ */
43
+ export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
44
+ const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
45
+ let py = -1, px = -1
46
+ for (let y = top; y < bot; y++) {
47
+ const l = b.getLine(y); if (!l) continue
48
+ let fx = -1
49
+ for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
50
+ if (fx < 0) continue
51
+ if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
52
+ }
53
+ if (py < 0) return false
54
+ const l = b.getLine(py)!
55
+ for (let x = px + 1; x < cols; x++) {
56
+ const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
57
+ if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
58
+ }
59
+ return false
60
+ }