@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.
@@ -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
+ }