@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,120 @@
1
+ // Supervisor DELIVER client (cutover Block 2 spawn-flip, Ф0a) — hands a message to a supervisor-
2
+ // HOSTED session over its unix socket: the host equivalent of tmux load-buffer → paste-buffer -p →
3
+ // send-keys Enter. @xterm-FREE BY CONSTRUCTION — imports ONLY protocol.ts (pure frames + the proven
4
+ // BackpressureWriter) + paths.ts (no @xterm) — so launch can deliver to a hosted session WITHOUT
5
+ // pulling the @xterm daemon into the hot path. Bracketed-paste wraps the message (same intent as
6
+ // tmux paste-buffer -p: the TUI takes it as one atomic paste, not retyped keystrokes), then a CR
7
+ // submits — the SAME two-step the cold/warm tmux delivery uses. Backpressure-safe: a multi-KB
8
+ // envelope past the socket buffer is queued + flushed on drain, never truncated (the same failure
9
+ // mode that staled heavy reattach — protocol.ts port-dep #2).
10
+ import { BackpressureWriter, FRAME_DATA, frame } from './protocol.ts'
11
+ import { sockPath } from './paths.ts'
12
+ import { existsSync } from 'node:fs'
13
+
14
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
15
+
16
+ export interface DeliverResult {
17
+ ok: boolean
18
+ error?: string
19
+ }
20
+
21
+ /** Deliver `message` to a hosted session over its supervisor socket: connect → bracketed-paste →
22
+ * (settle) → CR → close. submitDelayMs mirrors the tmux boot delivery's 300 ms paste→Enter settle. */
23
+ export async function deliverToHost(
24
+ runDir: string,
25
+ session: string,
26
+ message: string,
27
+ opts: { submitDelayMs?: number } = {},
28
+ ): Promise<DeliverResult> {
29
+ const sock = sockPath(runDir, session)
30
+ if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
31
+ let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
32
+ let writer: BackpressureWriter | undefined
33
+ try {
34
+ conn = await Bun.connect({
35
+ unix: sock,
36
+ socket: {
37
+ drain() {
38
+ writer?.flush()
39
+ },
40
+ data() {
41
+ /* the daemon forwards screen output; the deliver client ignores it */
42
+ },
43
+ error() {
44
+ /* surfaced via the writer's dead state */
45
+ },
46
+ close() {
47
+ /* */
48
+ },
49
+ },
50
+ })
51
+ const c = conn
52
+ writer = new BackpressureWriter(b => c.write(b))
53
+ const paste = `\x1b[200~${message}\x1b[201~` // bracketed paste — atomic, like paste-buffer -p
54
+ if (!writer.send(frame(FRAME_DATA, Buffer.from(paste, 'utf8')))) return { ok: false, error: 'socket dead during paste' }
55
+ if (!(await drainFully(writer))) return { ok: false, error: 'paste not fully flushed (socket stalled)' }
56
+ await sleep(opts.submitDelayMs ?? 300)
57
+ if (!writer.send(frame(FRAME_DATA, Buffer.from('\r', 'utf8')))) return { ok: false, error: 'socket dead during submit' }
58
+ if (!(await drainFully(writer))) return { ok: false, error: 'submit not fully flushed (socket stalled)' }
59
+ await sleep(50) // let the CR land before we close the connection
60
+ return { ok: true }
61
+ } catch (e) {
62
+ return { ok: false, error: `deliver to host failed: ${(e as Error).message}` }
63
+ } finally {
64
+ try {
65
+ conn?.end()
66
+ } catch {
67
+ /* */
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Wait until the writer's queued tail has flushed (drain-driven), or time out. */
73
+ async function drainFully(writer: BackpressureWriter, timeoutMs = 4000): Promise<boolean> {
74
+ const end = Date.now() + timeoutMs
75
+ while (writer.queuedBytes > 0 && !writer.isDead && Date.now() < end) await sleep(20)
76
+ return writer.queuedBytes === 0 && !writer.isDead
77
+ }
78
+
79
+ /**
80
+ * Send raw CONTROL bytes to a hosted session over its supervisor socket — the host equivalent of
81
+ * `tmux send-keys` for the control channel (interrupt / compact). Each chunk is ALREADY the exact
82
+ * keystrokes (translated from the send-keys vocabulary by keysToBytes upstream), sent as a FRAME_DATA
83
+ * write — NOT bracketed-pasted and NOT auto-submitted: an Escape must arrive as a bare ESC, never a
84
+ * pasted literal. `stepDelayMs` paces a multi-step plan after each step, mirroring executeControl's
85
+ * tmux loop (e.g. compact's '/compact' then a 300 ms settle then Enter). Backpressure-safe per step.
86
+ */
87
+ export async function sendControlToHost(
88
+ runDir: string,
89
+ session: string,
90
+ chunks: Buffer[],
91
+ opts: { stepDelayMs?: number } = {},
92
+ ): Promise<DeliverResult> {
93
+ const sock = sockPath(runDir, session)
94
+ if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
95
+ let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
96
+ let writer: BackpressureWriter | undefined
97
+ try {
98
+ conn = await Bun.connect({
99
+ unix: sock,
100
+ socket: { drain() { writer?.flush() }, data() { /* */ }, error() { /* */ }, close() { /* */ } },
101
+ })
102
+ const c = conn
103
+ writer = new BackpressureWriter(b => c.write(b))
104
+ for (const chunk of chunks) {
105
+ if (!writer.send(frame(FRAME_DATA, chunk))) return { ok: false, error: 'socket dead during control send' }
106
+ if (!(await drainFully(writer))) return { ok: false, error: 'control not fully flushed (socket stalled)' }
107
+ if (opts.stepDelayMs) await sleep(opts.stepDelayMs)
108
+ }
109
+ await sleep(50) // let the last chunk land before we close the connection
110
+ return { ok: true }
111
+ } catch (e) {
112
+ return { ok: false, error: `control to host failed: ${(e as Error).message}` }
113
+ } finally {
114
+ try {
115
+ conn?.end()
116
+ } catch {
117
+ /* */
118
+ }
119
+ }
120
+ }
@@ -5,16 +5,35 @@
5
5
  // serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
6
6
  // binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
7
7
  import { spawn as nodeSpawn } from 'node:child_process'
8
- import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
8
+ import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
- import { runSupervisorDaemon } from './daemon.ts'
11
10
  import { runSupervisorClient, sessionAlive } from './client.ts'
12
- import { defaultRunDir, logPath, pidPath, sockPath } from './paths.ts'
11
+ import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
13
12
 
14
- export { runSupervisorDaemon } from './daemon.ts'
13
+ // daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
14
+ // daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
15
+ // startSupervisorDaemon to host a flag-on peer — never pulls @xterm into the launch hot path.
15
16
  export { runSupervisorClient, sessionAlive } from './client.ts'
16
17
  export { defaultRunDir, sockPath, pidPath } from './paths.ts'
17
18
 
19
+ /** The composed invocation a detached daemon serves (slice b): buildLaunchInvocation output + the
20
+ * resolved cwd. Serialized to `<runDir>/<session>.serve.json` (0600) so the detached daemon process
21
+ * picks it up — the seam by which launch hands the supervisor a real session at the spawn-flip. */
22
+ export interface ServeSpec {
23
+ argv: string[]
24
+ env: Record<string, string>
25
+ cwd: string
26
+ /** Pane-log path (slice c): `<logDir>/<identity>.log` — where the daemon appends the child's raw
27
+ * output, byte-identical to tmux pipe-pane, so the observer/shadow read a served session's log. */
28
+ paneLogPath?: string
29
+ /** Exit-cause log (spawn-flip Ф0b): exits.log — the daemon appends the child's death cause here. */
30
+ exitLogPath?: string
31
+ /** Pty geometry (spawn-flip Ф0a): match the tmux launch's 220x50 so the served child + the pane-log
32
+ * model render at the SAME size launch reads readiness at. Unset → daemon default (80x30). */
33
+ cols?: number
34
+ rows?: number
35
+ }
36
+
18
37
  export interface StartDaemonOptions {
19
38
  session: string
20
39
  runtime: string
@@ -23,10 +42,24 @@ export interface StartDaemonOptions {
23
42
  * `daemon <session> <runtime> --run-dir <runDir>` is appended. Serving-prod injects the
24
43
  * installed-binary argv here. */
25
44
  daemonArgv?: string[]
45
+ /** Composed invocation to SERVE (slice b). When set, written to the serve-spec file before spawn so
46
+ * the detached daemon serves it; absent → a bare conduit (throwaway/tick). */
47
+ serve?: ServeSpec
26
48
  /** Max ms to wait for the daemon socket+pid to appear (default 5000). */
27
49
  timeoutMs?: number
28
50
  }
29
51
 
52
+ /** Read the serve-spec for a detached daemon, or undefined for a bare session. */
53
+ function readServeSpec(runDir: string, session: string): ServeSpec | undefined {
54
+ try {
55
+ const s = JSON.parse(readFileSync(servePath(runDir, session), 'utf8')) as ServeSpec
56
+ if (Array.isArray(s.argv) && s.env && typeof s.cwd === 'string') return s
57
+ } catch {
58
+ /* no / invalid serve spec → bare session */
59
+ }
60
+ return undefined
61
+ }
62
+
30
63
  export interface StartDaemonResult {
31
64
  state: 'started' | 'already-running' | 'failed'
32
65
  pid?: number
@@ -45,6 +78,15 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
45
78
  } catch {
46
79
  /* */
47
80
  }
81
+ // Serving seam (slice b): persist the composed invocation BEFORE spawn so the detached daemon serves
82
+ // it. 0600 — env may carry launch.env secrets. A stale spec from a prior run is overwritten or cleared.
83
+ if (opts.serve) writeFileSync(servePath(runDir, session), JSON.stringify(opts.serve), { mode: 0o600 })
84
+ else
85
+ try {
86
+ unlinkSync(servePath(runDir, session))
87
+ } catch {
88
+ /* no stale spec */
89
+ }
48
90
  const out = openSync(logPath(runDir, session), 'a')
49
91
  const argv = opts.daemonArgv ?? selfArgv()
50
92
  const child = nodeSpawn(argv[0]!, [...argv.slice(1), 'daemon', session, runtime, '--run-dir', runDir], {
@@ -59,6 +101,13 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
59
101
  }
60
102
  Bun.sleepSync(80)
61
103
  }
104
+ // Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
105
+ if (opts.serve)
106
+ try {
107
+ unlinkSync(servePath(runDir, session))
108
+ } catch {
109
+ /* */
110
+ }
62
111
  return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
63
112
  }
64
113
 
@@ -96,13 +145,34 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
96
145
  const [cmd, a, b] = argv
97
146
  const runDir = runDirFrom(argv)
98
147
  switch (cmd) {
99
- case 'daemon':
100
- runSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
148
+ case 'daemon': {
149
+ // Serving seam (slice b): a serve-spec → serve the launch-composed argv/env/cwd; else bare.
150
+ const serve = readServeSpec(runDir, a!)
151
+ // @xterm loads HERE — in the detached daemon process only (dynamic import keeps it out of any
152
+ // caller that merely resolves startSupervisorDaemon, e.g. the launch hot path).
153
+ const { runSupervisorDaemon } = await import('./daemon.ts')
154
+ runSupervisorDaemon({
155
+ session: a!,
156
+ runtime: b || 'claude',
157
+ runDir,
158
+ ...(serve
159
+ ? {
160
+ argv: serve.argv,
161
+ env: serve.env,
162
+ cwd: serve.cwd,
163
+ paneLogPath: serve.paneLogPath,
164
+ exitLogPath: serve.exitLogPath,
165
+ cols: serve.cols,
166
+ rows: serve.rows,
167
+ }
168
+ : {}),
169
+ })
101
170
  // The daemon is event-driven: runSupervisorDaemon RETURNS after wiring the pty + socket
102
171
  // server, and the event loop keeps the process alive. Await a never-resolving promise so the
103
172
  // caller's `process.exit` does NOT fire — shutdown() (child-exit / SIGTERM) owns the exit.
104
173
  await new Promise<never>(() => {})
105
174
  return 0 // unreachable
175
+ }
106
176
  case 'start': {
107
177
  const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
108
178
  console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
@@ -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).
@@ -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
+ })