@agfpd/iapeer 0.2.63 → 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 +81 -25
- 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.test.ts +72 -0
- package/src/launch/readyGateModel.ts +140 -0
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-3 slice 3c-1 — the supervisor `.attached` marker (the gate signal
|
|
2
|
+
// `hasAttachedSupervisorClient` reads to hold hosted delivery ONLY for a real attached human).
|
|
3
|
+
// boris's three faces: (1) present ⟺ clients.size>0; (2) ALL detach paths incl. ABNORMAL (abrupt
|
|
4
|
+
// terminate → error/close), not just a clean Ctrl-]; (3) CRASH-ROBUSTNESS — a crashed predecessor
|
|
5
|
+
// skips shutdown-cleanup, so a stale marker must be cleared on daemon START. Real detached daemon over
|
|
6
|
+
// a real unix socket (tick runtime — no codex needed); the marker is read CROSS-PROCESS, as in prod.
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { frame, FRAME_RESIZE, sizePayload } from './protocol.ts'
|
|
12
|
+
import { killSession, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
|
|
13
|
+
import { attachedPath } from './paths.ts'
|
|
14
|
+
|
|
15
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
16
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
17
|
+
const d = bunPty ? describe : describe.skip
|
|
18
|
+
|
|
19
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
20
|
+
async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 40): Promise<boolean> {
|
|
21
|
+
const end = Date.now() + timeoutMs
|
|
22
|
+
while (Date.now() < end) { if (pred()) return true; await sleep(step) }
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Client { end(): void; terminate(): void }
|
|
27
|
+
async function attach(runDir: string, session: string): Promise<Client> {
|
|
28
|
+
const sock = await Bun.connect({
|
|
29
|
+
unix: sockPath(runDir, session),
|
|
30
|
+
socket: { open(s) { s.write(frame(FRAME_RESIZE, sizePayload(80, 24))) }, data() { /* */ } },
|
|
31
|
+
})
|
|
32
|
+
return {
|
|
33
|
+
end: () => { try { sock.end() } catch { /* */ } },
|
|
34
|
+
terminate: () => { try { sock.terminate() } catch { /* */ } },
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dirs: string[] = []
|
|
39
|
+
const mkRunDir = (): string => { const d2 = mkdtempSync(join(tmpdir(), 'iapeer-attmark-')); dirs.push(d2); return d2 }
|
|
40
|
+
afterEach(() => { for (const d2 of dirs.splice(0)) rmSync(d2, { recursive: true, force: true }) })
|
|
41
|
+
|
|
42
|
+
d('supervisor .attached marker (the hosted occupancy gate signal)', () => {
|
|
43
|
+
test('present ⟺ clients.size>0 across multiple clients (no stuck "attached" on partial detach)', async () => {
|
|
44
|
+
const runDir = mkRunDir(), session = 'mk'
|
|
45
|
+
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
46
|
+
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
47
|
+
const marker = attachedPath(runDir, session)
|
|
48
|
+
expect(existsSync(marker)).toBe(false) // no client yet → absent
|
|
49
|
+
const c1 = await attach(runDir, session)
|
|
50
|
+
expect(await waitFor(() => existsSync(marker))).toBe(true) // ≥1 attached → present
|
|
51
|
+
const c2 = await attach(runDir, session)
|
|
52
|
+
await sleep(150)
|
|
53
|
+
expect(existsSync(marker)).toBe(true) // 2 attached → still present
|
|
54
|
+
c1.end()
|
|
55
|
+
await sleep(250)
|
|
56
|
+
expect(existsSync(marker)).toBe(true) // 1 still attached → NOT stuck-absent, NOT removed early
|
|
57
|
+
c2.end()
|
|
58
|
+
expect(await waitFor(() => !existsSync(marker))).toBe(true) // 0 attached → absent
|
|
59
|
+
killSession(runDir, session)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('ABNORMAL detach (abrupt terminate, not a clean Ctrl-]) → marker still removed', async () => {
|
|
63
|
+
const runDir = mkRunDir(), session = 'ab'
|
|
64
|
+
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
65
|
+
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
66
|
+
const marker = attachedPath(runDir, session)
|
|
67
|
+
const c = await attach(runDir, session)
|
|
68
|
+
expect(await waitFor(() => existsSync(marker))).toBe(true)
|
|
69
|
+
c.terminate() // abrupt RST — daemon sees error/close, NOT a graceful protocol detach
|
|
70
|
+
expect(await waitFor(() => !existsSync(marker))).toBe(true) // the error/close path ALSO syncs the marker
|
|
71
|
+
killSession(runDir, session)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('CRASH-ROBUSTNESS: a stale marker is cleared on daemon START (crash skips shutdown-cleanup)', async () => {
|
|
75
|
+
const runDir = mkRunDir(), session = 'cr'
|
|
76
|
+
const marker = attachedPath(runDir, session)
|
|
77
|
+
writeFileSync(marker, '') // a crashed predecessor left this present, with no live client
|
|
78
|
+
expect(existsSync(marker)).toBe(true)
|
|
79
|
+
startSupervisorDaemon({ session, runtime: 'tick', runDir }) // fresh daemon = 0 clients
|
|
80
|
+
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
81
|
+
expect(await waitFor(() => !existsSync(marker))).toBe(true) // start-cleanup removed the stale lie
|
|
82
|
+
killSession(runDir, session)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
3
|
+
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
4
|
+
import { nextBootAction, type BootAction } from './boot.ts'
|
|
5
|
+
|
|
6
|
+
// Fed the REAL runtime adapters (codexAdapter/claudeAdapter) — not fakes — so the test proves the
|
|
7
|
+
// supervisor answers the exact dialogs the launch primitive answers, with byte-correct keys.
|
|
8
|
+
const bytesOf = (a: BootAction): Buffer => (a.kind === 'dialog' ? a.bytes : Buffer.alloc(0))
|
|
9
|
+
|
|
10
|
+
describe('nextBootAction — codex startup dialogs answered off the model', () => {
|
|
11
|
+
test('dir-trust → Enter (CR)', () => {
|
|
12
|
+
const a = nextBootAction(codexAdapter, 'Do you trust the contents of this directory?\n 1. Yes, continue')
|
|
13
|
+
expect(a.kind).toBe('dialog')
|
|
14
|
+
expect(bytesOf(a)).toEqual(Buffer.from('\r'))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('update offer → decline [2,Enter], and NOT ready while "Press enter to continue" is up', () => {
|
|
18
|
+
const pane = 'Update available! 9.9.9\nPress enter to continue'
|
|
19
|
+
const a = nextBootAction(codexAdapter, pane)
|
|
20
|
+
expect(a.kind).toBe('dialog')
|
|
21
|
+
expect(bytesOf(a)).toEqual(Buffer.from('2\r'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('hooks-review → [Down,Enter]; the cursor byte follows the model cursor mode', () => {
|
|
25
|
+
const pane = 'Hooks need review\n 1. Review 2. Trust all and continue'
|
|
26
|
+
expect(bytesOf(nextBootAction(codexAdapter, pane))).toEqual(Buffer.from('\x1b[B\r')) // normal
|
|
27
|
+
expect(bytesOf(nextBootAction(codexAdapter, pane, { appCursorKeys: true }))).toEqual(Buffer.from('\x1bOB\r')) // DECCKM
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('composer rendered, no startup screen → ready', () => {
|
|
31
|
+
expect(nextBootAction(codexAdapter, 'a rotating tip line\n› ').kind).toBe('ready')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('splash up, composer not yet rendered → wait', () => {
|
|
35
|
+
expect(nextBootAction(codexAdapter, 'OpenAI Codex\n(loading session)').kind).toBe('wait')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('nextBootAction — claude startup dialogs answered off the model', () => {
|
|
40
|
+
test('folder-trust → Enter (CR)', () => {
|
|
41
|
+
const a = nextBootAction(claudeAdapter, 'Do you trust this folder?\n 1. Yes')
|
|
42
|
+
expect(a.kind).toBe('dialog')
|
|
43
|
+
expect(bytesOf(a)).toEqual(Buffer.from('\r'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('resume-picker before the cursor reaches option 2 → Down (mode-correct cursor byte)', () => {
|
|
47
|
+
const pane = 'Resume from summary\n ❯ 1. Start fresh\n 2. Resume full session'
|
|
48
|
+
const a = nextBootAction(claudeAdapter, pane)
|
|
49
|
+
expect(a.kind).toBe('dialog')
|
|
50
|
+
expect(bytesOf(a)).toEqual(Buffer.from('\x1b[B')) // normal cursor mode
|
|
51
|
+
expect(bytesOf(nextBootAction(claudeAdapter, pane, { appCursorKeys: true }))).toEqual(Buffer.from('\x1bOB'))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('resume-picker once the cursor IS on option 2 → Enter (never re-Downs past the proceed row)', () => {
|
|
55
|
+
const pane = 'Resume from summary\n 1. Start fresh\n ❯ 2. Resume full session'
|
|
56
|
+
expect(bytesOf(nextBootAction(claudeAdapter, pane))).toEqual(Buffer.from('\r'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('input row + bypass banner → ready', () => {
|
|
60
|
+
expect(nextBootAction(claudeAdapter, '❯ \n bypass permissions on').kind).toBe('ready')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('splash up, input row not yet rendered → wait', () => {
|
|
64
|
+
expect(nextBootAction(claudeAdapter, 'Welcome to Claude Code\n (starting)').kind).toBe('wait')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Supervisor BOOT-DRIVER decision (serving slice a) — the pure half of "answer the startup dialogs
|
|
2
|
+
// off the model". A real codex/claude TUI stalls on trust/update/hooks/resume modals; under tmux the
|
|
3
|
+
// launch primitive (index.ts boot loop, 384-411) answers them via capture-pane (read) + send-keys
|
|
4
|
+
// (write). The supervisor reads its authoritative @xterm model (modelToPlainText) and writes raw pty
|
|
5
|
+
// bytes (keysToBytes) — same decision, different surfaces. This module is the DECISION; daemon.ts
|
|
6
|
+
// owns the loop (render → decide → write). PURE: no @xterm, no pty — feed it a viewport string + the
|
|
7
|
+
// runtime's boot predicates and it returns the bytes to send, so it is unit-testable against the
|
|
8
|
+
// real adapters.
|
|
9
|
+
import { keysToBytes, type KeyEncoding } from './protocol.ts'
|
|
10
|
+
|
|
11
|
+
/** The slice of a RuntimeAdapter the boot-driver consumes. codexAdapter / claudeAdapter satisfy it
|
|
12
|
+
* structurally, so boot.ts stays decoupled from launch/types.ts. */
|
|
13
|
+
export interface BootPredicates {
|
|
14
|
+
bootDialogKeys(pane: string): string[] | null
|
|
15
|
+
isInputReady(pane: string): boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BootAction =
|
|
19
|
+
| { kind: 'dialog'; keys: string[]; bytes: Buffer }
|
|
20
|
+
| { kind: 'ready' }
|
|
21
|
+
| { kind: 'wait' }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One boot step, mirroring the launch primitive's order (index.ts:384-411): a visible startup dialog
|
|
25
|
+
* is answered FIRST (its keys clear it; bytes encoded for the model's current cursor mode), ELSE the
|
|
26
|
+
* input surface is checked for readiness, ELSE wait for the next frame. The caller loops this on a
|
|
27
|
+
* cadence, writing `action.bytes` to the pty on a 'dialog' and stopping on 'ready'.
|
|
28
|
+
*/
|
|
29
|
+
export function nextBootAction(adapter: BootPredicates, viewport: string, enc: KeyEncoding = {}): BootAction {
|
|
30
|
+
const keys = adapter.bootDialogKeys(viewport)
|
|
31
|
+
if (keys && keys.length) return { kind: 'dialog', keys, bytes: keysToBytes(keys, enc) }
|
|
32
|
+
if (adapter.isInputReady(viewport)) return { kind: 'ready' }
|
|
33
|
+
return { kind: 'wait' }
|
|
34
|
+
}
|
|
@@ -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
|
+
})
|