@agfpd/iapeer 0.2.60 → 0.2.62

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "1.29.0",
36
+ "@xterm/addon-serialize": "0.14.0",
36
37
  "@xterm/headless": "6.0.0",
37
38
  "proper-lockfile": "^4.1.2",
38
39
  "transliteration": "^2.6.1"
package/src/cli/index.ts CHANGED
@@ -939,6 +939,7 @@ const USAGE = `usage: iapeer <verb> [args]
939
939
  shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
940
940
  shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
941
941
  shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
942
+ supervisor up|start|attach|list|kill <sess> [runtime] DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)
942
943
  `
943
944
 
944
945
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
@@ -1801,6 +1802,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1801
1802
  out(`shadow burn-in job ${r.action}: ${r.path}\n`)
1802
1803
  return 0
1803
1804
  }
1805
+ case 'supervisor': {
1806
+ // DARK (cutover Block 2): the detach-persistent pty-supervisor (PoC pts.mjs port). NOT
1807
+ // wired into delivery/launch — it serves NOTHING on the live fleet; it is validated on
1808
+ // throwaway `tick` sessions. The dynamic import keeps @xterm OUT of the daemon hot path
1809
+ // (the daemon never loads this). Sub-commands: up|start|attach|list|kill|daemon.
1810
+ const { runSupervisorCli } = await import('../supervisor/index.ts')
1811
+ return await runSupervisorCli(rest)
1812
+ }
1804
1813
  default: {
1805
1814
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
1806
1815
  if (verb && isRuntime(verb)) {
@@ -3,7 +3,10 @@ import {
3
3
  GateTracker,
4
4
  classifyDirection,
5
5
  classifySampleState,
6
+ evaluateReadyGateFlipEligibility,
7
+ type GateSummary,
6
8
  type PeerSample,
9
+ type VerdictGateStat,
7
10
  } from './gate.ts'
8
11
 
9
12
  // SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
@@ -141,3 +144,106 @@ describe('GateTracker — coverage matrix (clean counts only over traversed cell
141
144
  expect(cov.runtimes).toEqual(['claude', 'codex'])
142
145
  })
143
146
  })
147
+
148
+ describe('GateTracker — center cell (codex×wake-resume×history readiness caught by the model)', () => {
149
+ test('a history-bearing peer whose readiness the model catches at the tmux transition → counted', () => {
150
+ const g = new GateTracker()
151
+ g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
152
+ g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // ready, model agreed
153
+ expect(g.summary().coverage.readyResumeCaught['codex']).toBe(1)
154
+ })
155
+ test('model MISSES readiness at the transition → NOT counted, and it is a pty-behind (dirty)', () => {
156
+ const g = new GateTracker()
157
+ g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
158
+ g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: true } })) // tmux ready, model NOT
159
+ const s = g.summary()
160
+ expect(s.coverage.readyResumeCaught['codex']).toBeUndefined()
161
+ expect(s.verdicts.readyGate.behind).toBeGreaterThanOrEqual(1) // the exact splash-blind bug
162
+ })
163
+ test('a FRESH (no-history) peer ready transition is NOT a center-cell hit', () => {
164
+ const g = new GateTracker()
165
+ g.sample(0, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: false, tmux: false } }))
166
+ g.sample(1, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: true, tmux: true } }))
167
+ expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
168
+ })
169
+ })
170
+
171
+ describe('evaluateReadyGateFlipEligibility (boris guardrail: the eligibility logic itself under test)', () => {
172
+ const stat = (over: Partial<VerdictGateStat> = {}): VerdictGateStat => ({
173
+ samples: 10,
174
+ agree: 10,
175
+ behind: 0,
176
+ aheadBenign: 0,
177
+ aheadHard: 0,
178
+ selfHeal: { count: 0, max: 0, mean: 0 },
179
+ gateClean: true,
180
+ ...over,
181
+ })
182
+ const mkSummary = (over: {
183
+ rgBehind?: number
184
+ rgAheadHard?: number
185
+ center?: Record<string, number>
186
+ births?: Record<string, number>
187
+ restarts?: Record<string, number>
188
+ } = {}): GateSummary => ({
189
+ verdicts: {
190
+ occupancy: stat(),
191
+ readyGate: stat({ behind: over.rgBehind ?? 0, aheadHard: over.rgAheadHard ?? 0 }),
192
+ liveness: stat(),
193
+ },
194
+ hardViolations: [],
195
+ coverage: {
196
+ fleetWidthMax: 2,
197
+ runtimes: ['claude', 'codex'],
198
+ byRuntimeState: {},
199
+ births: over.births ?? {},
200
+ restarts: over.restarts ?? {},
201
+ historySamples: {},
202
+ readyResumeCaught: over.center ?? {},
203
+ },
204
+ })
205
+ const COMPLETE = {
206
+ center: { codex: 1 },
207
+ births: { 'claude/fresh': 1, 'codex/wake-resume': 1 },
208
+ restarts: { codex: 1 },
209
+ }
210
+
211
+ test('incomplete (empty) → NOT eligible, reasons name every missing cell (actionable)', () => {
212
+ const e = evaluateReadyGateFlipEligibility(mkSummary())
213
+ expect(e.eligible).toBe(false)
214
+ expect(e.reasons.some(r => /center codex×wake-resume×history/.test(r))).toBe(true)
215
+ expect(e.reasons.some(r => /no codex birth/.test(r))).toBe(true)
216
+ expect(e.reasons.some(r => /no claude birth/.test(r))).toBe(true)
217
+ expect(e.reasons.some(r => /no restart/.test(r))).toBe(true)
218
+ })
219
+
220
+ test('complete + clean → eligible', () => {
221
+ const e = evaluateReadyGateFlipEligibility(mkSummary(COMPLETE))
222
+ expect(e.eligible).toBe(true)
223
+ expect(e.reasons).toHaveLength(0)
224
+ })
225
+
226
+ test('complete BUT dirty (readyGate behind) → NOT eligible, reason names the dirt', () => {
227
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, rgBehind: 2 }))
228
+ expect(e.eligible).toBe(false)
229
+ expect(e.reasons.some(r => /readyGate dirty: behind=2/.test(r))).toBe(true)
230
+ })
231
+
232
+ test('complete except the CENTER cell empty → NOT eligible, center reason only', () => {
233
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, center: {} }))
234
+ expect(e.eligible).toBe(false)
235
+ expect(e.reasons).toHaveLength(1)
236
+ expect(e.reasons[0]).toMatch(/center codex×wake-resume×history: 0/)
237
+ })
238
+
239
+ test('complete except no restart → NOT eligible, restart reason only', () => {
240
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, restarts: {} }))
241
+ expect(e.eligible).toBe(false)
242
+ expect(e.reasons).toEqual(['no restart observed'])
243
+ })
244
+
245
+ test('composer-busy is ORTHOGONAL — its absence never blocks ready-gate eligibility', () => {
246
+ // COMPLETE carries no composer-busy state anywhere; eligibility must still be true.
247
+ expect(evaluateReadyGateFlipEligibility(mkSummary(COMPLETE)).eligible).toBe(true)
248
+ })
249
+ })
@@ -93,6 +93,11 @@ interface PeerState {
93
93
  firstTick: number
94
94
  readyStreak: number
95
95
  bootDone: boolean
96
+ /** History present at first sight ⇒ this peer woke WITH resume-history (vs a fresh empty birth)
97
+ * — the population the center cell (codex×wake-resume×history) is measured on. */
98
+ bornWithHistory: boolean
99
+ /** Previous tmux ready value, to detect the false/none→true readiness transition. */
100
+ prevReadyTmux: boolean | null
96
101
  open: Record<Verdict, OpenAhead | null>
97
102
  }
98
103
 
@@ -118,6 +123,10 @@ export class GateTracker {
118
123
  private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
119
124
  private restarts = new Map<string, number>() // runtime → count
120
125
  private historySamples = new Map<string, number>() // runtime → count
126
+ // CENTER cell: a wake-resume-with-history peer whose readiness the MODEL caught at the tmux
127
+ // ready transition (rgTmux false/none→true with rgPty agreeing) — the codex splash-off-screen
128
+ // case the ready-gate flip exists for. A behind at that transition instead lands in readyGate.behind.
129
+ private readyResumeCaught = new Map<string, number>() // runtime → count
121
130
  private runtimesSeen = new Set<string>()
122
131
 
123
132
  constructor(cfg: GateConfig = {}) {
@@ -154,7 +163,15 @@ export class GateTracker {
154
163
  this.inc(this.restarts, s.runtime)
155
164
  this.goneOnce.delete(s.peer)
156
165
  }
157
- ps = { runtime: s.runtime, firstTick: tick, readyStreak: 0, bootDone: false, open: { occupancy: null, readyGate: null, liveness: null } }
166
+ ps = {
167
+ runtime: s.runtime,
168
+ firstTick: tick,
169
+ readyStreak: 0,
170
+ bootDone: false,
171
+ bornWithHistory: s.hasHistory,
172
+ prevReadyTmux: null,
173
+ open: { occupancy: null, readyGate: null, liveness: null },
174
+ }
158
175
  this.peers.set(s.peer, ps)
159
176
  }
160
177
 
@@ -167,6 +184,20 @@ export class GateTracker {
167
184
  }
168
185
  const bootWindow = !ps.bootDone
169
186
 
187
+ // Center-cell signal: at the readiness transition (rgTmux false/none→true) of a
188
+ // wake-resume-with-history peer, did the MODEL catch readiness too (rgPty=true)? That is the
189
+ // codex splash-off-screen case the ready-gate flip exists for. (A model MISS at this transition
190
+ // is a pty-behind → readyGate.behind → dirty → ineligible, which is exactly right.)
191
+ if (s.readyGate.tmux !== null) {
192
+ // becameReady requires an OBSERVED not-ready→ready transition (prev === false), NOT
193
+ // first-sight-already-ready (prev === null): the center cell must capture a REAL resume
194
+ // splash→ready the observer witnessed (boris's representativeness guardrail), not a warm
195
+ // peer that was already up when the observer attached.
196
+ const becameReady = s.readyGate.tmux === true && ps.prevReadyTmux === false
197
+ if (becameReady && ps.bornWithHistory && s.readyGate.pty === true) this.inc(this.readyResumeCaught, s.runtime)
198
+ ps.prevReadyTmux = s.readyGate.tmux
199
+ }
200
+
170
201
  // Coverage.
171
202
  const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
172
203
  this.inc(this.stateCells, `${s.runtime}/${state}`)
@@ -275,11 +306,37 @@ export class GateTracker {
275
306
  births: Object.fromEntries([...this.births].sort()),
276
307
  restarts: Object.fromEntries([...this.restarts].sort()),
277
308
  historySamples: Object.fromEntries([...this.historySamples].sort()),
309
+ readyResumeCaught: Object.fromEntries([...this.readyResumeCaught].sort()),
278
310
  },
279
311
  }
280
312
  }
281
313
  }
282
314
 
315
+ /** Ready-gate FLIP eligibility — boris's verdict-scoped gate, operationalized so the burn-in
316
+ * SELF-REPORTS instead of being eyeballed. NECESSARY input, NOT an auto-trigger: the flip stays
317
+ * an explicit human decision (the representativeness of the filled cells — e.g. the splash truly
318
+ * scrolled off-screen — is verified separately on top of this). Eligible iff:
319
+ * • ready-gate is HARD-clean (behind=0 ∧ aheadHard=0), AND
320
+ * • the CENTER cell is filled: codex woke WITH history and the model caught its readiness, AND
321
+ * • a codex AND a claude birth were observed, AND
322
+ * • a restart was observed.
323
+ * composer-busy is ORTHOGONAL to readiness (a typing human already passed readiness) → excluded;
324
+ * it gates the LATER occupancy flip. On !eligible `reasons` say WHICH cell is empty/dirty —
325
+ * actionable, never a black-box "not yet". PURE → its own logic is unit-testable (boris's guardrail). */
326
+ export function evaluateReadyGateFlipEligibility(s: GateSummary): FlipEligibility {
327
+ const reasons: string[] = []
328
+ const rg = s.verdicts.readyGate
329
+ if (rg.behind > 0 || rg.aheadHard > 0) reasons.push(`readyGate dirty: behind=${rg.behind} aheadHard=${rg.aheadHard}`)
330
+ const center = s.coverage.readyResumeCaught['codex'] ?? 0
331
+ if (center < 1) reasons.push(`center codex×wake-resume×history: ${center} readiness caught (need ≥1, representatively splash-off-screen)`)
332
+ const hasBirth = (rt: string): boolean => Object.keys(s.coverage.births).some(k => k.startsWith(`${rt}/`))
333
+ if (!hasBirth('codex')) reasons.push('no codex birth observed')
334
+ if (!hasBirth('claude')) reasons.push('no claude birth observed')
335
+ const restarts = Object.values(s.coverage.restarts).reduce((a, b) => a + b, 0)
336
+ if (restarts < 1) reasons.push('no restart observed')
337
+ return { verdict: 'readyGate', eligible: reasons.length === 0, reasons }
338
+ }
339
+
283
340
  export interface VerdictGateStat {
284
341
  samples: number
285
342
  agree: number
@@ -297,6 +354,8 @@ export interface GateCoverage {
297
354
  births: Record<string, number>
298
355
  restarts: Record<string, number>
299
356
  historySamples: Record<string, number>
357
+ /** runtime → count of wake-resume-with-history readiness transitions the MODEL caught (center cell). */
358
+ readyResumeCaught: Record<string, number>
300
359
  }
301
360
 
302
361
  export interface GateSummary {
@@ -304,3 +363,10 @@ export interface GateSummary {
304
363
  hardViolations: HardViolation[]
305
364
  coverage: GateCoverage
306
365
  }
366
+
367
+ export interface FlipEligibility {
368
+ verdict: 'readyGate'
369
+ eligible: boolean
370
+ /** On !eligible: one actionable line per unmet requirement (which cell is empty/dirty). */
371
+ reasons: string[]
372
+ }
@@ -19,7 +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, VERDICTS } from './gate.ts'
22
+ import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
23
23
 
24
24
  const SEED_BYTES = 4 * 1024 * 1024
25
25
  const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
@@ -122,7 +122,10 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
122
122
  for (;;) {
123
123
  if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
124
124
  if (existsSync(stopFile)) break
125
- if (i > 0 && i % 15 === 0) enumerate(models, opts, tracker)
125
+ // Re-enroll every ~3 ticks (~15s), not 15 (~75s): a freshly-woken peer must be enrolled
126
+ // and sampled DURING its not-ready phase for a readiness transition (the center cell) to
127
+ // register — a 75s enroll-lag could first-sight it already-ready and miss the transition.
128
+ if (i > 0 && i % 3 === 0) enumerate(models, opts, tracker)
126
129
  tracker.tickStart(models.size)
127
130
  for (const o of models.values()) {
128
131
  try {
@@ -178,6 +181,10 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
178
181
  // boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
179
182
  gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
180
183
  hardTotal: gs.hardViolations.length,
184
+ // Ready-gate FLIP eligibility (first-flip verdict) self-reported: NECESSARY input, not an
185
+ // auto-trigger — the flip stays an explicit decision. On !eligible, `reasons` say which cell
186
+ // is empty/dirty (actionable). composer-busy is excluded (orthogonal — gates the later occupancy flip).
187
+ readyGateFlip: evaluateReadyGateFlipEligibility(gs),
181
188
  verdicts: gs.verdicts,
182
189
  coverage: gs.coverage,
183
190
  recentHard: gs.hardViolations.slice(-20),
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ // Supervisor entry + CLI dispatch (cutover Block 2, ported from PoC pts.mjs). DARK: this module
2
+ // (and daemon.ts/client.ts) load @xterm — they are reachable ONLY via the `supervisor` CLI verb
3
+ // (dynamic import) and are NOT imported by transport/lifecycle/launch/daemon. The supervisor
4
+ // serves NOTHING on the live fleet; it is validated on throwaway `tick` sessions in-repo. When
5
+ // serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
6
+ // binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
7
+ import { spawn as nodeSpawn } from 'node:child_process'
8
+ import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { runSupervisorDaemon } from './daemon.ts'
11
+ import { runSupervisorClient, sessionAlive } from './client.ts'
12
+ import { defaultRunDir, logPath, pidPath, sockPath } from './paths.ts'
13
+
14
+ export { runSupervisorDaemon } from './daemon.ts'
15
+ export { runSupervisorClient, sessionAlive } from './client.ts'
16
+ export { defaultRunDir, sockPath, pidPath } from './paths.ts'
17
+
18
+ export interface StartDaemonOptions {
19
+ session: string
20
+ runtime: string
21
+ runDir: string
22
+ /** How to launch the detached daemon process (default: bun + this file). The trailing
23
+ * `daemon <session> <runtime> --run-dir <runDir>` is appended. Serving-prod injects the
24
+ * installed-binary argv here. */
25
+ daemonArgv?: string[]
26
+ /** Max ms to wait for the daemon socket+pid to appear (default 5000). */
27
+ timeoutMs?: number
28
+ }
29
+
30
+ export interface StartDaemonResult {
31
+ state: 'started' | 'already-running' | 'failed'
32
+ pid?: number
33
+ detail?: string
34
+ }
35
+
36
+ const selfArgv = (): string[] => [process.execPath, fileURLToPath(import.meta.url)]
37
+
38
+ /** Start a detached supervisor daemon and wait until its socket is live. Idempotent. */
39
+ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResult {
40
+ const { session, runtime, runDir } = opts
41
+ if (sessionAlive(runDir, session)) return { state: 'already-running' }
42
+ mkdirSync(runDir, { recursive: true })
43
+ try {
44
+ if (existsSync(sockPath(runDir, session))) unlinkSync(sockPath(runDir, session))
45
+ } catch {
46
+ /* */
47
+ }
48
+ const out = openSync(logPath(runDir, session), 'a')
49
+ const argv = opts.daemonArgv ?? selfArgv()
50
+ const child = nodeSpawn(argv[0]!, [...argv.slice(1), 'daemon', session, runtime, '--run-dir', runDir], {
51
+ detached: true,
52
+ stdio: ['ignore', out, out],
53
+ })
54
+ child.unref()
55
+ const deadline = Date.now() + (opts.timeoutMs ?? 5000)
56
+ while (Date.now() < deadline) {
57
+ if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
58
+ return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
59
+ }
60
+ Bun.sleepSync(80)
61
+ }
62
+ return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
63
+ }
64
+
65
+ export function listSessions(runDir: string): Array<{ session: string; alive: boolean; pid?: number }> {
66
+ if (!existsSync(runDir)) return []
67
+ return readdirSync(runDir)
68
+ .filter(f => f.endsWith('.sock'))
69
+ .map(f => f.replace(/\.sock$/, ''))
70
+ .map(session => {
71
+ const alive = sessionAlive(runDir, session)
72
+ return { session, alive, pid: alive ? Number(readFileSync(pidPath(runDir, session), 'utf8')) : undefined }
73
+ })
74
+ }
75
+
76
+ export function killSession(runDir: string, session: string): boolean {
77
+ const pf = pidPath(runDir, session)
78
+ if (!existsSync(pf)) return false
79
+ const pid = Number(readFileSync(pf, 'utf8'))
80
+ try {
81
+ process.kill(pid, 'SIGTERM')
82
+ } catch {
83
+ /* already gone */
84
+ }
85
+ return true
86
+ }
87
+
88
+ /** Parse a `--run-dir <dir>` flag out of an argv slice; returns the value or the default. */
89
+ function runDirFrom(args: string[]): string {
90
+ const i = args.indexOf('--run-dir')
91
+ return i >= 0 && args[i + 1] ? args[i + 1]! : defaultRunDir()
92
+ }
93
+
94
+ /** CLI dispatch (also the `import.meta.main` entry used by the spawned daemon process). */
95
+ export async function runSupervisorCli(argv: string[]): Promise<number> {
96
+ const [cmd, a, b] = argv
97
+ const runDir = runDirFrom(argv)
98
+ switch (cmd) {
99
+ case 'daemon':
100
+ runSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
101
+ // The daemon is event-driven: runSupervisorDaemon RETURNS after wiring the pty + socket
102
+ // server, and the event loop keeps the process alive. Await a never-resolving promise so the
103
+ // caller's `process.exit` does NOT fire — shutdown() (child-exit / SIGTERM) owns the exit.
104
+ await new Promise<never>(() => {})
105
+ return 0 // unreachable
106
+ case 'start': {
107
+ const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
108
+ console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
109
+ return r.state === 'failed' ? 1 : 0
110
+ }
111
+ case 'attach':
112
+ await runSupervisorClient(runDir, a!)
113
+ return 0
114
+ case 'up': {
115
+ const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
116
+ if (r.state === 'failed') {
117
+ console.error(r.detail)
118
+ return 1
119
+ }
120
+ await runSupervisorClient(runDir, a!)
121
+ return 0
122
+ }
123
+ case 'list':
124
+ for (const s of listSessions(runDir)) console.log(`${s.session}\t${s.alive ? `running pid=${s.pid}` : 'dead'}`)
125
+ return 0
126
+ case 'kill':
127
+ console.log(killSession(runDir, a!) ? `killed "${a}"` : `no session "${a}"`)
128
+ return 0
129
+ default:
130
+ console.log('usage: supervisor up|start|attach|list|kill <session> [runtime] [--run-dir <dir>] (detach: Ctrl-])')
131
+ return 0
132
+ }
133
+ }
134
+
135
+ if (import.meta.main) {
136
+ process.exit(await runSupervisorCli(process.argv.slice(2)))
137
+ }
@@ -0,0 +1,14 @@
1
+ // Supervisor session paths — under a configurable run dir (NOT hardcoded). Prod resolves it to
2
+ // ~/.iapeer/state/supervisor via the storage layer; tests pass a temp dir, so the daemon's
3
+ // socket/pid files never touch a real location.
4
+ import { join } from 'node:path'
5
+ import { pluginStateDir } from '../storage/index.ts'
6
+
7
+ /** Default run dir for the supervisor's session sockets/pids: ~/.iapeer/state/supervisor. */
8
+ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
9
+ return pluginStateDir('supervisor', { env })
10
+ }
11
+
12
+ export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
13
+ export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
14
+ export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ BackpressureWriter,
4
+ FRAME_DATA,
5
+ FRAME_RESIZE,
6
+ TERM_RESET,
7
+ capabilityResponses,
8
+ frame,
9
+ makeFramer,
10
+ parseSize,
11
+ sizePayload,
12
+ stripQueries,
13
+ } from './protocol.ts'
14
+
15
+ describe('frame protocol', () => {
16
+ test('frame() lays out [type][len:u32be][payload]', () => {
17
+ const f = frame(FRAME_DATA, 'hi')
18
+ expect(f[0]).toBe(FRAME_DATA)
19
+ expect(f.readUInt32BE(1)).toBe(2)
20
+ expect(f.subarray(5).toString()).toBe('hi')
21
+ })
22
+
23
+ test('makeFramer reassembles a frame split across chunk boundaries', () => {
24
+ const got: Array<{ t: number; p: string }> = []
25
+ const fr = makeFramer((t, p) => got.push({ t, p: p.toString() }))
26
+ const f = frame(FRAME_DATA, 'hello world')
27
+ fr(f.subarray(0, 3)) // partial header
28
+ fr(f.subarray(3, 8)) // rest of header + part of payload
29
+ expect(got).toHaveLength(0) // nothing complete yet
30
+ fr(f.subarray(8)) // tail
31
+ expect(got).toEqual([{ t: FRAME_DATA, p: 'hello world' }])
32
+ })
33
+
34
+ test('makeFramer splits multiple coalesced frames', () => {
35
+ const got: string[] = []
36
+ const fr = makeFramer((_t, p) => got.push(p.toString()))
37
+ fr(Buffer.concat([frame(FRAME_DATA, 'a'), frame(FRAME_DATA, 'bb'), frame(FRAME_DATA, 'ccc')]))
38
+ expect(got).toEqual(['a', 'bb', 'ccc'])
39
+ })
40
+
41
+ test('sizePayload/parseSize round-trip', () => {
42
+ expect(parseSize(sizePayload(132, 50))).toEqual({ cols: 132, rows: 50 })
43
+ expect(frame(FRAME_RESIZE, sizePayload(80, 24))[0]).toBe(FRAME_RESIZE)
44
+ })
45
+ })
46
+
47
+ describe('capability responder (the queries @xterm does not auto-answer)', () => {
48
+ test('secondary-DA-style XTVERSION query → version report', () => {
49
+ expect(capabilityResponses('\x1b[>0q')).toEqual(['\x1bP>|iapeer-pts(0.1)\x1b\\'])
50
+ })
51
+ test('OSC-11 theme query → an rgb background report', () => {
52
+ expect(capabilityResponses('\x1b]11;?\x07')[0]).toContain('\x1b]11;rgb:')
53
+ })
54
+ test('secondary DA (>c) → a DA2 report', () => {
55
+ expect(capabilityResponses('\x1b[>0c')).toEqual(['\x1b[>0;276;0c'])
56
+ })
57
+ test('plain output → no responses', () => {
58
+ expect(capabilityResponses('just some text\r\n')).toEqual([])
59
+ })
60
+ test('stripQueries removes DA1/DSR/theme but keeps real output', () => {
61
+ const s = 'before\x1b[0c\x1b[6nmid\x1b]11;?\x07after'
62
+ const out = stripQueries(s)
63
+ expect(out).toBe('beforemidafter')
64
+ expect(out).not.toContain('\x1b[6n')
65
+ })
66
+ })
67
+
68
+ describe('TERM_RESET (port-dep #1: clean every client exit path)', () => {
69
+ test('resets the leak-prone app-modes', () => {
70
+ expect(TERM_RESET).toContain('\x1b[?1049l') // leave alt-screen
71
+ expect(TERM_RESET).toContain('\x1b[?1000l') // mouse tracking off
72
+ expect(TERM_RESET).toContain('\x1b[?25h') // show cursor
73
+ expect(TERM_RESET).toContain('\x1b[<u') // pop kitty-keyboard
74
+ })
75
+ })
76
+
77
+ describe('BackpressureWriter (port-dep #2: the ~90 KB-over-8 KB truncation that staled reattach)', () => {
78
+ test('a fully-accepted write leaves nothing queued', () => {
79
+ const w = new BackpressureWriter(b => b.length)
80
+ expect(w.send(Buffer.alloc(100))).toBe(true)
81
+ expect(w.queuedBytes).toBe(0)
82
+ })
83
+
84
+ test('a heavy frame past the socket buffer is QUEUED, then fully delivered across drains', () => {
85
+ const CAP = 8192
86
+ let delivered = 0
87
+ const w = new BackpressureWriter(b => {
88
+ const n = Math.min(CAP, b.length)
89
+ delivered += n
90
+ return n
91
+ })
92
+ const heavy = Buffer.alloc(89305, 0x41) // the measured heavy-claude serialize size
93
+ expect(w.send(heavy)).toBe(true)
94
+ expect(delivered).toBe(CAP) // first write took only the socket buffer
95
+ expect(w.queuedBytes).toBe(89305 - CAP) // the rest is queued, NOT dropped
96
+ let guard = 0
97
+ while (w.queuedBytes > 0 && guard++ < 1000) w.flush() // drain events
98
+ expect(delivered).toBe(89305) // every byte eventually delivered (no truncation)
99
+ expect(w.queuedBytes).toBe(0)
100
+ expect(w.isDead).toBe(false)
101
+ })
102
+
103
+ test('coalesces a new send onto the pending tail (ordering preserved)', () => {
104
+ const CAP = 4
105
+ const chunks: string[] = []
106
+ const w = new BackpressureWriter(b => {
107
+ const n = Math.min(CAP, b.length)
108
+ chunks.push(b.subarray(0, n).toString())
109
+ return n
110
+ })
111
+ w.send(Buffer.from('ABCDEF')) // 'ABCD' written, 'EF' queued
112
+ w.send(Buffer.from('GH')) // coalesced onto 'EF' → 'EFGH'
113
+ let guard = 0
114
+ while (w.queuedBytes > 0 && guard++ < 100) w.flush()
115
+ expect(chunks.join('')).toBe('ABCDEFGH')
116
+ })
117
+
118
+ test('a dead socket (write throws) → send returns false and the writer is dead', () => {
119
+ const w = new BackpressureWriter(() => {
120
+ throw new Error('EPIPE')
121
+ })
122
+ expect(w.send(Buffer.alloc(10))).toBe(false)
123
+ expect(w.isDead).toBe(true)
124
+ })
125
+
126
+ test('a negative write count → dead', () => {
127
+ const w = new BackpressureWriter(() => -1)
128
+ expect(w.send(Buffer.alloc(10))).toBe(false)
129
+ expect(w.isDead).toBe(true)
130
+ })
131
+ })
@@ -0,0 +1,155 @@
1
+ // Supervisor wire/terminal PRIMITIVES — the pure, hermetically-testable core of the
2
+ // detach-persistent pty-supervisor (cutover Block 2, ported from the validated PoC pts.mjs).
3
+ //
4
+ // PURE: no Bun, no @xterm, no socket — frames, the capability responder, the backpressure
5
+ // writer (port-dep #2), and the terminal-reset string (port-dep #1) are all data-in/data-out,
6
+ // so the two reattach port-deps that bit the PoC are unit-provable without spawning anything.
7
+
8
+ const EMPTY = Buffer.alloc(0)
9
+
10
+ // ── client/daemon frame protocol: [type:u8][len:u32be][payload] ──
11
+ export const FRAME_DATA = 0
12
+ export const FRAME_RESIZE = 1
13
+
14
+ export function frame(type: number, payload: Buffer | string): Buffer {
15
+ const p = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
16
+ const h = Buffer.alloc(5)
17
+ h[0] = type
18
+ h.writeUInt32BE(p.length, 1)
19
+ return Buffer.concat([h, p])
20
+ }
21
+
22
+ /** Streaming frame de-chunker: reassembles frames across arbitrary TCP/unix chunk boundaries
23
+ * (a frame may arrive split, or several may arrive coalesced). Calls onFrame per complete frame. */
24
+ export function makeFramer(onFrame: (type: number, payload: Buffer) => void): (chunk: Uint8Array | Buffer) => void {
25
+ let buf: Buffer = EMPTY
26
+ return chunk => {
27
+ buf = buf.length ? Buffer.concat([buf, Buffer.from(chunk)]) : Buffer.from(chunk)
28
+ while (buf.length >= 5) {
29
+ const type = buf[0]!
30
+ const len = buf.readUInt32BE(1)
31
+ if (buf.length < 5 + len) break
32
+ onFrame(type, buf.subarray(5, 5 + len))
33
+ buf = buf.subarray(5 + len)
34
+ }
35
+ }
36
+ }
37
+
38
+ export function sizePayload(cols: number, rows: number): Buffer {
39
+ const b = Buffer.alloc(4)
40
+ b.writeUInt16BE(cols || 80, 0)
41
+ b.writeUInt16BE(rows || 24, 2)
42
+ return b
43
+ }
44
+
45
+ export function parseSize(payload: Buffer): { cols: number; rows: number } {
46
+ return { cols: payload.readUInt16BE(0), rows: payload.readUInt16BE(2) }
47
+ }
48
+
49
+ // ── capability responder ──────────────────────────────────────────────────────
50
+ // The child (claude/codex TUI) emits terminal capability QUERIES (device attributes, cursor
51
+ // position, theme) and BLOCKS its first paint until they are answered. tmux answers them; the
52
+ // supervisor daemon must too. @xterm/headless auto-answers DA1/DSR via onData; these are the
53
+ // ones it does NOT: secondary/tertiary DA (`>q`, `>c`) and the OSC-11 theme query.
54
+
55
+ export const QUERY_RE = /\x1b\[0?c|\x1b\[>\d*[qc]|\x1b\[6n|\x1b\]11;\?(?:\x07|\x1b\\)/g
56
+
57
+ /** Supplementary capability answers to write back INTO the child for queries @xterm does not
58
+ * auto-answer. Returns the response strings (possibly empty). Pure — testable without a pty. */
59
+ export function capabilityResponses(s: string): string[] {
60
+ const out: string[] = []
61
+ if (/\x1b\[>\d*q/.test(s)) out.push('\x1bP>|iapeer-pts(0.1)\x1b\\') // XTVERSION (DA2-style)
62
+ if (/\x1b\]11;\?(\x07|\x1b\\)/.test(s)) out.push('\x1b]11;rgb:1e1e/1e1e/1e1e\x07') // OSC-11 theme
63
+ if (/\x1b\[>\d*c/.test(s)) out.push('\x1b[>0;276;0c') // secondary DA
64
+ return out
65
+ }
66
+
67
+ /** Strip capability queries from a child-output chunk before forwarding to clients (the daemon
68
+ * is the authoritative responder — clients must not see/answer the queries themselves). */
69
+ export function stripQueries(s: string): string {
70
+ return s.replace(QUERY_RE, '')
71
+ }
72
+
73
+ // ── port-dep #1: terminal reset on every client exit path ───────────────────────
74
+ // The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
75
+ // focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
76
+ // They persist in the window after the client goes. A SIGHUP/window-close client that ran NOTHING
77
+ // leaked them → broke the NEXT reattach (mouse tracking eats the scroll wheel, kitty garbles keys,
78
+ // leftover modes freeze the render). Reset to a clean base on EVERY exit path; the next attach's
79
+ // repaint + the child's live re-render re-assert whatever the session actually needs.
80
+ export const TERM_RESET =
81
+ '\x1b[?1049l' + // leave alt-screen (normal buffer)
82
+ '\x1b[?2004l' + // bracketed-paste off
83
+ '\x1b[?1004l' + // focus-event reporting off
84
+ '\x1b[?2031l' + // theme/color-scheme-update off
85
+ '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l' + // ALL mouse tracking off
86
+ '\x1b[<u' + // pop kitty-keyboard flags
87
+ '\x1b[r' + // reset scroll region (DECSTBM) to full
88
+ '\x1b[?25h' + // show cursor
89
+ '\x1b[?7h' // wraparound on (default)
90
+
91
+ // ── port-dep #2: backpressure-safe socket writer ────────────────────────────────
92
+ // Bun's sock.write() of a large buffer accepts only the socket buffer (~8 KB) and RETURNS the
93
+ // count actually taken — the rest is DROPPED unless queued and re-sent on `drain`. The PoC's old
94
+ // code ignored the return, so a heavy-claude repaint frame (~90 KB serialize) arrived TRUNCATED
95
+ // (measured live: len=89305, only 8192 delivered) → the client's framer waited forever for a
96
+ // frame that never completed → reattach showed the STALE close-moment frame. This writer queues
97
+ // the unwritten tail and flushes it on drain, for BOTH the repaint snapshot and live forward.
98
+
99
+ export interface SocketWrite {
100
+ /** Write up to buf.length bytes; return the count ACCEPTED (Bun semantics), or <0 on a dead socket. */
101
+ (buf: Buffer): number
102
+ }
103
+
104
+ /** Per-socket backpressure queue. send() coalesces+writes and queues the unaccepted tail;
105
+ * flush() (call on the socket `drain` event) drains the queue. Dead once write throws / returns <0. */
106
+ export class BackpressureWriter {
107
+ private pending: Buffer = EMPTY
108
+ private dead = false
109
+ constructor(private readonly write: SocketWrite) {}
110
+
111
+ /** Queue and send `buf`. Returns false iff the socket is dead (caller should drop the client). */
112
+ send(buf: Buffer): boolean {
113
+ if (this.dead) return false
114
+ const data = this.pending.length ? Buffer.concat([this.pending, buf]) : buf
115
+ let n: number
116
+ try {
117
+ n = this.write(data)
118
+ } catch {
119
+ this.dead = true
120
+ return false
121
+ }
122
+ if (n < 0) {
123
+ this.dead = true
124
+ return false
125
+ }
126
+ this.pending = n < data.length ? data.subarray(n) : EMPTY
127
+ return true
128
+ }
129
+
130
+ /** Flush the queued tail (call on `drain`). Returns false iff the socket died flushing. */
131
+ flush(): boolean {
132
+ if (this.dead) return false
133
+ if (!this.pending.length) return true
134
+ let n: number
135
+ try {
136
+ n = this.write(this.pending)
137
+ } catch {
138
+ this.dead = true
139
+ return false
140
+ }
141
+ if (n < 0) {
142
+ this.dead = true
143
+ return false
144
+ }
145
+ this.pending = n < this.pending.length ? this.pending.subarray(n) : EMPTY
146
+ return true
147
+ }
148
+
149
+ get isDead(): boolean {
150
+ return this.dead
151
+ }
152
+ get queuedBytes(): number {
153
+ return this.pending.length
154
+ }
155
+ }