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