@agfpd/iapeer 0.2.64 → 0.2.66

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,95 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { Terminal } from '@xterm/headless'
7
+ import { modelToPlainText } from '../shadow/render.ts'
8
+ import { killSession, startSupervisorDaemon } from './index.ts'
9
+
10
+ // Slice c — the supervisor pane-log must be a drop-in for tmux pipe-pane: the SAME raw child bytes to
11
+ // <identity>.log (so observer/shadow/readyGateViewport read a served session identically), AND a model
12
+ // built from it must render what tmux's capture-pane shows. Live tmux + Bun-native pty → guarded.
13
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
14
+ const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
15
+ const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
16
+ const d = tmuxAvailable && bunPty ? describe : describe.skip
17
+
18
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
19
+ const readBuf = (p: string): Buffer => (existsSync(p) ? readFileSync(p) : Buffer.alloc(0))
20
+ const modelOf = (bytes: Buffer, cols: number, rows: number): Promise<string> => {
21
+ const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 1000 })
22
+ return new Promise<string>(res => t.write(bytes, () => res(modelToPlainText(t, cols, rows))))
23
+ }
24
+
25
+ d('supervisor pane-log ≡ tmux pipe-pane', () => {
26
+ test('RAW-BYTE-IDENTITY: the same child yields byte-identical logs (LF/CRLF + multibyte + ANSI)', async () => {
27
+ const dir = mkdtempSync(join(tmpdir(), 'panelog-bp-'))
28
+ const tmuxSock = join(dir, 't.sock')
29
+ const logTmux = join(dir, 'tmux.log')
30
+ const logSup = join(dir, 'sup.log')
31
+ const runDir = join(dir, 'run')
32
+ // bare \n, \r\n, multibyte ❯ › … é (the latin1 grabli), ANSI SGR — the byte classes that diverge
33
+ const child = ['sh', '-c', `sleep 0.5; printf 'L1\\nL2\\r\\ng:❯›…é\\n\\033[31mRED\\033[0m\\nTAB\\tEND\\n'; sleep 1`]
34
+ try {
35
+ spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'p', '-x', '80', '-y', '24', ...child], { stdio: 'ignore' })
36
+ spawnSync('tmux', ['-S', tmuxSock, 'pipe-pane', '-o', '-t', 'p', `cat >> '${logTmux}'`], { stdio: 'ignore' })
37
+ startSupervisorDaemon({ session: 'bp', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
38
+ await sleep(2300) // both children: sleep .5 + emit + sleep 1 ≈ exit ~1.5s; tmux's cat EOF-flushes
39
+ const a = readBuf(logTmux)
40
+ const b = readBuf(logSup)
41
+ expect(b.length).toBeGreaterThan(0)
42
+ expect(b.equals(a)).toBe(true) // byte-for-byte — raw Buffer passthrough, shared onlcr default
43
+ expect(b.includes(Buffer.from('❯›…é', 'utf8'))).toBe(true) // multibyte survived (no latin1 re-encode)
44
+ } finally {
45
+ spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
46
+ try {
47
+ killSession(runDir, 'bp')
48
+ } catch {
49
+ /* */
50
+ }
51
+ await sleep(300)
52
+ rmSync(dir, { recursive: true, force: true })
53
+ }
54
+ })
55
+
56
+ test('BINDING: a model from the supervisor pane-log renders what tmux capture-pane shows', async () => {
57
+ const dir = mkdtempSync(join(tmpdir(), 'panelog-bind-'))
58
+ const tmuxSock = join(dir, 't.sock')
59
+ const logSup = join(dir, 'sup.log')
60
+ const runDir = join(dir, 'run')
61
+ const COLS = 80,
62
+ ROWS = 24
63
+ // a static frame (clear+home, three lines incl. the ❯ ready glyph), then idle so capture-pane sees it
64
+ const child = ['sh', '-c', `printf '\\033[2J\\033[HLine one\\r\\n ❯ ready prompt\\r\\nthird line ok\\r\\n'; sleep 30`]
65
+ try {
66
+ spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'b', '-x', String(COLS), '-y', String(ROWS), ...child], { stdio: 'ignore' })
67
+ startSupervisorDaemon({ session: 'bind', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
68
+ // let the frame render in both
69
+ let cap = ''
70
+ for (let i = 0; i < 30 && !/❯ ready prompt/.test(cap); i++) {
71
+ await sleep(200)
72
+ cap = spawnSync('tmux', ['-S', tmuxSock, 'capture-pane', '-p', '-t', 'b'], { encoding: 'utf8' }).stdout || ''
73
+ }
74
+ const modelSup = await modelOf(readBuf(logSup), COLS, ROWS)
75
+ // tmux capture-pane render (its own emulator) == @xterm model of the SUPERVISOR's pane-log
76
+ const norm = (s: string): string =>
77
+ s
78
+ .split('\n')
79
+ .map(l => l.replace(/\s+$/, ''))
80
+ .join('\n')
81
+ .replace(/\n+$/, '')
82
+ expect(norm(modelSup)).toBe(norm(cap))
83
+ expect(modelSup).toContain('❯ ready prompt') // the ready glyph survived into the model
84
+ } finally {
85
+ spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
86
+ try {
87
+ killSession(runDir, 'bind')
88
+ } catch {
89
+ /* */
90
+ }
91
+ await sleep(300)
92
+ rmSync(dir, { recursive: true, force: true })
93
+ }
94
+ })
95
+ })
@@ -12,3 +12,12 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
12
12
  export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
13
13
  export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
14
14
  export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
15
+ /** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
16
+ * Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
17
+ * the daemon CLI entry, removed on shutdown. Absent → a bare throwaway/tick session. */
18
+ export const servePath = (runDir: string, session: string): string => join(runDir, `${session}.serve.json`)
19
+ /** Attached-client marker (spawn-flip Ф0b-3 slice 3c): present iff ≥1 supervisor client is attached.
20
+ * The daemon maintains it on every client add/delete; the warm-deliver path (a DIFFERENT process)
21
+ * reads it to gate the hosted busy-composer hold — never paste over a HUMAN's unfinished input, but
22
+ * never hold for the AI's own composer output when no operator is attached. */
23
+ export const attachedPath = (runDir: string, session: string): string => join(runDir, `${session}.attached`)
@@ -1,4 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
2
6
  import {
3
7
  BackpressureWriter,
4
8
  FRAME_DATA,
@@ -6,12 +10,15 @@ import {
6
10
  TERM_RESET,
7
11
  capabilityResponses,
8
12
  frame,
13
+ keysToBytes,
9
14
  makeFramer,
10
15
  parseSize,
11
16
  sizePayload,
12
17
  stripQueries,
13
18
  } from './protocol.ts'
14
19
 
20
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
21
+
15
22
  describe('frame protocol', () => {
16
23
  test('frame() lays out [type][len:u32be][payload]', () => {
17
24
  const f = frame(FRAME_DATA, 'hi')
@@ -129,3 +136,87 @@ describe('BackpressureWriter (port-dep #2: the ~90 KB-over-8 KB truncation that
129
136
  expect(w.isDead).toBe(true)
130
137
  })
131
138
  })
139
+
140
+ describe('keysToBytes (tmux send-keys vocabulary → pty bytes, the boot key-set)', () => {
141
+ test('Enter → CR (the trust/proceed dialogs)', () => {
142
+ expect(keysToBytes(['Enter'])).toEqual(Buffer.from('\r'))
143
+ })
144
+ test('codex update-decline [2,Enter] → "2" + CR', () => {
145
+ expect(keysToBytes(['2', 'Enter'])).toEqual(Buffer.from('2\r'))
146
+ })
147
+ test('codex hooks-review [Down,Enter] in NORMAL cursor mode → CSI-B + CR', () => {
148
+ expect(keysToBytes(['Down', 'Enter'])).toEqual(Buffer.from('\x1b[B\r'))
149
+ })
150
+ test('[Down,Enter] under DECCKM application-cursor mode → SS3-B + CR (the mode-sensitivity boris flagged)', () => {
151
+ expect(keysToBytes(['Down', 'Enter'], { appCursorKeys: true })).toEqual(Buffer.from('\x1bOB\r'))
152
+ })
153
+ test('claude resume-picker [Down] → cursor-down only', () => {
154
+ expect(keysToBytes(['Down'])).toEqual(Buffer.from('\x1b[B'))
155
+ })
156
+ test('literal mode -l sends the rest verbatim (executeControl /compact)', () => {
157
+ expect(keysToBytes(['-l', '/compact'])).toEqual(Buffer.from('/compact'))
158
+ })
159
+ test('Escape (interrupt) → ESC', () => {
160
+ expect(keysToBytes(['Escape'])).toEqual(Buffer.from('\x1b'))
161
+ })
162
+ test('an unknown token falls through to literal text', () => {
163
+ expect(keysToBytes(['hello'])).toEqual(Buffer.from('hello'))
164
+ })
165
+ })
166
+
167
+ // boris's acceptance bar: the translator must be BYTE-IDENTICAL to real `tmux send-keys` for the
168
+ // key-set the adapters actually return — not a "looks right" table. We capture exactly what tmux
169
+ // delivers to a pane (raw mode bypasses the line discipline so no CR↔NL cooking) and compare.
170
+ // Guarded by tmuxAvailable (a clean CI runner has no tmux → these skip, per the live-tmux lesson).
171
+ function tmuxSendKeysBytes(keys: string[], appCursor: boolean, n: number): Buffer {
172
+ const dir = mkdtempSync(join(tmpdir(), 'pts-keys-'))
173
+ const sock = join(dir, 's.sock')
174
+ const out = join(dir, 'out.bin')
175
+ // The pane program: optionally emit DECCKM (CSI ? 1 h) as its OWN output so tmux tracks
176
+ // application-cursor mode; then raw mode (capture send-keys bytes verbatim); then read exactly n
177
+ // bytes and exit (head flushes on exit — a buffered cat would lose the tail on kill).
178
+ const pre = appCursor ? `printf '\\033[?1h'; ` : ''
179
+ const paneCmd = `${pre}stty raw -echo; head -c ${n} > '${out}'`
180
+ try {
181
+ const create = spawnSync(
182
+ 'tmux',
183
+ ['-S', sock, 'new-session', '-d', '-s', 'k', '-x', '80', '-y', '24', 'sh', '-c', paneCmd],
184
+ { stdio: 'ignore' },
185
+ )
186
+ if (create.status !== 0) throw new Error('tmux new-session failed')
187
+ Bun.sleepSync(400) // let the pane reach raw mode + tmux process the DECCKM output
188
+ spawnSync('tmux', ['-S', sock, 'send-keys', '-t', 'k', ...keys], { stdio: 'ignore' })
189
+ const deadline = Date.now() + 3000
190
+ while (Date.now() < deadline) {
191
+ if (existsSync(out) && statSync(out).size >= n) break
192
+ Bun.sleepSync(50)
193
+ }
194
+ return existsSync(out) ? readFileSync(out) : Buffer.alloc(0)
195
+ } finally {
196
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
197
+ try {
198
+ rmSync(dir, { recursive: true, force: true })
199
+ } catch {
200
+ /* */
201
+ }
202
+ }
203
+ }
204
+
205
+ describe.if(tmuxAvailable)('keysToBytes byte-identity vs real tmux send-keys (live)', () => {
206
+ test('NORMAL cursor mode: [Down,Enter]', () => {
207
+ const ours = keysToBytes(['Down', 'Enter'])
208
+ expect(tmuxSendKeysBytes(['Down', 'Enter'], false, ours.length)).toEqual(ours)
209
+ })
210
+ test('DECCKM application-cursor mode: [Down,Enter] → SS3', () => {
211
+ const ours = keysToBytes(['Down', 'Enter'], { appCursorKeys: true })
212
+ expect(tmuxSendKeysBytes(['Down', 'Enter'], true, ours.length)).toEqual(ours)
213
+ })
214
+ test('literal digit + Enter: [2,Enter]', () => {
215
+ const ours = keysToBytes(['2', 'Enter'])
216
+ expect(tmuxSendKeysBytes(['2', 'Enter'], false, ours.length)).toEqual(ours)
217
+ })
218
+ test('bare Enter', () => {
219
+ const ours = keysToBytes(['Enter'])
220
+ expect(tmuxSendKeysBytes(['Enter'], false, ours.length)).toEqual(ours)
221
+ })
222
+ })
@@ -70,6 +70,55 @@ export function stripQueries(s: string): string {
70
70
  return s.replace(QUERY_RE, '')
71
71
  }
72
72
 
73
+ // ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
74
+ // Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
75
+ // boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
76
+ // the MODEL exactly as the launch primitive answers them via `tmux send-keys`. PURE — the byte
77
+ // output is unit-comparable to real `tmux send-keys` (the acceptance bar: byte-identity, not a
78
+ // "looks right" table).
79
+ //
80
+ // tmux semantics mirrored for the vocabulary our RuntimeAdapters actually emit (bootDialogKeys /
81
+ // permissionDialogKeys / executeControl): a leading `-l` switches to LITERAL mode (every following
82
+ // token sent verbatim, no key-name lookup); otherwise each token is a KEY NAME when known, else
83
+ // literal text. Cursor keys depend on DECCKM application-cursor-keys mode — `\x1b[B` (normal) vs
84
+ // `\x1bOB` (application) — which tmux keys off the terminal mode and the caller reads from the
85
+ // @xterm model (term.modes.applicationCursorKeysMode), passing it in `enc`. Both codex hooks-review
86
+ // (`['Down','Enter']`) and the claude resume-picker (`['Down']`) put a cursor key in the boot set,
87
+ // so the mode is load-bearing — a hardcoded `\x1b[B` would corrupt the key under DECCKM.
88
+
89
+ export interface KeyEncoding {
90
+ /** DECCKM application-cursor-keys mode (CSI ? 1 h). When set, arrow keys use SS3 (ESC O x)
91
+ * instead of CSI (ESC [ x), matching what tmux send-keys emits in that mode. */
92
+ appCursorKeys?: boolean
93
+ }
94
+
95
+ const NAMED_KEYS: Record<string, string> = {
96
+ Enter: '\r',
97
+ Escape: '\x1b',
98
+ Tab: '\t',
99
+ Space: ' ',
100
+ BSpace: '\x7f',
101
+ }
102
+
103
+ /** Translate `keys` (tmux send-keys vocabulary) to the pty byte stream. See section header. */
104
+ export function keysToBytes(keys: string[], enc: KeyEncoding = {}): Buffer {
105
+ const cur: Record<string, string> = enc.appCursorKeys
106
+ ? { Up: '\x1bOA', Down: '\x1bOB', Right: '\x1bOC', Left: '\x1bOD' }
107
+ : { Up: '\x1b[A', Down: '\x1b[B', Right: '\x1b[C', Left: '\x1b[D' }
108
+ let literal = false
109
+ let out = ''
110
+ for (const k of keys) {
111
+ if (!literal && k === '-l') {
112
+ literal = true
113
+ continue
114
+ }
115
+ if (!literal && k in NAMED_KEYS) out += NAMED_KEYS[k]
116
+ else if (!literal && k in cur) out += cur[k]
117
+ else out += k
118
+ }
119
+ return Buffer.from(out, 'utf8')
120
+ }
121
+
73
122
  // ── port-dep #1: terminal reset on every client exit path ───────────────────────
74
123
  // The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
75
124
  // focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
@@ -0,0 +1,21 @@
1
+ // Spawn-flip Ф1 serving-wiring — resolveDaemonSelfArgv picks the right re-invocation for the detached
2
+ // supervisor daemon: a real on-disk module (bun/source) runs directly; an EMBEDDED module (a compiled
3
+ // `bun build --compile` standalone, whose self path is NOT on disk) re-invokes the installed binary
4
+ // through its `supervisor` verb. The bug this guards: a bare bunfs-path invocation mis-routes through
5
+ // the CLI (unknown verb → usage), the daemon never comes up, and a hosted launch falls back to tmux.
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { resolveDaemonSelfArgv } from './index.ts'
8
+
9
+ describe('resolveDaemonSelfArgv', () => {
10
+ test('source (self on disk) → re-run the module directly with the runtime (bun)', () => {
11
+ expect(resolveDaemonSelfArgv('/opt/homebrew/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts', true))
12
+ .toEqual(['/opt/homebrew/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts'])
13
+ })
14
+
15
+ test('compiled standalone (self NOT on disk = embedded /$bunfs) → installed binary + `supervisor` verb', () => {
16
+ expect(resolveDaemonSelfArgv('/Users/x/.local/bin/iapeer', '/$bunfs/root/src/supervisor/index.ts', false))
17
+ .toEqual(['/Users/x/.local/bin/iapeer', 'supervisor'])
18
+ // The caller appends `daemon <session> <runtime> --run-dir <dir>` → `iapeer supervisor daemon …`,
19
+ // which the CLI routes to runSupervisorCli('daemon', …). NOT a bare bunfs-path (→ usage).
20
+ })
21
+ })
@@ -144,6 +144,36 @@ describe('createComposerDeliveryQueue', () => {
144
144
  expect(failed.join('\n')).toMatch(/vanished|replaced/)
145
145
  })
146
146
 
147
+ test('async-correctness: concurrent enqueue with ASYNC predicates → each delivered exactly once, no dup/loss (Ф0b-3 3c-2)', async () => {
148
+ // The host-aware refactor makes shouldQueue/hasHumanInput/deliver awaitable. Inject ASYNC seams
149
+ // (a real yield window) and fire concurrent enqueues — the FIFO + per-identity drain guard must
150
+ // still deliver each envelope EXACTLY once (no race-doubled, no race-lost).
151
+ let held = true
152
+ const delivered: string[] = []
153
+ const q = createComposerDeliveryQueue({
154
+ pollMs: 5,
155
+ forceTimeoutMs: 2000,
156
+ shouldQueue: async () => { await sleep(4); return true },
157
+ hasHumanInput: async () => { await sleep(2); return held },
158
+ sessionToken: () => 'session-1:pane-1',
159
+ sessionAlive: () => true,
160
+ deliver: async (_target, envelope) => { await sleep(1); delivered.push(envelope); return ok(undefined) },
161
+ notifyFailed: () => { /* */ },
162
+ })
163
+
164
+ const acks = await Promise.all([
165
+ q.tryEnqueue(args('msg-A')),
166
+ q.tryEnqueue(args('msg-B')),
167
+ q.tryEnqueue(args('msg-C')),
168
+ ])
169
+ expect(acks.every(a => a?.ok)).toBe(true)
170
+ await sleep(40)
171
+ expect(delivered).toEqual([]) // all three held while busy
172
+ held = false
173
+ await sleep(80)
174
+ expect(delivered.slice().sort()).toEqual(['msg-A', 'msg-B', 'msg-C']) // each ONCE — no dup, no loss
175
+ })
176
+
147
177
  test('daemon close/restart failAll re-fails every queued-but-undelivered envelope', async () => {
148
178
  const delivered: string[] = []
149
179
  const failed: string[] = []
@@ -0,0 +1,87 @@
1
+ // Spawn-flip cutover Block 2, Ф0b-2 slice 3 — host-aware in-session control (interrupt / compact).
2
+ //
3
+ // executeControlOnTarget becomes host-aware: a supervisor-HOSTED target has no `tmux send-keys`, so
4
+ // each control plan step is translated to pty bytes by keysToBytes (REUSED from the boot-driver) and
5
+ // sent over the supervisor socket. These suites cover the host branch hermetically (a seam injects the
6
+ // host-detect + the socket control-send) — asserting the BYTE TRANSLATION + dispatch. The live
7
+ // "interrupt really interrupts a hosted turn" effect is proven separately against a real hosted codex.
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { executeControlOnTarget, type ControlHostSeam, type DeliveryTarget } from './index.ts'
10
+
11
+ const hostedCodex: DeliveryTarget = {
12
+ runtime: 'codex',
13
+ personality: 'ptyx',
14
+ address: 'codex-ptyx',
15
+ socketPath: '/tmp/nonexistent-iap-test.sock',
16
+ }
17
+
18
+ interface Captured {
19
+ session: string
20
+ chunks: Buffer[]
21
+ stepDelayMs?: number
22
+ }
23
+
24
+ function capturingSeam(captured: Captured[]): ControlHostSeam {
25
+ return {
26
+ hostAlive: () => true,
27
+ sendControl: async (_runDir, session, chunks, opts) => {
28
+ captured.push({ session, chunks, stepDelayMs: opts.stepDelayMs })
29
+ return { ok: true }
30
+ },
31
+ }
32
+ }
33
+
34
+ describe('executeControlOnTarget — hosted control over the socket (slice 3)', () => {
35
+ test('interrupt → a single bare ESC byte (\\x1b), no step delay', async () => {
36
+ const cap: Captured[] = []
37
+ const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, capturingSeam(cap))
38
+ expect(r.ok).toBe(true)
39
+ expect(cap).toHaveLength(1)
40
+ expect(cap[0].session).toBe('codex-ptyx')
41
+ expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['\x1b'])
42
+ expect(cap[0].stepDelayMs).toBeUndefined()
43
+ })
44
+
45
+ test('compact → "/compact" literal then Enter (CR), with the 300ms step delay', async () => {
46
+ const cap: Captured[] = []
47
+ const r = await executeControlOnTarget(hostedCodex, { name: 'compact' }, capturingSeam(cap))
48
+ expect(r.ok).toBe(true)
49
+ expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['/compact', '\r'])
50
+ expect(cap[0].stepDelayMs).toBe(300)
51
+ })
52
+
53
+ test('flag-off (hostAlive=false) → the socket control-send is NEVER touched (tmux path)', async () => {
54
+ let sendCalls = 0
55
+ const seam: ControlHostSeam = {
56
+ hostAlive: () => false,
57
+ sendControl: async () => {
58
+ sendCalls++
59
+ return { ok: true }
60
+ },
61
+ }
62
+ // tmux send-keys will fail against the bogus socket — incidental; the contract is that flag-off
63
+ // never reaches the host branch.
64
+ await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
65
+ expect(sendCalls).toBe(0)
66
+ })
67
+
68
+ test('socket control-send fails → loud error, no false ok', async () => {
69
+ const seam: ControlHostSeam = {
70
+ hostAlive: () => true,
71
+ sendControl: async () => ({ ok: false, error: 'socket dead during control send' }),
72
+ }
73
+ const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
74
+ expect(r.ok).toBe(false)
75
+ if (!r.ok) {
76
+ expect(r.error.message).toContain('hosted control "interrupt" failed')
77
+ expect(r.error.message).toContain('socket dead')
78
+ }
79
+ })
80
+
81
+ test('unsupported command for the runtime → explicit refusal (router runtime, no control plan)', async () => {
82
+ const router: DeliveryTarget = { runtime: 'telegram', personality: 'tg', address: 'telegram-tg', socketPath: '/tmp/x.sock' }
83
+ const r = await executeControlOnTarget(router, { name: 'interrupt' }, capturingSeam([]))
84
+ expect(r.ok).toBe(false)
85
+ if (!r.ok) expect(r.error.message).toContain('does not support control command')
86
+ })
87
+ })
@@ -0,0 +1,102 @@
1
+ // Spawn-flip cutover Block 2, Ф0b-2 — host-aware warm delivery (`deliverWarm`).
2
+ //
3
+ // The warm-deliver path becomes host-aware: a supervisor-HOSTED target is delivered over its socket
4
+ // (Ф0a leaf) and CONFIRMED by the SAME final arbiter as the tmux busy path — a transcript-mtime
5
+ // advance, NOT the socket-ack. These suites cover the host branch hermetically (injected seams: a
6
+ // fake host-detect + host-deliver + a transcript-mtime proxy) so no live supervisor daemon is needed.
7
+ // The flag-off (tmux) routing is asserted by the host seam staying UNTOUCHED — the byte-identical
8
+ // guarantee that lets this ship dark.
9
+ import { afterEach, describe, expect, test } from 'bun:test'
10
+ import { deliverWarm, type DeliveryTarget, type WarmDeliverSeam } from './index.ts'
11
+
12
+ const hostedTarget: DeliveryTarget = {
13
+ runtime: 'codex',
14
+ personality: 'ptyx',
15
+ address: 'codex-ptyx',
16
+ socketPath: '/tmp/nonexistent-iap-test.sock',
17
+ }
18
+
19
+ const savedGrace = process.env.IAP_HOST_LIVENESS_GRACE_MS
20
+ afterEach(() => {
21
+ if (savedGrace === undefined) delete process.env.IAP_HOST_LIVENESS_GRACE_MS
22
+ else process.env.IAP_HOST_LIVENESS_GRACE_MS = savedGrace
23
+ })
24
+
25
+ describe('deliverWarm — flag-off (no live supervisor session) keeps the tmux path', () => {
26
+ test('hostAlive=false → the host-deliver seam is NEVER touched (routes to deliverViaTmux)', async () => {
27
+ let hostedCalls = 0
28
+ const seam: WarmDeliverSeam = {
29
+ hostAlive: () => false,
30
+ deliverHosted: async () => {
31
+ hostedCalls++
32
+ return { ok: true }
33
+ },
34
+ }
35
+ // The tmux path will fail against a bogus socket — but that is incidental; the contract under
36
+ // test is that flag-off NEVER reaches the host branch. We assert the host seam stayed cold.
37
+ await deliverWarm(hostedTarget, 'hi', '/tmp', seam)
38
+ expect(hostedCalls).toBe(0)
39
+ })
40
+ })
41
+
42
+ describe('deliverWarm — hosted target delivers over the socket + confirms by transcript advance', () => {
43
+ test('socket deliver ok AND transcript advances → ok', async () => {
44
+ let mtime = 100
45
+ const seam: WarmDeliverSeam = {
46
+ hostAlive: () => true,
47
+ deliverHosted: async () => {
48
+ mtime = 200 // the session took our message and started a model turn → transcript moved
49
+ return { ok: true }
50
+ },
51
+ newestActivityMtime: () => mtime,
52
+ }
53
+ const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
54
+ expect(r.ok).toBe(true)
55
+ })
56
+
57
+ test('socket-ack is NOT landed: deliver ok but NO transcript advance → loud fail (treated as dead)', async () => {
58
+ process.env.IAP_HOST_LIVENESS_GRACE_MS = '0' // expire the HOST grace immediately — deterministic fail
59
+ let hostedCalls = 0
60
+ const seam: WarmDeliverSeam = {
61
+ hostAlive: () => true,
62
+ deliverHosted: async () => {
63
+ hostedCalls++
64
+ return { ok: true } // CR flushed to the socket — but the session never advanced
65
+ },
66
+ newestActivityMtime: () => 100, // constant → never advances past baseline
67
+ }
68
+ const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
69
+ expect(hostedCalls).toBe(1)
70
+ expect(r.ok).toBe(false)
71
+ if (!r.ok) expect(r.error.message).toContain('no transcript advance')
72
+ })
73
+
74
+ test('socket deliver fails (socket dead/stalled) → loud fail, no false ok', async () => {
75
+ const seam: WarmDeliverSeam = {
76
+ hostAlive: () => true,
77
+ deliverHosted: async () => ({ ok: false, error: 'socket dead during submit' }),
78
+ newestActivityMtime: () => 100,
79
+ }
80
+ const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
81
+ expect(r.ok).toBe(false)
82
+ if (!r.ok) {
83
+ expect(r.error.message).toContain('deliver failed')
84
+ expect(r.error.message).toContain('socket dead')
85
+ }
86
+ })
87
+
88
+ test('no cwd (direct caller, no activity proxy) → confirmed-only (socket-ack), no mtime probe', async () => {
89
+ let mtimeProbes = 0
90
+ const seam: WarmDeliverSeam = {
91
+ hostAlive: () => true,
92
+ deliverHosted: async () => ({ ok: true }),
93
+ newestActivityMtime: () => {
94
+ mtimeProbes++
95
+ return 100
96
+ },
97
+ }
98
+ const r = await deliverWarm(hostedTarget, 'task', undefined, seam)
99
+ expect(r.ok).toBe(true)
100
+ expect(mtimeProbes).toBe(0) // no cwd → the transcript proxy is never consulted
101
+ })
102
+ })