@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,84 @@
1
+ // Spawn-flip cutover Block 2, Ф0b-3 slice 3c-1 — the supervisor `.attached` marker (the gate signal
2
+ // `hasAttachedSupervisorClient` reads to hold hosted delivery ONLY for a real attached human).
3
+ // boris's three faces: (1) present ⟺ clients.size>0; (2) ALL detach paths incl. ABNORMAL (abrupt
4
+ // terminate → error/close), not just a clean Ctrl-]; (3) CRASH-ROBUSTNESS — a crashed predecessor
5
+ // skips shutdown-cleanup, so a stale marker must be cleared on daemon START. Real detached daemon over
6
+ // a real unix socket (tick runtime — no codex needed); the marker is read CROSS-PROCESS, as in prod.
7
+ import { afterEach, describe, expect, test } from 'bun:test'
8
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { frame, FRAME_RESIZE, sizePayload } from './protocol.ts'
12
+ import { killSession, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
13
+ import { attachedPath } from './paths.ts'
14
+
15
+ const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
16
+ const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
17
+ const d = bunPty ? describe : describe.skip
18
+
19
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
20
+ async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 40): Promise<boolean> {
21
+ const end = Date.now() + timeoutMs
22
+ while (Date.now() < end) { if (pred()) return true; await sleep(step) }
23
+ return false
24
+ }
25
+
26
+ interface Client { end(): void; terminate(): void }
27
+ async function attach(runDir: string, session: string): Promise<Client> {
28
+ const sock = await Bun.connect({
29
+ unix: sockPath(runDir, session),
30
+ socket: { open(s) { s.write(frame(FRAME_RESIZE, sizePayload(80, 24))) }, data() { /* */ } },
31
+ })
32
+ return {
33
+ end: () => { try { sock.end() } catch { /* */ } },
34
+ terminate: () => { try { sock.terminate() } catch { /* */ } },
35
+ }
36
+ }
37
+
38
+ const dirs: string[] = []
39
+ const mkRunDir = (): string => { const d2 = mkdtempSync(join(tmpdir(), 'iapeer-attmark-')); dirs.push(d2); return d2 }
40
+ afterEach(() => { for (const d2 of dirs.splice(0)) rmSync(d2, { recursive: true, force: true }) })
41
+
42
+ d('supervisor .attached marker (the hosted occupancy gate signal)', () => {
43
+ test('present ⟺ clients.size>0 across multiple clients (no stuck "attached" on partial detach)', async () => {
44
+ const runDir = mkRunDir(), session = 'mk'
45
+ startSupervisorDaemon({ session, runtime: 'tick', runDir })
46
+ expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
47
+ const marker = attachedPath(runDir, session)
48
+ expect(existsSync(marker)).toBe(false) // no client yet → absent
49
+ const c1 = await attach(runDir, session)
50
+ expect(await waitFor(() => existsSync(marker))).toBe(true) // ≥1 attached → present
51
+ const c2 = await attach(runDir, session)
52
+ await sleep(150)
53
+ expect(existsSync(marker)).toBe(true) // 2 attached → still present
54
+ c1.end()
55
+ await sleep(250)
56
+ expect(existsSync(marker)).toBe(true) // 1 still attached → NOT stuck-absent, NOT removed early
57
+ c2.end()
58
+ expect(await waitFor(() => !existsSync(marker))).toBe(true) // 0 attached → absent
59
+ killSession(runDir, session)
60
+ })
61
+
62
+ test('ABNORMAL detach (abrupt terminate, not a clean Ctrl-]) → marker still removed', async () => {
63
+ const runDir = mkRunDir(), session = 'ab'
64
+ startSupervisorDaemon({ session, runtime: 'tick', runDir })
65
+ expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
66
+ const marker = attachedPath(runDir, session)
67
+ const c = await attach(runDir, session)
68
+ expect(await waitFor(() => existsSync(marker))).toBe(true)
69
+ c.terminate() // abrupt RST — daemon sees error/close, NOT a graceful protocol detach
70
+ expect(await waitFor(() => !existsSync(marker))).toBe(true) // the error/close path ALSO syncs the marker
71
+ killSession(runDir, session)
72
+ })
73
+
74
+ test('CRASH-ROBUSTNESS: a stale marker is cleared on daemon START (crash skips shutdown-cleanup)', async () => {
75
+ const runDir = mkRunDir(), session = 'cr'
76
+ const marker = attachedPath(runDir, session)
77
+ writeFileSync(marker, '') // a crashed predecessor left this present, with no live client
78
+ expect(existsSync(marker)).toBe(true)
79
+ startSupervisorDaemon({ session, runtime: 'tick', runDir }) // fresh daemon = 0 clients
80
+ expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
81
+ expect(await waitFor(() => !existsSync(marker))).toBe(true) // start-cleanup removed the stale lie
82
+ killSession(runDir, session)
83
+ })
84
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { codexAdapter } from '../launch/adapters/codex.ts'
3
+ import { claudeAdapter } from '../launch/adapters/claude.ts'
4
+ import { nextBootAction, type BootAction } from './boot.ts'
5
+
6
+ // Fed the REAL runtime adapters (codexAdapter/claudeAdapter) — not fakes — so the test proves the
7
+ // supervisor answers the exact dialogs the launch primitive answers, with byte-correct keys.
8
+ const bytesOf = (a: BootAction): Buffer => (a.kind === 'dialog' ? a.bytes : Buffer.alloc(0))
9
+
10
+ describe('nextBootAction — codex startup dialogs answered off the model', () => {
11
+ test('dir-trust → Enter (CR)', () => {
12
+ const a = nextBootAction(codexAdapter, 'Do you trust the contents of this directory?\n 1. Yes, continue')
13
+ expect(a.kind).toBe('dialog')
14
+ expect(bytesOf(a)).toEqual(Buffer.from('\r'))
15
+ })
16
+
17
+ test('update offer → decline [2,Enter], and NOT ready while "Press enter to continue" is up', () => {
18
+ const pane = 'Update available! 9.9.9\nPress enter to continue'
19
+ const a = nextBootAction(codexAdapter, pane)
20
+ expect(a.kind).toBe('dialog')
21
+ expect(bytesOf(a)).toEqual(Buffer.from('2\r'))
22
+ })
23
+
24
+ test('hooks-review → [Down,Enter]; the cursor byte follows the model cursor mode', () => {
25
+ const pane = 'Hooks need review\n 1. Review 2. Trust all and continue'
26
+ expect(bytesOf(nextBootAction(codexAdapter, pane))).toEqual(Buffer.from('\x1b[B\r')) // normal
27
+ expect(bytesOf(nextBootAction(codexAdapter, pane, { appCursorKeys: true }))).toEqual(Buffer.from('\x1bOB\r')) // DECCKM
28
+ })
29
+
30
+ test('composer rendered, no startup screen → ready', () => {
31
+ expect(nextBootAction(codexAdapter, 'a rotating tip line\n› ').kind).toBe('ready')
32
+ })
33
+
34
+ test('splash up, composer not yet rendered → wait', () => {
35
+ expect(nextBootAction(codexAdapter, 'OpenAI Codex\n(loading session)').kind).toBe('wait')
36
+ })
37
+ })
38
+
39
+ describe('nextBootAction — claude startup dialogs answered off the model', () => {
40
+ test('folder-trust → Enter (CR)', () => {
41
+ const a = nextBootAction(claudeAdapter, 'Do you trust this folder?\n 1. Yes')
42
+ expect(a.kind).toBe('dialog')
43
+ expect(bytesOf(a)).toEqual(Buffer.from('\r'))
44
+ })
45
+
46
+ test('resume-picker before the cursor reaches option 2 → Down (mode-correct cursor byte)', () => {
47
+ const pane = 'Resume from summary\n ❯ 1. Start fresh\n 2. Resume full session'
48
+ const a = nextBootAction(claudeAdapter, pane)
49
+ expect(a.kind).toBe('dialog')
50
+ expect(bytesOf(a)).toEqual(Buffer.from('\x1b[B')) // normal cursor mode
51
+ expect(bytesOf(nextBootAction(claudeAdapter, pane, { appCursorKeys: true }))).toEqual(Buffer.from('\x1bOB'))
52
+ })
53
+
54
+ test('resume-picker once the cursor IS on option 2 → Enter (never re-Downs past the proceed row)', () => {
55
+ const pane = 'Resume from summary\n 1. Start fresh\n ❯ 2. Resume full session'
56
+ expect(bytesOf(nextBootAction(claudeAdapter, pane))).toEqual(Buffer.from('\r'))
57
+ })
58
+
59
+ test('input row + bypass banner → ready', () => {
60
+ expect(nextBootAction(claudeAdapter, '❯ \n bypass permissions on').kind).toBe('ready')
61
+ })
62
+
63
+ test('splash up, input row not yet rendered → wait', () => {
64
+ expect(nextBootAction(claudeAdapter, 'Welcome to Claude Code\n (starting)').kind).toBe('wait')
65
+ })
66
+ })
@@ -0,0 +1,34 @@
1
+ // Supervisor BOOT-DRIVER decision (serving slice a) — the pure half of "answer the startup dialogs
2
+ // off the model". A real codex/claude TUI stalls on trust/update/hooks/resume modals; under tmux the
3
+ // launch primitive (index.ts boot loop, 384-411) answers them via capture-pane (read) + send-keys
4
+ // (write). The supervisor reads its authoritative @xterm model (modelToPlainText) and writes raw pty
5
+ // bytes (keysToBytes) — same decision, different surfaces. This module is the DECISION; daemon.ts
6
+ // owns the loop (render → decide → write). PURE: no @xterm, no pty — feed it a viewport string + the
7
+ // runtime's boot predicates and it returns the bytes to send, so it is unit-testable against the
8
+ // real adapters.
9
+ import { keysToBytes, type KeyEncoding } from './protocol.ts'
10
+
11
+ /** The slice of a RuntimeAdapter the boot-driver consumes. codexAdapter / claudeAdapter satisfy it
12
+ * structurally, so boot.ts stays decoupled from launch/types.ts. */
13
+ export interface BootPredicates {
14
+ bootDialogKeys(pane: string): string[] | null
15
+ isInputReady(pane: string): boolean
16
+ }
17
+
18
+ export type BootAction =
19
+ | { kind: 'dialog'; keys: string[]; bytes: Buffer }
20
+ | { kind: 'ready' }
21
+ | { kind: 'wait' }
22
+
23
+ /**
24
+ * One boot step, mirroring the launch primitive's order (index.ts:384-411): a visible startup dialog
25
+ * is answered FIRST (its keys clear it; bytes encoded for the model's current cursor mode), ELSE the
26
+ * input surface is checked for readiness, ELSE wait for the next frame. The caller loops this on a
27
+ * cadence, writing `action.bytes` to the pty on a 'dialog' and stopping on 'ready'.
28
+ */
29
+ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: KeyEncoding = {}): BootAction {
30
+ const keys = adapter.bootDialogKeys(viewport)
31
+ if (keys && keys.length) return { kind: 'dialog', keys, bytes: keysToBytes(keys, enc) }
32
+ if (adapter.isInputReady(viewport)) return { kind: 'ready' }
33
+ return { kind: 'wait' }
34
+ }
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
- import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { FRAME_DATA, FRAME_RESIZE, frame, makeFramer, sizePayload } from './protocol.ts'
@@ -109,4 +109,69 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
109
109
  expect(fresh).toBe(true)
110
110
  second.close()
111
111
  })
112
+
113
+ // Serving seam (slice b): a serve-spec is written before spawn, the detached daemon reads it, and
114
+ // the composed argv + env + cwd reach the child VERBATIM (with the pty-host TERM layered on top and
115
+ // TMUX stripped). A probe child dumps the markers it actually received — proving the supervisor
116
+ // serves a launch-composed invocation faithfully (the parity foundation; the env-identity ABI rides
117
+ // in here at serving time).
118
+ test('serving seam: composed argv + env + cwd reach the child verbatim (slice b)', async () => {
119
+ const servingSession = 'ptsserve'
120
+ const childCwd = realpathSync(mkdtempSync(join(tmpdir(), 'iapeer-serve-cwd-')))
121
+ const out = join(runDir, 'serve-probe.out')
122
+ try {
123
+ const serve = {
124
+ // dump what the child ACTUALLY received: a composed env var, its cwd, TERM, and TMUX-presence
125
+ argv: [
126
+ 'sh',
127
+ '-c',
128
+ 'printf "%s\\n%s\\n%s\\n%s\\n" "$SERVE_MARKER" "$(pwd -P)" "$TERM" "${TMUX:-no-tmux}" > "$OUTFILE"; exec sleep 30',
129
+ ],
130
+ env: { SERVE_MARKER: 'identity-abi-xyz', OUTFILE: out, PATH: process.env.PATH ?? '', TMUX: 'should-be-stripped' },
131
+ cwd: childCwd,
132
+ }
133
+ const r = startSupervisorDaemon({ session: servingSession, runtime: 'claude', runDir, serve })
134
+ expect(r.state).toBe('started')
135
+ const wrote = await waitFor(() => existsSync(out) && readFileSync(out, 'utf8').trim().split('\n').length >= 4)
136
+ expect(wrote).toBe(true)
137
+ const [marker, pwd, term, tmux] = readFileSync(out, 'utf8').split('\n')
138
+ expect(marker).toBe('identity-abi-xyz') // composed env reached the child verbatim
139
+ expect(pwd).toBe(childCwd) // composed cwd honored
140
+ expect(term).toBe('xterm-256color') // pty-host TERM layered on top of the composed env
141
+ expect(tmux).toBe('no-tmux') // TMUX stripped — a served child never thinks it is inside tmux
142
+ } finally {
143
+ killSession(runDir, servingSession)
144
+ rmSync(childCwd, { recursive: true, force: true })
145
+ }
146
+ })
147
+
148
+ // Exit-cause (spawn-flip Ф0b-1 resilience-b): when the hosted child dies, the daemon records a
149
+ // `ev=session-exit … host=supervisor` line in exits.log — the cause the supervise-tick death
150
+ // classification + crash-loop accounting need (mirror of the tmux pane-died hook). Kill the child,
151
+ // assert the line lands.
152
+ test('exit-cause: the daemon records the hosted child death in exits.log (resilience-b)', async () => {
153
+ const exSession = 'ptsexit'
154
+ const exitLog = join(runDir, 'exits.log')
155
+ const childPidFile = join(runDir, 'exit-child.pid')
156
+ try {
157
+ const r = startSupervisorDaemon({
158
+ session: exSession,
159
+ runtime: 'tick',
160
+ runDir,
161
+ serve: { argv: ['sh', '-c', `echo $$ > '${childPidFile}'; exec sleep 30`], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, exitLogPath: exitLog },
162
+ })
163
+ expect(r.state).toBe('started')
164
+ const got = await waitFor(() => existsSync(childPidFile))
165
+ expect(got).toBe(true)
166
+ process.kill(Number(readFileSync(childPidFile, 'utf8').trim()), 'SIGKILL')
167
+ const recorded = await waitFor(() => existsSync(exitLog) && /ev=session-exit identity=ptsexit.*host=supervisor/.test(readFileSync(exitLog, 'utf8')))
168
+ expect(recorded).toBe(true) // the death cause is in exits.log → crash-loop accounting has it
169
+ } finally {
170
+ try {
171
+ killSession(runDir, exSession)
172
+ } catch {
173
+ /* */
174
+ }
175
+ }
176
+ })
112
177
  })
@@ -9,7 +9,8 @@
9
9
  // `bun build --compile` (pure JS; the Bun-native pty has no native addon — PoC-proven).
10
10
  import { Terminal } from '@xterm/headless'
11
11
  import { SerializeAddon } from '@xterm/addon-serialize'
12
- import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
12
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
13
14
  import {
14
15
  BackpressureWriter,
15
16
  FRAME_DATA,
@@ -20,7 +21,11 @@ import {
20
21
  parseSize,
21
22
  stripQueries,
22
23
  } from './protocol.ts'
23
- import { pidPath, sockPath } from './paths.ts'
24
+ import { attachedPath, pidPath, servePath, sockPath } from './paths.ts'
25
+ import { nextBootAction, type BootPredicates } from './boot.ts'
26
+ import { modelToPlainText } from '../shadow/render.ts'
27
+ import { codexAdapter } from '../launch/adapters/codex.ts'
28
+ import { claudeAdapter } from '../launch/adapters/claude.ts'
24
29
 
25
30
  /** Known runtime launchers; `tick` is a deterministic test stream (no TUI noise) for hermetic
26
31
  * spawn/attach/reattach tests — it never needs a real claude/codex on the box. */
@@ -30,6 +35,20 @@ const KNOWN: Record<string, string> = {
30
35
  }
31
36
  const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n" "$i"; i=$((i+1)); sleep 0.3; done']
32
37
 
38
+ // Boot-driver (serving slice a): the runtimes whose startup dialogs the supervisor knows how to
39
+ // answer off its model. 'tick' (and any unknown runtime) has no entry → no driver → the hermetic
40
+ // spawn/attach tests and the bare conduit are untouched.
41
+ const BOOT_ADAPTERS: Record<string, BootPredicates> = { codex: codexAdapter, claude: claudeAdapter }
42
+ // Poll cadence mirrors the launch primitive's proven 2 s boot loop (index.ts:377). The supervisor
43
+ // model is strictly FRESHER than capture-pane (bytes land in @xterm synchronously as they arrive),
44
+ // so 2 s leaves ample repaint time — the claude resume-picker never over-Downs, since bootDialogKeys
45
+ // returns Enter (not another Down) the moment the moved cursor renders '❯ 2.'. A swallowed key
46
+ // self-heals on the next tick (re-sent while the dialog is still up), same as the launch loop.
47
+ const BOOT_POLL_MS = 2000
48
+ // Stop driving after this long even if readiness is never observed (a hung/unknown startup); the
49
+ // driver is best-effort and never blocks the conduit, which keeps serving regardless.
50
+ const BOOT_DRIVE_MS = 120_000
51
+
33
52
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
34
53
  interface PtyChild {
35
54
  terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
@@ -44,6 +63,22 @@ export interface SupervisorDaemonOptions {
44
63
  cwd?: string
45
64
  /** Override the runtime launcher (else KNOWN[runtime] if it exists, else the bare name). */
46
65
  bin?: string
66
+ /** SERVING SEAM (slice b): the composed runtime argv (buildLaunchInvocation). When set, the daemon
67
+ * serves THIS verbatim instead of the bare [bin] — the supervisor is a HOST, not a composer
68
+ * (launch/lifecycle composes argv+env; the supervisor, like tmux, just serves the invocation). */
69
+ argv?: string[]
70
+ /** SERVING SEAM (slice b): the composed child env (buildLaunchInvocation) — base for the child.
71
+ * The pty-host env (TERM set, TMUX/TMUX_PANE stripped) is layered ON TOP. Unset → process.env. */
72
+ env?: Record<string, string>
73
+ /** PANE-LOG (slice c): absolute path the child's RAW output bytes are appended to — `<logDir>/
74
+ * <identity>.log`, the SAME file + append semantics tmux `pipe-pane` writes today, so the observer
75
+ * / shadow / readyGateViewport tail a served session's log identically. Unset → no pane-log
76
+ * (throwaway/tick). */
77
+ paneLogPath?: string
78
+ /** EXIT-CAUSE (spawn-flip Ф0b resilience-b): exits.log path. On child exit the daemon appends a
79
+ * `ev=session-exit … dead_status/dead_signal host=supervisor` line (mirroring the tmux pane-died
80
+ * hook), so the supervise-tick death classification + crash-loop accounting have the cause. */
81
+ exitLogPath?: string
47
82
  cols?: number
48
83
  rows?: number
49
84
  }
@@ -63,14 +98,53 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
63
98
  xterm.loadAddon(ser)
64
99
 
65
100
  const bin = opts.bin ?? (KNOWN[runtime] && existsSync(KNOWN[runtime]!) ? KNOWN[runtime]! : runtime)
66
- const CMD = runtime === 'tick' ? TICK_CMD : [bin]
67
- const env = { ...process.env, TERM: 'xterm-256color' } as Record<string, string>
101
+ // SERVING SEAM (slice b): opts.argv (launch-composed, carries the system-prompt swap + PEER_START_ARGS)
102
+ // is served verbatim; else the bare conduit ([bin], or the deterministic tick stream for tests).
103
+ const CMD = opts.argv ?? (runtime === 'tick' ? TICK_CMD : [bin])
104
+ // pty-host env layered ON TOP of the composed child env (opts.env, = buildLaunchInvocation): force a
105
+ // real TERM and strip TMUX/TMUX_PANE so a served child never believes it is inside tmux. The identity
106
+ // ABI (PEER_IDENTITY, IAPEER_BEARER, …) rides in opts.env underneath, untouched.
107
+ const env = { ...(opts.env ?? process.env), TERM: 'xterm-256color' } as Record<string, string>
68
108
  delete env.TMUX
69
109
  delete env.TMUX_PANE
70
110
 
111
+ // PANE-LOG (slice c): append the child's RAW output bytes to <logDir>/<identity>.log, byte-identical
112
+ // to tmux `pipe-pane … 'cat >> <log>'`. The fd is opened once (append); every chunk is written as a
113
+ // raw Buffer (writeSync(fd, buf)) — NEVER via a string (Bun re-encodes a latin1 string to UTF-8 and
114
+ // corrupts multibyte glyphs like ❯/›; the canonical latin1/Buffer grabli). Parent dir 0700, matching
115
+ // launch's mkdir before pipe-pane. Unset paneLogPath → no log (throwaway/tick).
116
+ let paneLogFd = -1
117
+ if (opts.paneLogPath) {
118
+ try {
119
+ mkdirSync(dirname(opts.paneLogPath), { recursive: true, mode: 0o700 })
120
+ paneLogFd = openSync(opts.paneLogPath, 'a')
121
+ } catch {
122
+ paneLogFd = -1 // best-effort: a log open hiccup never blocks serving the session
123
+ }
124
+ }
125
+
71
126
  const clients = new Set<unknown>()
72
127
  const writers = new WeakMap<object, BackpressureWriter>()
73
128
  const framers = new WeakMap<object, (chunk: Uint8Array) => void>()
129
+ // ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
130
+ // clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
131
+ // reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
132
+ // composer text), never for the AI's own composer output when no one is attached. Best-effort: a
133
+ // marker fs hiccup must never break the conduit.
134
+ const attachedMarker = attachedPath(runDir, session)
135
+ const syncAttached = (): void => {
136
+ try {
137
+ if (clients.size > 0) writeFileSync(attachedMarker, '')
138
+ else if (existsSync(attachedMarker)) unlinkSync(attachedMarker)
139
+ } catch {
140
+ /* */
141
+ }
142
+ }
143
+ // CRASH-ROBUSTNESS (boris): a crashed predecessor daemon skips shutdown-cleanup, so a STALE
144
+ // `.attached` could survive into this fresh daemon — and a stale-present marker would make the
145
+ // warm-deliver gate believe a human is attached when none is, holding deliveries forever (the
146
+ // anti-bug inverted). A fresh daemon has ZERO clients, so clear the marker on START (idempotent).
147
+ syncAttached()
74
148
  const writerFor = (s: { write(b: Buffer): number }): BackpressureWriter => {
75
149
  let w = writers.get(s as object)
76
150
  if (!w) {
@@ -96,13 +170,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
96
170
  name: 'xterm-256color',
97
171
  data(_t: unknown, d: Uint8Array | string) {
98
172
  const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
173
+ // PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
174
+ // for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
175
+ if (paneLogFd >= 0)
176
+ try {
177
+ writeSync(paneLogFd, buf)
178
+ } catch {
179
+ /* best-effort */
180
+ }
99
181
  const s = buf.toString('latin1')
100
182
  xterm.write(buf) // authoritative model
101
183
  for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
102
184
  const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
103
185
  if (fwd.length) for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
104
186
  },
105
- exit() {
187
+ exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
188
+ // resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log,
189
+ // mirroring the tmux pane-died hook (ev=session-exit dead_status/dead_signal), so the daemon's
190
+ // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
191
+ // distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
192
+ // onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
193
+ if (opts.exitLogPath) {
194
+ try {
195
+ mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) // ensure the exits.log dir exists
196
+ const c = child as { exitCode?: number | null; signalCode?: number | null }
197
+ const status = exitCode ?? c.exitCode ?? ''
198
+ const signal = signalCode ?? c.signalCode ?? ''
199
+ appendFileSync(
200
+ opts.exitLogPath,
201
+ `ts=${new Date().toISOString()} ev=session-exit identity=${session} dead_status=${status} dead_signal=${signal} host=supervisor\n`,
202
+ )
203
+ } catch {
204
+ /* best-effort */
205
+ }
206
+ }
106
207
  shutdown()
107
208
  },
108
209
  },
@@ -152,21 +253,24 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
152
253
  open(s: { write(b: Buffer): number }) {
153
254
  framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
154
255
  clients.add(s)
256
+ syncAttached() // ≥1 client now attached → marker present
155
257
  repaint(s) // UNCONDITIONAL repaint on attach (tmux-like)
156
258
  },
157
259
  data(s: object, d: Uint8Array) {
158
260
  framers.get(s)?.(d)
159
261
  },
160
262
  drain(s: object) {
161
- if (!writers.get(s)?.flush()) clients.delete(s) // writable again → flush queued tail
263
+ if (!writers.get(s)?.flush()) { clients.delete(s); syncAttached() } // writable again → flush queued tail
162
264
  },
163
265
  close(s: object) {
164
266
  clients.delete(s)
165
267
  writers.delete(s)
268
+ syncAttached()
166
269
  },
167
270
  error(s: object) {
168
271
  clients.delete(s)
169
272
  writers.delete(s)
273
+ syncAttached()
170
274
  },
171
275
  },
172
276
  })
@@ -176,6 +280,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
176
280
  function shutdown(): void {
177
281
  if (down) return
178
282
  down = true
283
+ if (paneLogFd >= 0)
284
+ try {
285
+ closeSync(paneLogFd)
286
+ } catch {
287
+ /* */
288
+ }
179
289
  try {
180
290
  child.kill('SIGKILL')
181
291
  } catch {
@@ -186,7 +296,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
186
296
  } catch {
187
297
  /* */
188
298
  }
189
- for (const f of [sock, pid]) {
299
+ for (const f of [sock, pid, servePath(runDir, session), attachedMarker]) {
190
300
  try {
191
301
  unlinkSync(f)
192
302
  } catch {
@@ -207,4 +317,33 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
207
317
  /* */
208
318
  }
209
319
  })
320
+
321
+ // ── boot-dialog driver (serving slice a) ────────────────────────────────────────
322
+ // For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume)
323
+ // off the AUTHORITATIVE @xterm model so the session reaches a ready input surface UNDER the
324
+ // supervisor — exactly what the launch primitive does over `tmux capture-pane` (read) + `send-keys`
325
+ // (write), but model-read (modelToPlainText) + raw pty-byte-write (keysToBytes). The cursor-key
326
+ // byte follows the model's live DECCKM mode (xterm.modes.applicationCursorKeysMode) — tmux keys off
327
+ // the same mode, so hooks-review/resume-picker Downs are byte-identical. DARK: the supervisor
328
+ // serves nothing on the live fleet; this drives only throwaway sessions until the serving flip.
329
+ const bootAdapter = BOOT_ADAPTERS[runtime]
330
+ if (bootAdapter) {
331
+ const driveDeadline = Date.now() + BOOT_DRIVE_MS
332
+ const driver = setInterval(() => {
333
+ if (down) {
334
+ clearInterval(driver)
335
+ return
336
+ }
337
+ let action
338
+ try {
339
+ const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
340
+ action = nextBootAction(bootAdapter, modelToPlainText(xterm, cols, rows), enc)
341
+ } catch {
342
+ return // transient model read — retry next tick
343
+ }
344
+ if (action.kind === 'dialog') child.terminal.write(action.bytes)
345
+ else if (action.kind === 'ready' || Date.now() > driveDeadline) clearInterval(driver)
346
+ }, BOOT_POLL_MS)
347
+ ;(driver as { unref?: () => void }).unref?.()
348
+ }
210
349
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { deliverToHost } from './deliver.ts'
6
+ import { killSession, startSupervisorDaemon } from './index.ts'
7
+
8
+ // The deliver leaf is the host equivalent of tmux load-buffer/paste-buffer/send-keys: it must put the
9
+ // message on the hosted child's pty input as a bracketed paste followed by a CR. Hermetic: a probe
10
+ // child captures its raw stdin (stty raw bypasses the line discipline) so we assert the EXACT bytes
11
+ // the child received. Guarded on Bun-native pty.
12
+ const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
13
+ const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
14
+ const d = bunPty ? describe : describe.skip
15
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
16
+
17
+ d('deliverToHost (supervisor-socket delivery — bracketed paste + CR)', () => {
18
+ test('the child receives exactly: ESC[200~ <message> ESC[201~ CR', async () => {
19
+ const runDir = mkdtempSync(join(tmpdir(), 'iapeer-deliver-'))
20
+ const out = join(runDir, 'stdin.bin')
21
+ const msg = 'hello-deliver-❯'
22
+ const expected = `\x1b[200~${msg}\x1b[201~\r`
23
+ const n = Buffer.byteLength(expected, 'utf8')
24
+ try {
25
+ // probe child: raw mode (no line-discipline cooking), read exactly n bytes, dump to a file
26
+ const argv = ['sh', '-c', `stty raw -echo; head -c ${n} > '${out}'; sleep 5`]
27
+ const r = startSupervisorDaemon({ session: 'dlv', runtime: 'tick', runDir, serve: { argv, env: { PATH: process.env.PATH ?? '' }, cwd: runDir } })
28
+ expect(r.state).toBe('started')
29
+ await sleep(400) // let the probe reach raw mode
30
+ const res = await deliverToHost(runDir, 'dlv', msg)
31
+ expect(res.ok).toBe(true)
32
+ // wait for the child to have captured n bytes
33
+ const deadline = Date.now() + 3000
34
+ while (Date.now() < deadline && !(existsSync(out) && readFileSync(out).length >= n)) await sleep(50)
35
+ const got = existsSync(out) ? readFileSync(out) : Buffer.alloc(0)
36
+ expect(got.equals(Buffer.from(expected, 'utf8'))).toBe(true) // bracketed paste + CR, byte-exact
37
+ } finally {
38
+ try {
39
+ killSession(runDir, 'dlv')
40
+ } catch {
41
+ /* */
42
+ }
43
+ await sleep(300)
44
+ rmSync(runDir, { recursive: true, force: true })
45
+ }
46
+ })
47
+
48
+ test('no supervisor socket → a clean error, not a throw', async () => {
49
+ const runDir = mkdtempSync(join(tmpdir(), 'iapeer-deliver-none-'))
50
+ try {
51
+ const res = await deliverToHost(runDir, 'nope', 'x')
52
+ expect(res.ok).toBe(false)
53
+ expect(res.error).toContain('no supervisor socket')
54
+ } finally {
55
+ rmSync(runDir, { recursive: true, force: true })
56
+ }
57
+ })
58
+ })