@agfpd/iapeer 0.2.59 → 0.2.61

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.
@@ -19,6 +19,7 @@ import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transpor
19
19
  import { getAdapter } from '../launch/index.ts'
20
20
  import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
21
21
  import type { Runtime } from '../core/constants.ts'
22
+ import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
22
23
 
23
24
  const SEED_BYTES = 4 * 1024 * 1024
24
25
  const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
@@ -72,16 +73,18 @@ export interface ShadowOptions {
72
73
  log?: (s: string) => void
73
74
  }
74
75
 
75
- function feed(o: Obs): Promise<void> {
76
+ /** Feed the pane-log delta into the model; resolves with the byte count appended this tick
77
+ * (drives the coverage state classifier: >0 ⇒ the model is advancing, not idle). */
78
+ function feed(o: Obs): Promise<number> {
76
79
  const sz = fstatSync(o.fd).size
77
- if (sz <= o.offset) return Promise.resolve()
80
+ if (sz <= o.offset) return Promise.resolve(0)
78
81
  const buf = Buffer.alloc(sz - o.offset)
79
82
  readSync(o.fd, buf, 0, buf.length, o.offset)
80
83
  o.offset = sz
81
- return new Promise(res => o.term.write(buf, () => res()))
84
+ return new Promise(res => o.term.write(buf, () => res(buf.length)))
82
85
  }
83
86
 
84
- function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
87
+ function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
85
88
  let peers: { runtime: Runtime; personality: string }[] = []
86
89
  try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
87
90
  const live = new Set<string>()
@@ -98,7 +101,7 @@ function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
98
101
  models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
99
102
  } catch { /* a peer racing spawn/teardown — skip this round */ }
100
103
  }
101
- for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; models.delete(addr) }
104
+ for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; tracker?.peerGone(addr); models.delete(addr) }
102
105
  }
103
106
 
104
107
  /** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
@@ -110,19 +113,20 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
110
113
  const interval = opts.intervalMs ?? 5000
111
114
  const log = opts.log ?? (() => { /* */ })
112
115
  const models = new Map<string, Obs>()
113
- const tot = { samples: 0, occDiv: 0, occNt: 0, rgDiv: 0, livDiv: 0 }
116
+ const tracker = new GateTracker()
114
117
  const startedISO = new Date().toISOString(), t0 = Date.now()
115
- enumerate(models, opts)
118
+ enumerate(models, opts, tracker)
116
119
  for (const o of models.values()) await feed(o) // seed each model
117
120
  log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
118
121
  let i = 0
119
122
  for (;;) {
120
123
  if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
121
124
  if (existsSync(stopFile)) break
122
- if (i > 0 && i % 15 === 0) enumerate(models, opts)
125
+ if (i > 0 && i % 15 === 0) enumerate(models, opts, tracker)
126
+ tracker.tickStart(models.size)
123
127
  for (const o of models.values()) {
124
128
  try {
125
- await feed(o)
129
+ const bytesFed = await feed(o)
126
130
  const adapter = getAdapter(o.runtime)
127
131
  const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
128
132
  const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
@@ -134,34 +138,53 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
134
138
  const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
135
139
  const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
136
140
  const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
141
+ const hasHistory = o.term.buffer.active.baseY > 0
137
142
 
138
- tot.samples++
139
- const occDiv = occPty !== occTmux, rgDiv = rgTmux !== null && rgPty !== rgTmux, livDiv = livPty !== livTmux
140
- const occNt = occPty || occTmux
141
- if (occNt) tot.occNt++
142
- if (occDiv) tot.occDiv++
143
- if (rgDiv) tot.rgDiv++
144
- if (livDiv) tot.livDiv++
145
- if (occDiv || rgDiv || livDiv) {
143
+ // Feed the gate tracker — it scores DIRECTION (behind=hard / ahead=benign-if-boot-self-heal)
144
+ // and accumulates the COVERAGE matrix. The boolean booleans never leave this call.
145
+ tracker.sample(i, {
146
+ peer: o.addr, runtime: o.runtime,
147
+ occupancy: { pty: occPty, tmux: occTmux },
148
+ readyGate: { pty: rgPty, tmux: rgTmux },
149
+ liveness: { pty: livPty, tmux: livTmux },
150
+ bytesFed, hasHistory,
151
+ })
152
+
153
+ // DIRECTION-AWARE per-event divergence trail (gate scoring lives in the tracker; this is
154
+ // the forensic log). Occupancy carries the raw capture + model frames for root-cause.
155
+ const occDir = classifyDirection(occPty, occTmux)
156
+ const rgDir = rgTmux === null ? 'agree' : classifyDirection(rgPty, rgTmux)
157
+ const livDir = classifyDirection(livPty, livTmux)
158
+ if (occDir !== 'agree' || rgDir !== 'agree' || livDir !== 'agree') {
146
159
  appendFileSync(jsonl, JSON.stringify({
147
- ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`,
148
- occupancy: { pty: occPty, tmux: occTmux, diverged: occDiv, nontrivial: occNt },
149
- readyGate: { pty: rgPty, tmux: rgTmux, diverged: rgDiv },
150
- liveness: { pty: livPty, tmux: livTmux, diverged: livDiv },
151
- ...(occDiv ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
160
+ ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`, hasHistory,
161
+ occupancy: { pty: occPty, tmux: occTmux, dir: occDir },
162
+ readyGate: { pty: rgPty, tmux: rgTmux, dir: rgDir },
163
+ liveness: { pty: livPty, tmux: livTmux, dir: livDir },
164
+ ...(occDir !== 'agree' ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
152
165
  }) + '\n')
153
- log(`DIVERGE ${o.addr} occ=${occDiv} rg=${rgDiv} liv=${livDiv}${occNt && occDiv ? ' *** NON-TRIVIAL occ ***' : ''}\n`)
166
+ log(`DIVERGE ${o.addr} occ=${occDir} rg=${rgDir} liv=${livDir}\n`)
154
167
  }
155
168
  } catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
156
169
  }
157
170
  i++
171
+ const gs = tracker.summary()
172
+ const totalSamples = VERDICTS.reduce((n, v) => n + gs.verdicts[v].samples, 0)
158
173
  writeFileSync(statsFile, JSON.stringify({
159
174
  startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
160
- peers: models.size, samples: tot.samples,
161
- occupancy: { divergences: tot.occDiv, nonTrivialSamples: tot.occNt },
162
- readyGate: { divergences: tot.rgDiv },
163
- liveness: { divergences: tot.livDiv },
164
- fidelity: tot.samples ? +(1 - (tot.occDiv + tot.rgDiv + tot.livDiv) / (tot.samples * 3)).toFixed(6) : 1,
175
+ peers: models.size, samples: totalSamples,
176
+ // Gate (boris's criterion): clean iff zero hard divergences across all verdicts. Hard =
177
+ // every pty-behind + non-benign pty-ahead (steady-state or non-converging). Benign =
178
+ // boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
179
+ gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
180
+ hardTotal: gs.hardViolations.length,
181
+ // Ready-gate FLIP eligibility (first-flip verdict) self-reported: NECESSARY input, not an
182
+ // auto-trigger — the flip stays an explicit decision. On !eligible, `reasons` say which cell
183
+ // is empty/dirty (actionable). composer-busy is excluded (orthogonal — gates the later occupancy flip).
184
+ readyGateFlip: evaluateReadyGateFlipEligibility(gs),
185
+ verdicts: gs.verdicts,
186
+ coverage: gs.coverage,
187
+ recentHard: gs.hardViolations.slice(-20),
165
188
  }, null, 2))
166
189
  if (opts.once) break
167
190
  await new Promise(r => setTimeout(r, interval))
@@ -0,0 +1,118 @@
1
+ // Supervisor CLIENT — attaches a terminal to a running daemon over the unix socket (cutover
2
+ // Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
3
+ // port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
4
+ // via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
5
+ import { existsSync, readFileSync, writeSync } from 'node:fs'
6
+ import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
7
+ import { pidPath, sockPath } from './paths.ts'
8
+
9
+ const DETACH = 0x1d // Ctrl-]
10
+
11
+ const pidAlive = (p: number): boolean => {
12
+ try {
13
+ process.kill(p, 0)
14
+ return true
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+ export function sessionAlive(runDir: string, session: string): boolean {
20
+ const pf = pidPath(runDir, session)
21
+ return existsSync(pf) && pidAlive(Number(readFileSync(pf, 'utf8')))
22
+ }
23
+
24
+ /** Attach an interactive terminal client to a live daemon session. Resolves when the client
25
+ * detaches / the session ends (it calls process.exit on every terminal path). */
26
+ export async function runSupervisorClient(runDir: string, session: string): Promise<void> {
27
+ if (!sessionAlive(runDir, session)) {
28
+ console.error(`no live session "${session}". start it: supervisor start ${session} <runtime>`)
29
+ process.exit(1)
30
+ }
31
+ let restored = false
32
+ // writeSync(1, …) works inside a signal handler (no event-loop dependency) — so a
33
+ // SIGHUP/window-close client still resets the terminal before it dies (port-dep #1).
34
+ const restore = (): void => {
35
+ if (restored) return
36
+ restored = true
37
+ try {
38
+ writeSync(1, TERM_RESET)
39
+ } catch {
40
+ /* */
41
+ }
42
+ try {
43
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
44
+ } catch {
45
+ /* */
46
+ }
47
+ try {
48
+ process.stdin.pause()
49
+ } catch {
50
+ /* */
51
+ }
52
+ }
53
+
54
+ const framer = makeFramer((type, payload) => {
55
+ if (type === FRAME_DATA) process.stdout.write(Buffer.from(payload))
56
+ })
57
+ // Ready the terminal BEFORE connecting, so the repaint snapshot renders the instant it arrives.
58
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
59
+ process.stdin.resume()
60
+
61
+ const curSize = (): Buffer => sizePayload(process.stdout.columns || 80, process.stdout.rows || 24)
62
+ const sock = await Bun.connect({
63
+ unix: sockPath(runDir, session),
64
+ socket: {
65
+ open(s) {
66
+ s.write(frame(FRAME_RESIZE, curSize())) // tell the daemon our size → repaint at our size
67
+ },
68
+ data(_s, d) {
69
+ framer(d)
70
+ },
71
+ close() {
72
+ restore()
73
+ process.stdout.write('\r\n[supervisor] session ended.\r\n')
74
+ process.exit(0)
75
+ },
76
+ error() {
77
+ restore()
78
+ process.exit(1)
79
+ },
80
+ },
81
+ })
82
+
83
+ process.stdin.on('data', b => {
84
+ if (b.includes(DETACH)) {
85
+ restore()
86
+ try {
87
+ sock.end()
88
+ } catch {
89
+ /* */
90
+ }
91
+ process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
92
+ process.exit(0)
93
+ }
94
+ sock.write(frame(FRAME_DATA, b))
95
+ })
96
+ process.on('SIGWINCH', () => {
97
+ try {
98
+ sock.write(frame(FRAME_RESIZE, curSize()))
99
+ } catch {
100
+ /* */
101
+ }
102
+ })
103
+ // Window-close (SIGHUP) / kill (SIGTERM) of the CLIENT: detach cleanly (session keeps running)
104
+ // and reset the terminal — the prior gap leaked the child's app-modes and broke the next reattach.
105
+ for (const sig of ['SIGHUP', 'SIGTERM'] as const) {
106
+ process.on(sig, () => {
107
+ restore()
108
+ try {
109
+ sock.end()
110
+ } catch {
111
+ /* */
112
+ }
113
+ process.exit(0)
114
+ })
115
+ }
116
+ process.on('exit', () => restore()) // last-resort sync net
117
+ process.stdout.write(`\x1b[2m[supervisor] attached to "${session}". detach: Ctrl-] (session keeps running)\x1b[0m\r\n`)
118
+ }
@@ -0,0 +1,112 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { FRAME_DATA, FRAME_RESIZE, frame, makeFramer, sizePayload } from './protocol.ts'
6
+ import { killSession, listSessions, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
7
+
8
+ // Integration: a REAL Bun-native pty daemon over a real unix socket, driven by the deterministic
9
+ // `tick` runtime (no claude/codex needed) — hermetic (temp run dir, throwaway session). Proves
10
+ // boris's Block-2 acceptance points: spawn + responder-served boot + attach repaint + live
11
+ // forward + detach-survival + REATTACH FRESHNESS (the heavy-reattach bug the port-deps fixed).
12
+ // Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
13
+ const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
14
+ const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
15
+ const d = bunPty ? describe : describe.skip
16
+
17
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
18
+ async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 50): Promise<boolean> {
19
+ const end = Date.now() + timeoutMs
20
+ while (Date.now() < end) {
21
+ if (pred()) return true
22
+ await sleep(step)
23
+ }
24
+ return false
25
+ }
26
+ const maxTick = (s: string): number => {
27
+ let m = -1
28
+ for (const x of s.matchAll(/STREAMTICK_(\d+)/g)) m = Math.max(m, Number(x[1]))
29
+ return m
30
+ }
31
+
32
+ interface Client {
33
+ text(): string
34
+ close(): void
35
+ }
36
+ async function attach(runDir: string, session: string, cols = 80, rows = 24): Promise<Client> {
37
+ const frames: Buffer[] = []
38
+ const framer = makeFramer((t, p) => {
39
+ if (t === FRAME_DATA) frames.push(Buffer.from(p))
40
+ })
41
+ const sock = await Bun.connect({
42
+ unix: sockPath(runDir, session),
43
+ socket: {
44
+ open(s) {
45
+ s.write(frame(FRAME_RESIZE, sizePayload(cols, rows)))
46
+ },
47
+ data(_s, dd) {
48
+ framer(dd)
49
+ },
50
+ },
51
+ })
52
+ return {
53
+ text: () => Buffer.concat(frames).toString('latin1'),
54
+ close: () => {
55
+ try {
56
+ sock.end()
57
+ } catch {
58
+ /* */
59
+ }
60
+ },
61
+ }
62
+ }
63
+
64
+ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
65
+ let runDir: string
66
+ const session = 'ptstest'
67
+
68
+ beforeAll(() => {
69
+ runDir = mkdtempSync(join(tmpdir(), 'iapeer-supervisor-'))
70
+ })
71
+ afterAll(() => {
72
+ try {
73
+ killSession(runDir, session)
74
+ } catch {
75
+ /* */
76
+ }
77
+ rmSync(runDir, { recursive: true, force: true })
78
+ })
79
+
80
+ test('spawns a detached daemon that comes up live', () => {
81
+ const r = startSupervisorDaemon({ session, runtime: 'tick', runDir })
82
+ expect(r.state).toBe('started')
83
+ expect(sessionAlive(runDir, session)).toBe(true)
84
+ expect(listSessions(runDir).some(s => s.session === session && s.alive)).toBe(true)
85
+ })
86
+
87
+ test('attach forwards live child output (the pty conduit works end-to-end)', async () => {
88
+ const c = await attach(runDir, session)
89
+ const got = await waitFor(() => /STREAMTICK_\d+/.test(c.text()))
90
+ expect(got).toBe(true) // live STREAMTICK_N stream reached the client
91
+ c.close()
92
+ })
93
+
94
+ test('detach keeps the daemon alive (detach-persistence)', async () => {
95
+ await sleep(150)
96
+ expect(sessionAlive(runDir, session)).toBe(true)
97
+ })
98
+
99
+ test('REATTACH repaints the CURRENT frame, not a stale one (port-dep #2 freshness)', async () => {
100
+ const first = await attach(runDir, session)
101
+ await waitFor(() => maxTick(first.text()) >= 0)
102
+ const base = maxTick(first.text())
103
+ first.close()
104
+ await sleep(800) // the stream advances while detached
105
+ const second = await attach(runDir, session)
106
+ // the reattach repaint (serialize of the CURRENT screen) must reflect ticks emitted AFTER the
107
+ // first detach — a stale close-moment frame would show base, not a higher index.
108
+ const fresh = await waitFor(() => maxTick(second.text()) > base, 5000)
109
+ expect(fresh).toBe(true)
110
+ second.close()
111
+ })
112
+ })
@@ -0,0 +1,210 @@
1
+ // Supervisor DAEMON — holds the pty + child + headless-xterm model + capability responder
2
+ // independent of any terminal; clients attach/detach over a unix socket (cutover Block 2, ported
3
+ // from the validated PoC pts.mjs). DARK: reachable only via the `supervisor` CLI verb (dynamic
4
+ // import), so @xterm never loads in the delivery hot path. NOT wired into launch/lifecycle — it
5
+ // serves NOTHING on the live fleet yet; it is validated on throwaway sessions only.
6
+ //
7
+ // Loads @xterm/headless + @xterm/addon-serialize (the latter is the repaint source: a reattaching
8
+ // client gets the full current screen even if the child is silent). Both bundle under
9
+ // `bun build --compile` (pure JS; the Bun-native pty has no native addon — PoC-proven).
10
+ import { Terminal } from '@xterm/headless'
11
+ import { SerializeAddon } from '@xterm/addon-serialize'
12
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
13
+ import {
14
+ BackpressureWriter,
15
+ FRAME_DATA,
16
+ FRAME_RESIZE,
17
+ capabilityResponses,
18
+ frame,
19
+ makeFramer,
20
+ parseSize,
21
+ stripQueries,
22
+ } from './protocol.ts'
23
+ import { pidPath, sockPath } from './paths.ts'
24
+
25
+ /** Known runtime launchers; `tick` is a deterministic test stream (no TUI noise) for hermetic
26
+ * spawn/attach/reattach tests — it never needs a real claude/codex on the box. */
27
+ const KNOWN: Record<string, string> = {
28
+ claude: '/Users/macmini/.local/bin/claude',
29
+ codex: '/opt/homebrew/bin/codex',
30
+ }
31
+ const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n" "$i"; i=$((i+1)); sleep 0.3; done']
32
+
33
+ // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
34
+ interface PtyChild {
35
+ terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
36
+ kill(sig?: number | string): void
37
+ }
38
+
39
+ export interface SupervisorDaemonOptions {
40
+ session: string
41
+ runtime: string
42
+ runDir: string
43
+ /** Child working directory (default: runDir — a throwaway scratch for dark validation). */
44
+ cwd?: string
45
+ /** Override the runtime launcher (else KNOWN[runtime] if it exists, else the bare name). */
46
+ bin?: string
47
+ cols?: number
48
+ rows?: number
49
+ }
50
+
51
+ /** Run the supervisor daemon in THIS process (blocks until the child exits / SIGTERM). Spawn it
52
+ * detached via startSupervisorDaemon (index.ts) for a real session; call directly in a test that
53
+ * owns its own process. */
54
+ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
55
+ const { session, runtime, runDir } = opts
56
+ let cols = opts.cols ?? 80
57
+ let rows = opts.rows ?? 30
58
+ const sock = sockPath(runDir, session)
59
+ const pid = pidPath(runDir, session)
60
+
61
+ const xterm = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 })
62
+ const ser = new SerializeAddon()
63
+ xterm.loadAddon(ser)
64
+
65
+ 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>
68
+ delete env.TMUX
69
+ delete env.TMUX_PANE
70
+
71
+ const clients = new Set<unknown>()
72
+ const writers = new WeakMap<object, BackpressureWriter>()
73
+ const framers = new WeakMap<object, (chunk: Uint8Array) => void>()
74
+ const writerFor = (s: { write(b: Buffer): number }): BackpressureWriter => {
75
+ let w = writers.get(s as object)
76
+ if (!w) {
77
+ w = new BackpressureWriter(b => s.write(b))
78
+ writers.set(s as object, w)
79
+ }
80
+ return w
81
+ }
82
+ /** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */
83
+ const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => {
84
+ const ok = writerFor(s).send(buf)
85
+ if (!ok) clients.delete(s)
86
+ return ok
87
+ }
88
+
89
+ const child = Bun.spawn(CMD, {
90
+ cwd: opts.cwd ?? runDir,
91
+ env,
92
+ // Bun-native PTY: a real pty pair, child output arrives via terminal.data(t, bytes).
93
+ terminal: {
94
+ cols,
95
+ rows,
96
+ name: 'xterm-256color',
97
+ data(_t: unknown, d: Uint8Array | string) {
98
+ const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
99
+ const s = buf.toString('latin1')
100
+ xterm.write(buf) // authoritative model
101
+ for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
102
+ const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
103
+ if (fwd.length) for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
104
+ },
105
+ exit() {
106
+ shutdown()
107
+ },
108
+ },
109
+ } as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
110
+ xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
111
+
112
+ /** Repaint a (re)attaching client from the headless snapshot: clear+home, then the full grid.
113
+ * Backpressure-safe — a ~90 KB heavy snapshot MUST NOT drop past the socket buffer (port-dep #2). */
114
+ const repaint = (s: { write(b: Buffer): number }): void => {
115
+ const snap = '\x1b[2J\x1b[3J\x1b[H' + ser.serialize()
116
+ sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
117
+ }
118
+
119
+ const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
120
+ if (type === FRAME_DATA) {
121
+ child.terminal.write(payload) // keystrokes → child
122
+ } else if (type === FRAME_RESIZE) {
123
+ const sz = parseSize(payload)
124
+ cols = sz.cols
125
+ rows = sz.rows
126
+ try {
127
+ xterm.resize(cols, rows)
128
+ child.terminal.resize(cols, rows)
129
+ } catch {
130
+ /* transient */
131
+ }
132
+ repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
133
+ // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
134
+ // snapshot above is the real freshness fix).
135
+ try {
136
+ child.terminal.resize(cols, Math.max(1, rows - 1))
137
+ child.terminal.resize(cols, rows)
138
+ } catch {
139
+ /* */
140
+ }
141
+ }
142
+ }
143
+
144
+ try {
145
+ if (existsSync(sock)) unlinkSync(sock)
146
+ } catch {
147
+ /* */
148
+ }
149
+ const server = Bun.listen({
150
+ unix: sock,
151
+ socket: {
152
+ open(s: { write(b: Buffer): number }) {
153
+ framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
154
+ clients.add(s)
155
+ repaint(s) // UNCONDITIONAL repaint on attach (tmux-like)
156
+ },
157
+ data(s: object, d: Uint8Array) {
158
+ framers.get(s)?.(d)
159
+ },
160
+ drain(s: object) {
161
+ if (!writers.get(s)?.flush()) clients.delete(s) // writable again → flush queued tail
162
+ },
163
+ close(s: object) {
164
+ clients.delete(s)
165
+ writers.delete(s)
166
+ },
167
+ error(s: object) {
168
+ clients.delete(s)
169
+ writers.delete(s)
170
+ },
171
+ },
172
+ })
173
+ writeFileSync(pid, String(process.pid))
174
+
175
+ let down = false
176
+ function shutdown(): void {
177
+ if (down) return
178
+ down = true
179
+ try {
180
+ child.kill('SIGKILL')
181
+ } catch {
182
+ /* */
183
+ }
184
+ try {
185
+ server.stop(true)
186
+ } catch {
187
+ /* */
188
+ }
189
+ for (const f of [sock, pid]) {
190
+ try {
191
+ unlinkSync(f)
192
+ } catch {
193
+ /* */
194
+ }
195
+ }
196
+ process.exit(0)
197
+ }
198
+ process.on('SIGTERM', shutdown)
199
+ process.on('SIGINT', shutdown)
200
+ process.on('SIGHUP', () => {
201
+ /* daemon survives client window close */
202
+ })
203
+ process.on('uncaughtException', e => {
204
+ try {
205
+ console.error('[supervisor-daemon] uncaught', (e && (e as Error).stack) || e)
206
+ } catch {
207
+ /* */
208
+ }
209
+ })
210
+ }