@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.
- package/package.json +2 -1
- package/src/cli/index.ts +9 -0
- package/src/shadow/gate.test.ts +249 -0
- package/src/shadow/gate.ts +372 -0
- package/src/shadow/index.ts +51 -28
- package/src/supervisor/client.ts +118 -0
- package/src/supervisor/daemon.test.ts +112 -0
- package/src/supervisor/daemon.ts +210 -0
- package/src/supervisor/index.ts +137 -0
- package/src/supervisor/paths.ts +14 -0
- package/src/supervisor/protocol.test.ts +131 -0
- package/src/supervisor/protocol.ts +155 -0
package/src/shadow/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
149
|
-
readyGate: { pty: rgPty, tmux: rgTmux,
|
|
150
|
-
liveness: { pty: livPty, tmux: livTmux,
|
|
151
|
-
...(
|
|
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=${
|
|
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:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
}
|