@agfpd/iapeer 0.2.64 → 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.
@@ -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
+ })
@@ -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}`)