@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
package/src/launch/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|