@agfpd/iapeer 0.2.64 → 0.2.65
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 +1 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/index.ts +16 -3
- package/src/cli/listTui.ts +7 -0
- package/src/daemon/main.ts +4 -0
- package/src/launch/hostComposerOccupied.test.ts +63 -0
- package/src/launch/index.ts +57 -24
- package/src/launch/invocation.test.ts +134 -0
- package/src/launch/invocation.ts +56 -0
- package/src/launch/ptyHost.test.ts +33 -0
- package/src/launch/ptyHost.ts +148 -0
- package/src/launch/readyGateModel.ts +47 -6
- package/src/lifecycle/index.ts +11 -1
- package/src/shadow/enumerate-skip.test.ts +65 -0
- package/src/shadow/index.ts +13 -37
- package/src/shadow/render.ts +60 -0
- package/src/supervisor/attached-marker.test.ts +84 -0
- package/src/supervisor/boot.test.ts +66 -0
- package/src/supervisor/boot.ts +34 -0
- package/src/supervisor/daemon.test.ts +66 -1
- package/src/supervisor/daemon.ts +146 -7
- package/src/supervisor/deliver.test.ts +58 -0
- package/src/supervisor/deliver.ts +120 -0
- package/src/supervisor/index.ts +76 -6
- package/src/supervisor/panelog.test.ts +95 -0
- package/src/supervisor/paths.ts +9 -0
- package/src/supervisor/protocol.test.ts +91 -0
- package/src/supervisor/protocol.ts +49 -0
- package/src/transport/composerQueue.test.ts +30 -0
- package/src/transport/hostControl.test.ts +87 -0
- package/src/transport/hostDeliver.test.ts +102 -0
- package/src/transport/index.ts +186 -24
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { FRAME_DATA, FRAME_RESIZE, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
@@ -109,4 +109,69 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
|
|
|
109
109
|
expect(fresh).toBe(true)
|
|
110
110
|
second.close()
|
|
111
111
|
})
|
|
112
|
+
|
|
113
|
+
// Serving seam (slice b): a serve-spec is written before spawn, the detached daemon reads it, and
|
|
114
|
+
// the composed argv + env + cwd reach the child VERBATIM (with the pty-host TERM layered on top and
|
|
115
|
+
// TMUX stripped). A probe child dumps the markers it actually received — proving the supervisor
|
|
116
|
+
// serves a launch-composed invocation faithfully (the parity foundation; the env-identity ABI rides
|
|
117
|
+
// in here at serving time).
|
|
118
|
+
test('serving seam: composed argv + env + cwd reach the child verbatim (slice b)', async () => {
|
|
119
|
+
const servingSession = 'ptsserve'
|
|
120
|
+
const childCwd = realpathSync(mkdtempSync(join(tmpdir(), 'iapeer-serve-cwd-')))
|
|
121
|
+
const out = join(runDir, 'serve-probe.out')
|
|
122
|
+
try {
|
|
123
|
+
const serve = {
|
|
124
|
+
// dump what the child ACTUALLY received: a composed env var, its cwd, TERM, and TMUX-presence
|
|
125
|
+
argv: [
|
|
126
|
+
'sh',
|
|
127
|
+
'-c',
|
|
128
|
+
'printf "%s\\n%s\\n%s\\n%s\\n" "$SERVE_MARKER" "$(pwd -P)" "$TERM" "${TMUX:-no-tmux}" > "$OUTFILE"; exec sleep 30',
|
|
129
|
+
],
|
|
130
|
+
env: { SERVE_MARKER: 'identity-abi-xyz', OUTFILE: out, PATH: process.env.PATH ?? '', TMUX: 'should-be-stripped' },
|
|
131
|
+
cwd: childCwd,
|
|
132
|
+
}
|
|
133
|
+
const r = startSupervisorDaemon({ session: servingSession, runtime: 'claude', runDir, serve })
|
|
134
|
+
expect(r.state).toBe('started')
|
|
135
|
+
const wrote = await waitFor(() => existsSync(out) && readFileSync(out, 'utf8').trim().split('\n').length >= 4)
|
|
136
|
+
expect(wrote).toBe(true)
|
|
137
|
+
const [marker, pwd, term, tmux] = readFileSync(out, 'utf8').split('\n')
|
|
138
|
+
expect(marker).toBe('identity-abi-xyz') // composed env reached the child verbatim
|
|
139
|
+
expect(pwd).toBe(childCwd) // composed cwd honored
|
|
140
|
+
expect(term).toBe('xterm-256color') // pty-host TERM layered on top of the composed env
|
|
141
|
+
expect(tmux).toBe('no-tmux') // TMUX stripped — a served child never thinks it is inside tmux
|
|
142
|
+
} finally {
|
|
143
|
+
killSession(runDir, servingSession)
|
|
144
|
+
rmSync(childCwd, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Exit-cause (spawn-flip Ф0b-1 resilience-b): when the hosted child dies, the daemon records a
|
|
149
|
+
// `ev=session-exit … host=supervisor` line in exits.log — the cause the supervise-tick death
|
|
150
|
+
// classification + crash-loop accounting need (mirror of the tmux pane-died hook). Kill the child,
|
|
151
|
+
// assert the line lands.
|
|
152
|
+
test('exit-cause: the daemon records the hosted child death in exits.log (resilience-b)', async () => {
|
|
153
|
+
const exSession = 'ptsexit'
|
|
154
|
+
const exitLog = join(runDir, 'exits.log')
|
|
155
|
+
const childPidFile = join(runDir, 'exit-child.pid')
|
|
156
|
+
try {
|
|
157
|
+
const r = startSupervisorDaemon({
|
|
158
|
+
session: exSession,
|
|
159
|
+
runtime: 'tick',
|
|
160
|
+
runDir,
|
|
161
|
+
serve: { argv: ['sh', '-c', `echo $$ > '${childPidFile}'; exec sleep 30`], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, exitLogPath: exitLog },
|
|
162
|
+
})
|
|
163
|
+
expect(r.state).toBe('started')
|
|
164
|
+
const got = await waitFor(() => existsSync(childPidFile))
|
|
165
|
+
expect(got).toBe(true)
|
|
166
|
+
process.kill(Number(readFileSync(childPidFile, 'utf8').trim()), 'SIGKILL')
|
|
167
|
+
const recorded = await waitFor(() => existsSync(exitLog) && /ev=session-exit identity=ptsexit.*host=supervisor/.test(readFileSync(exitLog, 'utf8')))
|
|
168
|
+
expect(recorded).toBe(true) // the death cause is in exits.log → crash-loop accounting has it
|
|
169
|
+
} finally {
|
|
170
|
+
try {
|
|
171
|
+
killSession(runDir, exSession)
|
|
172
|
+
} catch {
|
|
173
|
+
/* */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
112
177
|
})
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// `bun build --compile` (pure JS; the Bun-native pty has no native addon — PoC-proven).
|
|
10
10
|
import { Terminal } from '@xterm/headless'
|
|
11
11
|
import { SerializeAddon } from '@xterm/addon-serialize'
|
|
12
|
-
import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
13
|
+
import { dirname } from 'node:path'
|
|
13
14
|
import {
|
|
14
15
|
BackpressureWriter,
|
|
15
16
|
FRAME_DATA,
|
|
@@ -20,7 +21,11 @@ import {
|
|
|
20
21
|
parseSize,
|
|
21
22
|
stripQueries,
|
|
22
23
|
} from './protocol.ts'
|
|
23
|
-
import { pidPath, sockPath } from './paths.ts'
|
|
24
|
+
import { attachedPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
25
|
+
import { nextBootAction, type BootPredicates } from './boot.ts'
|
|
26
|
+
import { modelToPlainText } from '../shadow/render.ts'
|
|
27
|
+
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
28
|
+
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
24
29
|
|
|
25
30
|
/** Known runtime launchers; `tick` is a deterministic test stream (no TUI noise) for hermetic
|
|
26
31
|
* spawn/attach/reattach tests — it never needs a real claude/codex on the box. */
|
|
@@ -30,6 +35,20 @@ const KNOWN: Record<string, string> = {
|
|
|
30
35
|
}
|
|
31
36
|
const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n" "$i"; i=$((i+1)); sleep 0.3; done']
|
|
32
37
|
|
|
38
|
+
// Boot-driver (serving slice a): the runtimes whose startup dialogs the supervisor knows how to
|
|
39
|
+
// answer off its model. 'tick' (and any unknown runtime) has no entry → no driver → the hermetic
|
|
40
|
+
// spawn/attach tests and the bare conduit are untouched.
|
|
41
|
+
const BOOT_ADAPTERS: Record<string, BootPredicates> = { codex: codexAdapter, claude: claudeAdapter }
|
|
42
|
+
// Poll cadence mirrors the launch primitive's proven 2 s boot loop (index.ts:377). The supervisor
|
|
43
|
+
// model is strictly FRESHER than capture-pane (bytes land in @xterm synchronously as they arrive),
|
|
44
|
+
// so 2 s leaves ample repaint time — the claude resume-picker never over-Downs, since bootDialogKeys
|
|
45
|
+
// returns Enter (not another Down) the moment the moved cursor renders '❯ 2.'. A swallowed key
|
|
46
|
+
// self-heals on the next tick (re-sent while the dialog is still up), same as the launch loop.
|
|
47
|
+
const BOOT_POLL_MS = 2000
|
|
48
|
+
// Stop driving after this long even if readiness is never observed (a hung/unknown startup); the
|
|
49
|
+
// driver is best-effort and never blocks the conduit, which keeps serving regardless.
|
|
50
|
+
const BOOT_DRIVE_MS = 120_000
|
|
51
|
+
|
|
33
52
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
34
53
|
interface PtyChild {
|
|
35
54
|
terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
|
|
@@ -44,6 +63,22 @@ export interface SupervisorDaemonOptions {
|
|
|
44
63
|
cwd?: string
|
|
45
64
|
/** Override the runtime launcher (else KNOWN[runtime] if it exists, else the bare name). */
|
|
46
65
|
bin?: string
|
|
66
|
+
/** SERVING SEAM (slice b): the composed runtime argv (buildLaunchInvocation). When set, the daemon
|
|
67
|
+
* serves THIS verbatim instead of the bare [bin] — the supervisor is a HOST, not a composer
|
|
68
|
+
* (launch/lifecycle composes argv+env; the supervisor, like tmux, just serves the invocation). */
|
|
69
|
+
argv?: string[]
|
|
70
|
+
/** SERVING SEAM (slice b): the composed child env (buildLaunchInvocation) — base for the child.
|
|
71
|
+
* The pty-host env (TERM set, TMUX/TMUX_PANE stripped) is layered ON TOP. Unset → process.env. */
|
|
72
|
+
env?: Record<string, string>
|
|
73
|
+
/** PANE-LOG (slice c): absolute path the child's RAW output bytes are appended to — `<logDir>/
|
|
74
|
+
* <identity>.log`, the SAME file + append semantics tmux `pipe-pane` writes today, so the observer
|
|
75
|
+
* / shadow / readyGateViewport tail a served session's log identically. Unset → no pane-log
|
|
76
|
+
* (throwaway/tick). */
|
|
77
|
+
paneLogPath?: string
|
|
78
|
+
/** EXIT-CAUSE (spawn-flip Ф0b resilience-b): exits.log path. On child exit the daemon appends a
|
|
79
|
+
* `ev=session-exit … dead_status/dead_signal host=supervisor` line (mirroring the tmux pane-died
|
|
80
|
+
* hook), so the supervise-tick death classification + crash-loop accounting have the cause. */
|
|
81
|
+
exitLogPath?: string
|
|
47
82
|
cols?: number
|
|
48
83
|
rows?: number
|
|
49
84
|
}
|
|
@@ -63,14 +98,53 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
63
98
|
xterm.loadAddon(ser)
|
|
64
99
|
|
|
65
100
|
const bin = opts.bin ?? (KNOWN[runtime] && existsSync(KNOWN[runtime]!) ? KNOWN[runtime]! : runtime)
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
// SERVING SEAM (slice b): opts.argv (launch-composed, carries the system-prompt swap + PEER_START_ARGS)
|
|
102
|
+
// is served verbatim; else the bare conduit ([bin], or the deterministic tick stream for tests).
|
|
103
|
+
const CMD = opts.argv ?? (runtime === 'tick' ? TICK_CMD : [bin])
|
|
104
|
+
// pty-host env layered ON TOP of the composed child env (opts.env, = buildLaunchInvocation): force a
|
|
105
|
+
// real TERM and strip TMUX/TMUX_PANE so a served child never believes it is inside tmux. The identity
|
|
106
|
+
// ABI (PEER_IDENTITY, IAPEER_BEARER, …) rides in opts.env underneath, untouched.
|
|
107
|
+
const env = { ...(opts.env ?? process.env), TERM: 'xterm-256color' } as Record<string, string>
|
|
68
108
|
delete env.TMUX
|
|
69
109
|
delete env.TMUX_PANE
|
|
70
110
|
|
|
111
|
+
// PANE-LOG (slice c): append the child's RAW output bytes to <logDir>/<identity>.log, byte-identical
|
|
112
|
+
// to tmux `pipe-pane … 'cat >> <log>'`. The fd is opened once (append); every chunk is written as a
|
|
113
|
+
// raw Buffer (writeSync(fd, buf)) — NEVER via a string (Bun re-encodes a latin1 string to UTF-8 and
|
|
114
|
+
// corrupts multibyte glyphs like ❯/›; the canonical latin1/Buffer grabli). Parent dir 0700, matching
|
|
115
|
+
// launch's mkdir before pipe-pane. Unset paneLogPath → no log (throwaway/tick).
|
|
116
|
+
let paneLogFd = -1
|
|
117
|
+
if (opts.paneLogPath) {
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(opts.paneLogPath), { recursive: true, mode: 0o700 })
|
|
120
|
+
paneLogFd = openSync(opts.paneLogPath, 'a')
|
|
121
|
+
} catch {
|
|
122
|
+
paneLogFd = -1 // best-effort: a log open hiccup never blocks serving the session
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
71
126
|
const clients = new Set<unknown>()
|
|
72
127
|
const writers = new WeakMap<object, BackpressureWriter>()
|
|
73
128
|
const framers = new WeakMap<object, (chunk: Uint8Array) => void>()
|
|
129
|
+
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
130
|
+
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
131
|
+
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
132
|
+
// composer text), never for the AI's own composer output when no one is attached. Best-effort: a
|
|
133
|
+
// marker fs hiccup must never break the conduit.
|
|
134
|
+
const attachedMarker = attachedPath(runDir, session)
|
|
135
|
+
const syncAttached = (): void => {
|
|
136
|
+
try {
|
|
137
|
+
if (clients.size > 0) writeFileSync(attachedMarker, '')
|
|
138
|
+
else if (existsSync(attachedMarker)) unlinkSync(attachedMarker)
|
|
139
|
+
} catch {
|
|
140
|
+
/* */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// CRASH-ROBUSTNESS (boris): a crashed predecessor daemon skips shutdown-cleanup, so a STALE
|
|
144
|
+
// `.attached` could survive into this fresh daemon — and a stale-present marker would make the
|
|
145
|
+
// warm-deliver gate believe a human is attached when none is, holding deliveries forever (the
|
|
146
|
+
// anti-bug inverted). A fresh daemon has ZERO clients, so clear the marker on START (idempotent).
|
|
147
|
+
syncAttached()
|
|
74
148
|
const writerFor = (s: { write(b: Buffer): number }): BackpressureWriter => {
|
|
75
149
|
let w = writers.get(s as object)
|
|
76
150
|
if (!w) {
|
|
@@ -96,13 +170,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
96
170
|
name: 'xterm-256color',
|
|
97
171
|
data(_t: unknown, d: Uint8Array | string) {
|
|
98
172
|
const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
|
|
173
|
+
// PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
|
|
174
|
+
// for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
|
|
175
|
+
if (paneLogFd >= 0)
|
|
176
|
+
try {
|
|
177
|
+
writeSync(paneLogFd, buf)
|
|
178
|
+
} catch {
|
|
179
|
+
/* best-effort */
|
|
180
|
+
}
|
|
99
181
|
const s = buf.toString('latin1')
|
|
100
182
|
xterm.write(buf) // authoritative model
|
|
101
183
|
for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
|
|
102
184
|
const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
103
185
|
if (fwd.length) for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
|
|
104
186
|
},
|
|
105
|
-
exit() {
|
|
187
|
+
exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
|
|
188
|
+
// resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log,
|
|
189
|
+
// mirroring the tmux pane-died hook (ev=session-exit dead_status/dead_signal), so the daemon's
|
|
190
|
+
// supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
|
|
191
|
+
// distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
|
|
192
|
+
// onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
|
|
193
|
+
if (opts.exitLogPath) {
|
|
194
|
+
try {
|
|
195
|
+
mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) // ensure the exits.log dir exists
|
|
196
|
+
const c = child as { exitCode?: number | null; signalCode?: number | null }
|
|
197
|
+
const status = exitCode ?? c.exitCode ?? ''
|
|
198
|
+
const signal = signalCode ?? c.signalCode ?? ''
|
|
199
|
+
appendFileSync(
|
|
200
|
+
opts.exitLogPath,
|
|
201
|
+
`ts=${new Date().toISOString()} ev=session-exit identity=${session} dead_status=${status} dead_signal=${signal} host=supervisor\n`,
|
|
202
|
+
)
|
|
203
|
+
} catch {
|
|
204
|
+
/* best-effort */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
106
207
|
shutdown()
|
|
107
208
|
},
|
|
108
209
|
},
|
|
@@ -152,21 +253,24 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
152
253
|
open(s: { write(b: Buffer): number }) {
|
|
153
254
|
framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
|
|
154
255
|
clients.add(s)
|
|
256
|
+
syncAttached() // ≥1 client now attached → marker present
|
|
155
257
|
repaint(s) // UNCONDITIONAL repaint on attach (tmux-like)
|
|
156
258
|
},
|
|
157
259
|
data(s: object, d: Uint8Array) {
|
|
158
260
|
framers.get(s)?.(d)
|
|
159
261
|
},
|
|
160
262
|
drain(s: object) {
|
|
161
|
-
if (!writers.get(s)?.flush()) clients.delete(s) // writable again → flush queued tail
|
|
263
|
+
if (!writers.get(s)?.flush()) { clients.delete(s); syncAttached() } // writable again → flush queued tail
|
|
162
264
|
},
|
|
163
265
|
close(s: object) {
|
|
164
266
|
clients.delete(s)
|
|
165
267
|
writers.delete(s)
|
|
268
|
+
syncAttached()
|
|
166
269
|
},
|
|
167
270
|
error(s: object) {
|
|
168
271
|
clients.delete(s)
|
|
169
272
|
writers.delete(s)
|
|
273
|
+
syncAttached()
|
|
170
274
|
},
|
|
171
275
|
},
|
|
172
276
|
})
|
|
@@ -176,6 +280,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
176
280
|
function shutdown(): void {
|
|
177
281
|
if (down) return
|
|
178
282
|
down = true
|
|
283
|
+
if (paneLogFd >= 0)
|
|
284
|
+
try {
|
|
285
|
+
closeSync(paneLogFd)
|
|
286
|
+
} catch {
|
|
287
|
+
/* */
|
|
288
|
+
}
|
|
179
289
|
try {
|
|
180
290
|
child.kill('SIGKILL')
|
|
181
291
|
} catch {
|
|
@@ -186,7 +296,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
186
296
|
} catch {
|
|
187
297
|
/* */
|
|
188
298
|
}
|
|
189
|
-
for (const f of [sock, pid]) {
|
|
299
|
+
for (const f of [sock, pid, servePath(runDir, session), attachedMarker]) {
|
|
190
300
|
try {
|
|
191
301
|
unlinkSync(f)
|
|
192
302
|
} catch {
|
|
@@ -207,4 +317,33 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
207
317
|
/* */
|
|
208
318
|
}
|
|
209
319
|
})
|
|
320
|
+
|
|
321
|
+
// ── boot-dialog driver (serving slice a) ────────────────────────────────────────
|
|
322
|
+
// For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume)
|
|
323
|
+
// off the AUTHORITATIVE @xterm model so the session reaches a ready input surface UNDER the
|
|
324
|
+
// supervisor — exactly what the launch primitive does over `tmux capture-pane` (read) + `send-keys`
|
|
325
|
+
// (write), but model-read (modelToPlainText) + raw pty-byte-write (keysToBytes). The cursor-key
|
|
326
|
+
// byte follows the model's live DECCKM mode (xterm.modes.applicationCursorKeysMode) — tmux keys off
|
|
327
|
+
// the same mode, so hooks-review/resume-picker Downs are byte-identical. DARK: the supervisor
|
|
328
|
+
// serves nothing on the live fleet; this drives only throwaway sessions until the serving flip.
|
|
329
|
+
const bootAdapter = BOOT_ADAPTERS[runtime]
|
|
330
|
+
if (bootAdapter) {
|
|
331
|
+
const driveDeadline = Date.now() + BOOT_DRIVE_MS
|
|
332
|
+
const driver = setInterval(() => {
|
|
333
|
+
if (down) {
|
|
334
|
+
clearInterval(driver)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
let action
|
|
338
|
+
try {
|
|
339
|
+
const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
|
|
340
|
+
action = nextBootAction(bootAdapter, modelToPlainText(xterm, cols, rows), enc)
|
|
341
|
+
} catch {
|
|
342
|
+
return // transient model read — retry next tick
|
|
343
|
+
}
|
|
344
|
+
if (action.kind === 'dialog') child.terminal.write(action.bytes)
|
|
345
|
+
else if (action.kind === 'ready' || Date.now() > driveDeadline) clearInterval(driver)
|
|
346
|
+
}, BOOT_POLL_MS)
|
|
347
|
+
;(driver as { unref?: () => void }).unref?.()
|
|
348
|
+
}
|
|
210
349
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { deliverToHost } from './deliver.ts'
|
|
6
|
+
import { killSession, startSupervisorDaemon } from './index.ts'
|
|
7
|
+
|
|
8
|
+
// The deliver leaf is the host equivalent of tmux load-buffer/paste-buffer/send-keys: it must put the
|
|
9
|
+
// message on the hosted child's pty input as a bracketed paste followed by a CR. Hermetic: a probe
|
|
10
|
+
// child captures its raw stdin (stty raw bypasses the line discipline) so we assert the EXACT bytes
|
|
11
|
+
// the child received. Guarded on Bun-native pty.
|
|
12
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
13
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
14
|
+
const d = bunPty ? describe : describe.skip
|
|
15
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
16
|
+
|
|
17
|
+
d('deliverToHost (supervisor-socket delivery — bracketed paste + CR)', () => {
|
|
18
|
+
test('the child receives exactly: ESC[200~ <message> ESC[201~ CR', async () => {
|
|
19
|
+
const runDir = mkdtempSync(join(tmpdir(), 'iapeer-deliver-'))
|
|
20
|
+
const out = join(runDir, 'stdin.bin')
|
|
21
|
+
const msg = 'hello-deliver-❯'
|
|
22
|
+
const expected = `\x1b[200~${msg}\x1b[201~\r`
|
|
23
|
+
const n = Buffer.byteLength(expected, 'utf8')
|
|
24
|
+
try {
|
|
25
|
+
// probe child: raw mode (no line-discipline cooking), read exactly n bytes, dump to a file
|
|
26
|
+
const argv = ['sh', '-c', `stty raw -echo; head -c ${n} > '${out}'; sleep 5`]
|
|
27
|
+
const r = startSupervisorDaemon({ session: 'dlv', runtime: 'tick', runDir, serve: { argv, env: { PATH: process.env.PATH ?? '' }, cwd: runDir } })
|
|
28
|
+
expect(r.state).toBe('started')
|
|
29
|
+
await sleep(400) // let the probe reach raw mode
|
|
30
|
+
const res = await deliverToHost(runDir, 'dlv', msg)
|
|
31
|
+
expect(res.ok).toBe(true)
|
|
32
|
+
// wait for the child to have captured n bytes
|
|
33
|
+
const deadline = Date.now() + 3000
|
|
34
|
+
while (Date.now() < deadline && !(existsSync(out) && readFileSync(out).length >= n)) await sleep(50)
|
|
35
|
+
const got = existsSync(out) ? readFileSync(out) : Buffer.alloc(0)
|
|
36
|
+
expect(got.equals(Buffer.from(expected, 'utf8'))).toBe(true) // bracketed paste + CR, byte-exact
|
|
37
|
+
} finally {
|
|
38
|
+
try {
|
|
39
|
+
killSession(runDir, 'dlv')
|
|
40
|
+
} catch {
|
|
41
|
+
/* */
|
|
42
|
+
}
|
|
43
|
+
await sleep(300)
|
|
44
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('no supervisor socket → a clean error, not a throw', async () => {
|
|
49
|
+
const runDir = mkdtempSync(join(tmpdir(), 'iapeer-deliver-none-'))
|
|
50
|
+
try {
|
|
51
|
+
const res = await deliverToHost(runDir, 'nope', 'x')
|
|
52
|
+
expect(res.ok).toBe(false)
|
|
53
|
+
expect(res.error).toContain('no supervisor socket')
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Supervisor DELIVER client (cutover Block 2 spawn-flip, Ф0a) — hands a message to a supervisor-
|
|
2
|
+
// HOSTED session over its unix socket: the host equivalent of tmux load-buffer → paste-buffer -p →
|
|
3
|
+
// send-keys Enter. @xterm-FREE BY CONSTRUCTION — imports ONLY protocol.ts (pure frames + the proven
|
|
4
|
+
// BackpressureWriter) + paths.ts (no @xterm) — so launch can deliver to a hosted session WITHOUT
|
|
5
|
+
// pulling the @xterm daemon into the hot path. Bracketed-paste wraps the message (same intent as
|
|
6
|
+
// tmux paste-buffer -p: the TUI takes it as one atomic paste, not retyped keystrokes), then a CR
|
|
7
|
+
// submits — the SAME two-step the cold/warm tmux delivery uses. Backpressure-safe: a multi-KB
|
|
8
|
+
// envelope past the socket buffer is queued + flushed on drain, never truncated (the same failure
|
|
9
|
+
// mode that staled heavy reattach — protocol.ts port-dep #2).
|
|
10
|
+
import { BackpressureWriter, FRAME_DATA, frame } from './protocol.ts'
|
|
11
|
+
import { sockPath } from './paths.ts'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
15
|
+
|
|
16
|
+
export interface DeliverResult {
|
|
17
|
+
ok: boolean
|
|
18
|
+
error?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Deliver `message` to a hosted session over its supervisor socket: connect → bracketed-paste →
|
|
22
|
+
* (settle) → CR → close. submitDelayMs mirrors the tmux boot delivery's 300 ms paste→Enter settle. */
|
|
23
|
+
export async function deliverToHost(
|
|
24
|
+
runDir: string,
|
|
25
|
+
session: string,
|
|
26
|
+
message: string,
|
|
27
|
+
opts: { submitDelayMs?: number } = {},
|
|
28
|
+
): Promise<DeliverResult> {
|
|
29
|
+
const sock = sockPath(runDir, session)
|
|
30
|
+
if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
|
|
31
|
+
let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
|
|
32
|
+
let writer: BackpressureWriter | undefined
|
|
33
|
+
try {
|
|
34
|
+
conn = await Bun.connect({
|
|
35
|
+
unix: sock,
|
|
36
|
+
socket: {
|
|
37
|
+
drain() {
|
|
38
|
+
writer?.flush()
|
|
39
|
+
},
|
|
40
|
+
data() {
|
|
41
|
+
/* the daemon forwards screen output; the deliver client ignores it */
|
|
42
|
+
},
|
|
43
|
+
error() {
|
|
44
|
+
/* surfaced via the writer's dead state */
|
|
45
|
+
},
|
|
46
|
+
close() {
|
|
47
|
+
/* */
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
const c = conn
|
|
52
|
+
writer = new BackpressureWriter(b => c.write(b))
|
|
53
|
+
const paste = `\x1b[200~${message}\x1b[201~` // bracketed paste — atomic, like paste-buffer -p
|
|
54
|
+
if (!writer.send(frame(FRAME_DATA, Buffer.from(paste, 'utf8')))) return { ok: false, error: 'socket dead during paste' }
|
|
55
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'paste not fully flushed (socket stalled)' }
|
|
56
|
+
await sleep(opts.submitDelayMs ?? 300)
|
|
57
|
+
if (!writer.send(frame(FRAME_DATA, Buffer.from('\r', 'utf8')))) return { ok: false, error: 'socket dead during submit' }
|
|
58
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'submit not fully flushed (socket stalled)' }
|
|
59
|
+
await sleep(50) // let the CR land before we close the connection
|
|
60
|
+
return { ok: true }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { ok: false, error: `deliver to host failed: ${(e as Error).message}` }
|
|
63
|
+
} finally {
|
|
64
|
+
try {
|
|
65
|
+
conn?.end()
|
|
66
|
+
} catch {
|
|
67
|
+
/* */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Wait until the writer's queued tail has flushed (drain-driven), or time out. */
|
|
73
|
+
async function drainFully(writer: BackpressureWriter, timeoutMs = 4000): Promise<boolean> {
|
|
74
|
+
const end = Date.now() + timeoutMs
|
|
75
|
+
while (writer.queuedBytes > 0 && !writer.isDead && Date.now() < end) await sleep(20)
|
|
76
|
+
return writer.queuedBytes === 0 && !writer.isDead
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send raw CONTROL bytes to a hosted session over its supervisor socket — the host equivalent of
|
|
81
|
+
* `tmux send-keys` for the control channel (interrupt / compact). Each chunk is ALREADY the exact
|
|
82
|
+
* keystrokes (translated from the send-keys vocabulary by keysToBytes upstream), sent as a FRAME_DATA
|
|
83
|
+
* write — NOT bracketed-pasted and NOT auto-submitted: an Escape must arrive as a bare ESC, never a
|
|
84
|
+
* pasted literal. `stepDelayMs` paces a multi-step plan after each step, mirroring executeControl's
|
|
85
|
+
* tmux loop (e.g. compact's '/compact' then a 300 ms settle then Enter). Backpressure-safe per step.
|
|
86
|
+
*/
|
|
87
|
+
export async function sendControlToHost(
|
|
88
|
+
runDir: string,
|
|
89
|
+
session: string,
|
|
90
|
+
chunks: Buffer[],
|
|
91
|
+
opts: { stepDelayMs?: number } = {},
|
|
92
|
+
): Promise<DeliverResult> {
|
|
93
|
+
const sock = sockPath(runDir, session)
|
|
94
|
+
if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
|
|
95
|
+
let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
|
|
96
|
+
let writer: BackpressureWriter | undefined
|
|
97
|
+
try {
|
|
98
|
+
conn = await Bun.connect({
|
|
99
|
+
unix: sock,
|
|
100
|
+
socket: { drain() { writer?.flush() }, data() { /* */ }, error() { /* */ }, close() { /* */ } },
|
|
101
|
+
})
|
|
102
|
+
const c = conn
|
|
103
|
+
writer = new BackpressureWriter(b => c.write(b))
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
if (!writer.send(frame(FRAME_DATA, chunk))) return { ok: false, error: 'socket dead during control send' }
|
|
106
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'control not fully flushed (socket stalled)' }
|
|
107
|
+
if (opts.stepDelayMs) await sleep(opts.stepDelayMs)
|
|
108
|
+
}
|
|
109
|
+
await sleep(50) // let the last chunk land before we close the connection
|
|
110
|
+
return { ok: true }
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { ok: false, error: `control to host failed: ${(e as Error).message}` }
|
|
113
|
+
} finally {
|
|
114
|
+
try {
|
|
115
|
+
conn?.end()
|
|
116
|
+
} catch {
|
|
117
|
+
/* */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/supervisor/index.ts
CHANGED
|
@@ -5,16 +5,35 @@
|
|
|
5
5
|
// serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
|
|
6
6
|
// binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
|
|
7
7
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
8
|
-
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
|
-
import { runSupervisorDaemon } from './daemon.ts'
|
|
11
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
12
|
-
import { defaultRunDir, logPath, pidPath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
// daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
|
|
14
|
+
// daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
|
|
15
|
+
// startSupervisorDaemon to host a flag-on peer — never pulls @xterm into the launch hot path.
|
|
15
16
|
export { runSupervisorClient, sessionAlive } from './client.ts'
|
|
16
17
|
export { defaultRunDir, sockPath, pidPath } from './paths.ts'
|
|
17
18
|
|
|
19
|
+
/** The composed invocation a detached daemon serves (slice b): buildLaunchInvocation output + the
|
|
20
|
+
* resolved cwd. Serialized to `<runDir>/<session>.serve.json` (0600) so the detached daemon process
|
|
21
|
+
* picks it up — the seam by which launch hands the supervisor a real session at the spawn-flip. */
|
|
22
|
+
export interface ServeSpec {
|
|
23
|
+
argv: string[]
|
|
24
|
+
env: Record<string, string>
|
|
25
|
+
cwd: string
|
|
26
|
+
/** Pane-log path (slice c): `<logDir>/<identity>.log` — where the daemon appends the child's raw
|
|
27
|
+
* output, byte-identical to tmux pipe-pane, so the observer/shadow read a served session's log. */
|
|
28
|
+
paneLogPath?: string
|
|
29
|
+
/** Exit-cause log (spawn-flip Ф0b): exits.log — the daemon appends the child's death cause here. */
|
|
30
|
+
exitLogPath?: string
|
|
31
|
+
/** Pty geometry (spawn-flip Ф0a): match the tmux launch's 220x50 so the served child + the pane-log
|
|
32
|
+
* model render at the SAME size launch reads readiness at. Unset → daemon default (80x30). */
|
|
33
|
+
cols?: number
|
|
34
|
+
rows?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export interface StartDaemonOptions {
|
|
19
38
|
session: string
|
|
20
39
|
runtime: string
|
|
@@ -23,10 +42,24 @@ export interface StartDaemonOptions {
|
|
|
23
42
|
* `daemon <session> <runtime> --run-dir <runDir>` is appended. Serving-prod injects the
|
|
24
43
|
* installed-binary argv here. */
|
|
25
44
|
daemonArgv?: string[]
|
|
45
|
+
/** Composed invocation to SERVE (slice b). When set, written to the serve-spec file before spawn so
|
|
46
|
+
* the detached daemon serves it; absent → a bare conduit (throwaway/tick). */
|
|
47
|
+
serve?: ServeSpec
|
|
26
48
|
/** Max ms to wait for the daemon socket+pid to appear (default 5000). */
|
|
27
49
|
timeoutMs?: number
|
|
28
50
|
}
|
|
29
51
|
|
|
52
|
+
/** Read the serve-spec for a detached daemon, or undefined for a bare session. */
|
|
53
|
+
function readServeSpec(runDir: string, session: string): ServeSpec | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const s = JSON.parse(readFileSync(servePath(runDir, session), 'utf8')) as ServeSpec
|
|
56
|
+
if (Array.isArray(s.argv) && s.env && typeof s.cwd === 'string') return s
|
|
57
|
+
} catch {
|
|
58
|
+
/* no / invalid serve spec → bare session */
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
export interface StartDaemonResult {
|
|
31
64
|
state: 'started' | 'already-running' | 'failed'
|
|
32
65
|
pid?: number
|
|
@@ -45,6 +78,15 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
|
|
|
45
78
|
} catch {
|
|
46
79
|
/* */
|
|
47
80
|
}
|
|
81
|
+
// Serving seam (slice b): persist the composed invocation BEFORE spawn so the detached daemon serves
|
|
82
|
+
// it. 0600 — env may carry launch.env secrets. A stale spec from a prior run is overwritten or cleared.
|
|
83
|
+
if (opts.serve) writeFileSync(servePath(runDir, session), JSON.stringify(opts.serve), { mode: 0o600 })
|
|
84
|
+
else
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(servePath(runDir, session))
|
|
87
|
+
} catch {
|
|
88
|
+
/* no stale spec */
|
|
89
|
+
}
|
|
48
90
|
const out = openSync(logPath(runDir, session), 'a')
|
|
49
91
|
const argv = opts.daemonArgv ?? selfArgv()
|
|
50
92
|
const child = nodeSpawn(argv[0]!, [...argv.slice(1), 'daemon', session, runtime, '--run-dir', runDir], {
|
|
@@ -59,6 +101,13 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
|
|
|
59
101
|
}
|
|
60
102
|
Bun.sleepSync(80)
|
|
61
103
|
}
|
|
104
|
+
// Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
|
|
105
|
+
if (opts.serve)
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(servePath(runDir, session))
|
|
108
|
+
} catch {
|
|
109
|
+
/* */
|
|
110
|
+
}
|
|
62
111
|
return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
|
|
63
112
|
}
|
|
64
113
|
|
|
@@ -96,13 +145,34 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
|
|
|
96
145
|
const [cmd, a, b] = argv
|
|
97
146
|
const runDir = runDirFrom(argv)
|
|
98
147
|
switch (cmd) {
|
|
99
|
-
case 'daemon':
|
|
100
|
-
|
|
148
|
+
case 'daemon': {
|
|
149
|
+
// Serving seam (slice b): a serve-spec → serve the launch-composed argv/env/cwd; else bare.
|
|
150
|
+
const serve = readServeSpec(runDir, a!)
|
|
151
|
+
// @xterm loads HERE — in the detached daemon process only (dynamic import keeps it out of any
|
|
152
|
+
// caller that merely resolves startSupervisorDaemon, e.g. the launch hot path).
|
|
153
|
+
const { runSupervisorDaemon } = await import('./daemon.ts')
|
|
154
|
+
runSupervisorDaemon({
|
|
155
|
+
session: a!,
|
|
156
|
+
runtime: b || 'claude',
|
|
157
|
+
runDir,
|
|
158
|
+
...(serve
|
|
159
|
+
? {
|
|
160
|
+
argv: serve.argv,
|
|
161
|
+
env: serve.env,
|
|
162
|
+
cwd: serve.cwd,
|
|
163
|
+
paneLogPath: serve.paneLogPath,
|
|
164
|
+
exitLogPath: serve.exitLogPath,
|
|
165
|
+
cols: serve.cols,
|
|
166
|
+
rows: serve.rows,
|
|
167
|
+
}
|
|
168
|
+
: {}),
|
|
169
|
+
})
|
|
101
170
|
// The daemon is event-driven: runSupervisorDaemon RETURNS after wiring the pty + socket
|
|
102
171
|
// server, and the event loop keeps the process alive. Await a never-resolving promise so the
|
|
103
172
|
// caller's `process.exit` does NOT fire — shutdown() (child-exit / SIGTERM) owns the exit.
|
|
104
173
|
await new Promise<never>(() => {})
|
|
105
174
|
return 0 // unreachable
|
|
175
|
+
}
|
|
106
176
|
case 'start': {
|
|
107
177
|
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
108
178
|
console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
|