@agfpd/iapeer 0.2.63 → 0.2.64

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.63",
3
+ "version": "0.2.64",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@ import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/
21
21
  import { readLaunchEnv } from '../storage/index.ts'
22
22
  import { canaryChannel, dismissCanary, ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
23
23
  import { hardenCmdLogDir, prepareCmdLogDir } from './cmdlog.ts'
24
+ import { logReadyGateCrossCheck, readyGatePtyFlipEnabled, readyGateViewport } from './readyGateModel.ts'
24
25
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
25
26
  import { claudeAdapter } from './adapters/claude.ts'
26
27
  import { codexAdapter } from './adapters/codex.ts'
@@ -385,7 +386,29 @@ export const launch: LaunchFn = async (
385
386
  tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
386
387
  continue
387
388
  }
388
- if (adapter.isInputReady(pane)) {
389
+ // Ф1 READY-GATE SOURCE FLIP (per-peer flag, DARK by default). When the peer's flip flag is set,
390
+ // run the SAME isInputReady predicate on the pty-MODEL viewport (validated 0-divergence vs
391
+ // capture-pane by the burn-in gate, incl. the codex resume-with-history splash-off-screen center)
392
+ // instead of the capture-pane scrape, with an UNCONDITIONAL capture-pane fallback. This is the
393
+ // ONLY changed input — dialog detection (above) and delivery + delivery-confirm (below + transport)
394
+ // are untouched. Flag OFF → readyView === pane (byte-identical to today).
395
+ let readyView = pane
396
+ if (readyGatePtyFlipEnabled(cfg.logDir, identity)) {
397
+ const geo = tmux(sock, 'display', '-p', '-t', identity, '#{pane_width}x#{pane_height}')
398
+ const [c, r] = geo.ok ? geo.out.trim().split('x').map(Number) : [0, 0]
399
+ readyView = await readyGateViewport({ logDir: cfg.logDir, identity, fallback: pane, cols: c ?? 0, rows: r ?? 0 })
400
+ // SERVING-path cross-check (post-flip soak gate): the model verdict is AUTHORITATIVE (readyView);
401
+ // record what capture-pane WOULD have said, to prove 0 model-vs-capture divergence live in the
402
+ // serving path. Observability only — never affects the ready decision.
403
+ logReadyGateCrossCheck(cfg.exitLogDir, {
404
+ identity,
405
+ modelReady: adapter.isInputReady(readyView),
406
+ captureReady: adapter.isInputReady(pane),
407
+ geom: `${c ?? 0}x${r ?? 0}`,
408
+ nowISO: new Date().toISOString(),
409
+ })
410
+ }
411
+ if (adapter.isInputReady(readyView)) {
389
412
  if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
390
413
  // Boot delivery = load-buffer → paste-buffer -p → Enter (Ф-#8b hardening):
391
414
  // the SAME mechanism warm delivery uses (transport.deliverViaTmux), replacing
@@ -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,99 @@
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
+
25
+ const SEED_BYTES = 4 * 1024 * 1024
26
+
27
+ /**
28
+ * SERVING-path cross-check log (the post-flip soak gate). When a peer is flipped, the MODEL verdict
29
+ * is AUTHORITATIVE in the cold-wake ready-gate; this records, per evaluation, what capture-pane WOULD
30
+ * have said — so a soak can prove 0 model-vs-capture divergence IN THE SERVING PATH (the read-only
31
+ * burn-in measures continuously but never drove the actual ready decision). Append-only jsonl under
32
+ * the event-log dir. Best-effort: a log hiccup never affects the ready decision. No-op without a dir.
33
+ */
34
+ export function logReadyGateCrossCheck(
35
+ eventLogDir: string | undefined,
36
+ rec: { identity: string; modelReady: boolean; captureReady: boolean; geom: string; nowISO: string },
37
+ ): void {
38
+ if (!eventLogDir) return
39
+ try {
40
+ appendFileSync(
41
+ join(eventLogDir, 'readygate-flip.jsonl'),
42
+ JSON.stringify({
43
+ ts: rec.nowISO,
44
+ identity: rec.identity,
45
+ modelReady: rec.modelReady,
46
+ captureReady: rec.captureReady,
47
+ diverged: rec.modelReady !== rec.captureReady,
48
+ geom: rec.geom,
49
+ }) + '\n',
50
+ )
51
+ } catch {
52
+ /* observability only — never block the ready-gate */
53
+ }
54
+ }
55
+
56
+ /** Per-peer ready-gate flip flag: the marker `<logDir>/<identity>.pty-readygate`. */
57
+ export function readyGatePtyFlipEnabled(logDir: string, identity: string): boolean {
58
+ return existsSync(`${logDir}/${identity}.pty-readygate`)
59
+ }
60
+
61
+ export interface ReadyGateViewportOpts {
62
+ logDir: string
63
+ identity: string
64
+ /** The capture-pane scrape — returned verbatim when the flag is off OR any model build fails. */
65
+ fallback: string
66
+ cols: number
67
+ rows: number
68
+ }
69
+
70
+ /**
71
+ * Resolve the ready-gate viewport. Flag OFF → `fallback` (capture-pane) verbatim — zero work, the
72
+ * caller never even reaches here unless it pre-checked the flag, but this stays safe regardless.
73
+ * Flag ON → the pty-model viewport as plain text. ANY failure (unreadable/short log, @xterm hiccup)
74
+ * → `fallback`: the flip can never make readiness WORSE than capture-pane today (tmux is the floor).
75
+ */
76
+ export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<string> {
77
+ if (!readyGatePtyFlipEnabled(opts.logDir, opts.identity)) return opts.fallback
78
+ if (!(opts.cols > 0 && opts.rows > 0)) return opts.fallback
79
+ const log = `${opts.logDir}/${opts.identity}.log`
80
+ if (!existsSync(log)) return opts.fallback
81
+ try {
82
+ const { Terminal } = await import('@xterm/headless')
83
+ const { modelToPlainText } = await import('../shadow/index.ts')
84
+ const fd = openSync(log, 'r')
85
+ try {
86
+ const sz = fstatSync(fd).size
87
+ const off = Math.max(0, sz - SEED_BYTES)
88
+ const buf = Buffer.alloc(sz - off)
89
+ readSync(fd, buf, 0, buf.length, off)
90
+ const term = new Terminal({ cols: opts.cols, rows: opts.rows, allowProposedApi: true, scrollback: 3000 })
91
+ await new Promise<void>(res => term.write(buf, () => res()))
92
+ return modelToPlainText(term, opts.cols, opts.rows)
93
+ } finally {
94
+ closeSync(fd)
95
+ }
96
+ } catch {
97
+ return opts.fallback // tmux-fallback is unconditional — never worse than today
98
+ }
99
+ }