@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Launch-side hosted-session orchestration (cutover Block 2 spawn-flip, Ф0a) — the supervisor-HOST
|
|
2
|
+
// equivalent of the tmux bring-up half of launch(): start the session under the supervisor, wait for
|
|
3
|
+
// readiness off its pane-log model (the supervisor's own boot-driver dismisses startup dialogs), and
|
|
4
|
+
// deliver the first message over its socket. Flag-gated per-peer (.pty-host); when the flag is off
|
|
5
|
+
// this module is never reached and launch is byte-identical (golden).
|
|
6
|
+
//
|
|
7
|
+
// @xterm STAYS OUT of the launch process by construction:
|
|
8
|
+
// - startSupervisorDaemon is dynamic-imported and @xterm-free (daemon.ts is deferred to the detached
|
|
9
|
+
// 'daemon' subprocess) — it only SPAWNS the daemon, @xterm runs there;
|
|
10
|
+
// - the deliver client is a pure-protocol leaf (no @xterm);
|
|
11
|
+
// - sessionAlive comes from client.ts (no @xterm);
|
|
12
|
+
// - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
|
+
import { attachedPath, defaultRunDir, pidPath } from '../supervisor/paths.ts'
|
|
16
|
+
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
|
+
import { killSession as killSupervisorSession } from '../supervisor/index.ts'
|
|
18
|
+
import { paneLogViewport } from './readyGateModel.ts'
|
|
19
|
+
import type { LaunchInvocation } from './invocation.ts'
|
|
20
|
+
import type { RuntimeAdapter } from './types.ts'
|
|
21
|
+
|
|
22
|
+
/** Match the tmux launch geometry (index.ts new-session -x 220 -y 50) so the served child + the
|
|
23
|
+
* pane-log model render at the same size launch reads readiness at. */
|
|
24
|
+
export const HOST_COLS = 220
|
|
25
|
+
export const HOST_ROWS = 50
|
|
26
|
+
|
|
27
|
+
/** Per-peer spawn-flip flag: marker `<logDir>/<identity>.pty-host`. Present → launch hosts the session
|
|
28
|
+
* under the supervisor instead of `tmux new-session`. DISTINCT from `.pty-readygate` (the ready-gate
|
|
29
|
+
* viewport-source flip): a readygate-flipped peer is still tmux-HOSTED and stays under observer
|
|
30
|
+
* cross-check; only a `.pty-host` peer is supervisor-hosted (cross-check retires for it). */
|
|
31
|
+
export function ptyHostEnabled(logDir: string, identity: string): boolean {
|
|
32
|
+
return existsSync(`${logDir}/${identity}.pty-host`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
|
|
36
|
+
export function hostRunDir(): string {
|
|
37
|
+
return defaultRunDir()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is there a LIVE supervisor session for this identity? This is the RUNTIME-STATE host detector the
|
|
42
|
+
* warm surfaces (liveness / deliver / reap / attach / observer) route on — NOT the `.pty-host` marker.
|
|
43
|
+
* The marker is spawn-INTENT (read by launch at spawn); deriving warm routing from the live session
|
|
44
|
+
* means rolling the marker back (rm) on a STILL-RUNNING hosted session never tears it (rm affects only
|
|
45
|
+
* the NEXT spawn). Rollback of a RUNNING hosted session is therefore kill-then-rm, NOT rm-alone.
|
|
46
|
+
*/
|
|
47
|
+
export function hostSessionAlive(identity: string): boolean {
|
|
48
|
+
return hostDaemonAlive(hostRunDir(), identity)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up). */
|
|
52
|
+
export function killPtyHost(identity: string): void {
|
|
53
|
+
killSupervisorSession(hostRunDir(), identity)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is a HUMAN operator attached to this hosted session right now? (spawn-flip Ф0b-3 slice 3c). The
|
|
58
|
+
* supervisor daemon maintains a `.attached` marker present iff ≥1 client is connected (synced on every
|
|
59
|
+
* client add/detach incl. abnormal close/error/drain, and cleared on daemon start so a crashed
|
|
60
|
+
* predecessor's stale marker never lies). The warm-deliver busy-composer hold gates on THIS: hold for
|
|
61
|
+
* a human's unfinished composer input, but NEVER for the AI's own composer output when no one is
|
|
62
|
+
* attached. The host equivalent of `hasAttachedTmuxClient`.
|
|
63
|
+
*/
|
|
64
|
+
export function hasAttachedSupervisorClient(identity: string): boolean {
|
|
65
|
+
return existsSync(attachedPath(hostRunDir(), identity))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A session-REPLACEMENT token for a hosted session (spawn-flip Ф0b-3 slice 3c): the supervisor
|
|
70
|
+
* daemon's pid. A `/new` or restart spawns a NEW daemon (new pid), so a message queued against the old
|
|
71
|
+
* session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
|
|
72
|
+
* `session_id:pane_id` token. null when no daemon pid is readable.
|
|
73
|
+
*/
|
|
74
|
+
export function hostSessionToken(identity: string): string | null {
|
|
75
|
+
try {
|
|
76
|
+
const pid = readFileSync(pidPath(hostRunDir(), identity), 'utf8').trim()
|
|
77
|
+
return pid ? `host:${pid}` : null
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HostStartResult {
|
|
84
|
+
ok: boolean
|
|
85
|
+
error?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bring up the hosted session: start a detached supervisor daemon serving the composed invocation
|
|
90
|
+
* (argv+env from buildLaunchInvocation), writing the pane-log at `paneLogPath`. Returns ok/fail so
|
|
91
|
+
* launch can fall back to `tmux new-session` on failure (the unconditional spawn-fallback — a flipped
|
|
92
|
+
* peer is never worse than tmux ON SPAWN).
|
|
93
|
+
*/
|
|
94
|
+
export async function startPtyHost(b: {
|
|
95
|
+
identity: string
|
|
96
|
+
runtime: string
|
|
97
|
+
cwd: string
|
|
98
|
+
paneLogPath: string
|
|
99
|
+
exitLogPath?: string
|
|
100
|
+
invocation: LaunchInvocation
|
|
101
|
+
}): Promise<HostStartResult> {
|
|
102
|
+
try {
|
|
103
|
+
const { startSupervisorDaemon } = await import('../supervisor/index.ts')
|
|
104
|
+
const r = startSupervisorDaemon({
|
|
105
|
+
session: b.identity,
|
|
106
|
+
runtime: b.runtime,
|
|
107
|
+
runDir: hostRunDir(),
|
|
108
|
+
serve: {
|
|
109
|
+
argv: b.invocation.argv,
|
|
110
|
+
env: b.invocation.env as Record<string, string>,
|
|
111
|
+
cwd: b.cwd,
|
|
112
|
+
paneLogPath: b.paneLogPath,
|
|
113
|
+
exitLogPath: b.exitLogPath,
|
|
114
|
+
cols: HOST_COLS,
|
|
115
|
+
rows: HOST_ROWS,
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
return r.state === 'failed' ? { ok: false, error: r.detail ?? 'supervisor host failed to come up' } : { ok: true }
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { ok: false, error: `pty-host bringup threw: ${(e as Error).message}` }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wait for the hosted input surface to be ready: poll the pane-log model and run the adapter's
|
|
126
|
+
* isInputReady (the supervisor's boot-driver answers dialogs internally, so launch does NOT drive
|
|
127
|
+
* them). Returns true when ready, false on deadline / session death. Mirrors the tmux boot loop's
|
|
128
|
+
* 2 s cadence + bootDeadline.
|
|
129
|
+
*/
|
|
130
|
+
export async function waitHostReady(
|
|
131
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number },
|
|
132
|
+
sleep: (ms: number) => Promise<void>,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const log = `${b.logDir}/${b.identity}.log`
|
|
135
|
+
const iters = Math.max(1, Math.ceil(b.bootDeadlineSecs / 2))
|
|
136
|
+
for (let i = 0; i < iters; i++) {
|
|
137
|
+
await sleep(2000)
|
|
138
|
+
if (!hostSessionAlive(b.identity)) return false
|
|
139
|
+
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS)
|
|
140
|
+
if (view && b.adapter.isInputReady(view)) return true
|
|
141
|
+
}
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Deliver the first/next message to the hosted session over its supervisor socket. */
|
|
146
|
+
export function deliverHosted(identity: string, message: string): Promise<DeliverResult> {
|
|
147
|
+
return deliverToHost(hostRunDir(), identity, message)
|
|
148
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
23
23
|
import { join } from 'node:path'
|
|
24
|
+
import type { Terminal } from '@xterm/headless' // TYPE-ONLY (erased) — the runtime @xterm load is the dynamic import below
|
|
24
25
|
|
|
25
26
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
26
27
|
|
|
@@ -76,24 +77,64 @@ export interface ReadyGateViewportOpts {
|
|
|
76
77
|
export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<string> {
|
|
77
78
|
if (!readyGatePtyFlipEnabled(opts.logDir, opts.identity)) return opts.fallback
|
|
78
79
|
if (!(opts.cols > 0 && opts.rows > 0)) return opts.fallback
|
|
79
|
-
const
|
|
80
|
-
|
|
80
|
+
const view = await paneLogViewport(`${opts.logDir}/${opts.identity}.log`, opts.cols, opts.rows)
|
|
81
|
+
return view ?? opts.fallback // tmux-fallback is unconditional — never worse than today
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read a pane-log's tail into a headless @xterm and render the viewport as plain text — the SAME
|
|
86
|
+
* 0-divergence-validated model the ready-gate flip uses, shared with the spawn-flip hosted path (a
|
|
87
|
+
* supervisor-HOSTED session has no capture-pane, so its pane-log model is the ONLY readiness source).
|
|
88
|
+
* @xterm is dynamic-imported here so a flag-off launch never loads it. Returns null on any failure
|
|
89
|
+
* (unreadable/short log, @xterm hiccup) — the caller decides the fallback (capture-pane for the
|
|
90
|
+
* readygate flip; a retry/fail for hosted).
|
|
91
|
+
*/
|
|
92
|
+
async function loadPaneLogModel(log: string, cols: number, rows: number): Promise<Terminal | null> {
|
|
93
|
+
if (!(cols > 0 && rows > 0) || !existsSync(log)) return null
|
|
81
94
|
try {
|
|
82
95
|
const { Terminal } = await import('@xterm/headless')
|
|
83
|
-
const { modelToPlainText } = await import('../shadow/index.ts')
|
|
84
96
|
const fd = openSync(log, 'r')
|
|
85
97
|
try {
|
|
86
98
|
const sz = fstatSync(fd).size
|
|
87
99
|
const off = Math.max(0, sz - SEED_BYTES)
|
|
88
100
|
const buf = Buffer.alloc(sz - off)
|
|
89
101
|
readSync(fd, buf, 0, buf.length, off)
|
|
90
|
-
const term = new Terminal({ cols
|
|
102
|
+
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
91
103
|
await new Promise<void>(res => term.write(buf, () => res()))
|
|
92
|
-
return
|
|
104
|
+
return term
|
|
93
105
|
} finally {
|
|
94
106
|
closeSync(fd)
|
|
95
107
|
}
|
|
96
108
|
} catch {
|
|
97
|
-
return
|
|
109
|
+
return null
|
|
98
110
|
}
|
|
99
111
|
}
|
|
112
|
+
|
|
113
|
+
export async function paneLogViewport(log: string, cols: number, rows: number): Promise<string | null> {
|
|
114
|
+
const term = await loadPaneLogModel(log, cols, rows)
|
|
115
|
+
if (!term) return null
|
|
116
|
+
const { modelToPlainText } = await import('../shadow/render.ts')
|
|
117
|
+
return modelToPlainText(term, cols, rows)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Spawn-flip Ф0b-2 hosted composer-occupancy guard (slice 2): does the hosted session's composer hold
|
|
122
|
+
* human input? Reads the pane-log model — the SAME raw child bytes tmux renders — and runs the burn-in-
|
|
123
|
+
* validated `composerOccupancyFromModel` (REUSED, not reimplemented). The hosted equivalent of
|
|
124
|
+
* `targetComposerBlocksDelivery`'s capture-pane occupancy. @xterm loads dynamically here (flag-on
|
|
125
|
+
* hosted only; the leaf `composerOccupancyFromModel` import is type-only at runtime, so the warm path
|
|
126
|
+
* stays @xterm-free with the flip off). UNCERTAINTY → BUSY (true): a model-build failure mirrors the
|
|
127
|
+
* tmux predicate's capture-fail-is-busy stance — prefer a false-busy hold over a false-free paste into
|
|
128
|
+
* a human composer.
|
|
129
|
+
*
|
|
130
|
+
* NB (sequence, boris): this is the DETECTION primitive. The end-to-end gate (attached-supervisor-
|
|
131
|
+
* client + enqueue + host-aware drain) lands with the attach client in Ф0b-3 — until a human can
|
|
132
|
+
* attach to a hosted session there is no human composer to protect. The caller composes the pane-log
|
|
133
|
+
* path from logDir+identity (the threaded `logDir` finding), mirroring `waitHostReady`.
|
|
134
|
+
*/
|
|
135
|
+
export async function paneLogComposerOccupied(log: string, cols: number, rows: number, runtime: string): Promise<boolean> {
|
|
136
|
+
const term = await loadPaneLogModel(log, cols, rows)
|
|
137
|
+
if (!term) return true // uncertainty → busy: never paste into a maybe-occupied composer
|
|
138
|
+
const { composerOccupancyFromModel } = await import('../shadow/render.ts')
|
|
139
|
+
return composerOccupancyFromModel(term, cols, rows, runtime)
|
|
140
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
} from '../launch/index.ts'
|
|
49
49
|
import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
|
|
50
50
|
import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
|
|
51
|
+
import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
|
|
51
52
|
import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
52
53
|
|
|
53
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -650,7 +651,12 @@ function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err:
|
|
|
650
651
|
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
|
|
651
652
|
}
|
|
652
653
|
function sessionAlive(sock: string, identity: string): boolean {
|
|
653
|
-
|
|
654
|
+
// RUNTIME-STATE host detection (spawn-flip Ф0b), NOT the `.pty-host` marker: a peer with a LIVE
|
|
655
|
+
// supervisor session is hosted-and-alive; otherwise fall to tmux has-session. Routing liveness on
|
|
656
|
+
// the live supervisor session (not the spawn-intent marker) makes a marker rollback safe on a
|
|
657
|
+
// still-running hosted session — rm-marker only changes the NEXT spawn, never the running routing.
|
|
658
|
+
// Non-hosted peers: hostSessionAlive is a cheap pid-file miss → identical result to today (tmux).
|
|
659
|
+
return hostSessionAlive(identity) || tmux(sock, 'has-session', '-t', identity).ok
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
/** Death-class tag for a gone session (live case: iapeer-memory 10.06 — the WHOLE
|
|
@@ -1049,6 +1055,10 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1049
1055
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1050
1056
|
|
|
1051
1057
|
export function killSession(sock: string, identity: string): void {
|
|
1058
|
+
// Hosted peer (spawn-flip Ф0b): SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and
|
|
1059
|
+
// removes the sock/pid/serve-spec. The tmux teardown below is then a harmless no-op (no tmux session
|
|
1060
|
+
// on this sock); for a non-hosted peer it is the real reap, byte-identical to today.
|
|
1061
|
+
if (hostSessionAlive(identity)) killPtyHost(identity)
|
|
1052
1062
|
tmux(sock, 'kill-session', '-t', identity)
|
|
1053
1063
|
const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
|
|
1054
1064
|
if (!sessions.trim()) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
|
|
2
|
+
//
|
|
3
|
+
// The observer compares the pty-model vs LIVE TMUX. A `.pty-host`-designated peer has no tmux to
|
|
4
|
+
// compare against, and during a flip a lingering tmux socket for it would surface FALSE divergences.
|
|
5
|
+
// This proves the explicit skip fires IN enumerate even in that race (a live tmux session + a pane-log
|
|
6
|
+
// present): the marked peer is NOT enrolled, while an unmarked sibling IS. Real-tmux, guarded (a clean
|
|
7
|
+
// CI runner has no tmux; the release machine has it — same pattern as routesend-wake).
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { buildSocketPath } from '../core/socket.ts'
|
|
14
|
+
import { enumerate } from './index.ts'
|
|
15
|
+
|
|
16
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
17
|
+
|
|
18
|
+
let sockDir: string
|
|
19
|
+
let logDir: string
|
|
20
|
+
const KEEP = 'keepobs' // unmarked → observed
|
|
21
|
+
const SKIP = 'skipobs' // .pty-host marker → skipped
|
|
22
|
+
const addrOf = (p: string) => `codex-${p}`
|
|
23
|
+
const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
|
|
24
|
+
|
|
25
|
+
function startTmux(personality: string): void {
|
|
26
|
+
const r = spawnSync(
|
|
27
|
+
'tmux',
|
|
28
|
+
['-S', sockOf(personality), 'new-session', '-d', '-x', '80', '-y', '24', '-s', addrOf(personality), 'cat'],
|
|
29
|
+
{ encoding: 'utf8' },
|
|
30
|
+
)
|
|
31
|
+
if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
|
|
32
|
+
}
|
|
33
|
+
function killTmux(personality: string): void {
|
|
34
|
+
spawnSync('tmux', ['-S', sockOf(personality), 'kill-server'], { stdio: 'ignore' })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
sockDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-sock-'))
|
|
39
|
+
logDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-log-'))
|
|
40
|
+
if (!tmuxAvailable) return
|
|
41
|
+
// Both peers: a live tmux session + a pane-log (so enrolment is otherwise satisfied).
|
|
42
|
+
for (const p of [KEEP, SKIP]) {
|
|
43
|
+
startTmux(p)
|
|
44
|
+
writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
|
|
45
|
+
}
|
|
46
|
+
// SKIP additionally carries the `.pty-host` designation — even WITH a live tmux session, the observer
|
|
47
|
+
// must not enroll it (the flip-transition race the skip guards against).
|
|
48
|
+
writeFileSync(join(logDir, `${addrOf(SKIP)}.pty-host`), '')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
if (tmuxAvailable) for (const p of [KEEP, SKIP]) killTmux(p)
|
|
53
|
+
rmSync(sockDir, { recursive: true, force: true })
|
|
54
|
+
rmSync(logDir, { recursive: true, force: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe.if(tmuxAvailable)('observer enumerate skips supervisor-hosted (.pty-host) peers', () => {
|
|
58
|
+
test('a .pty-host peer is NOT enrolled even with a live tmux session + pane-log; an unmarked one IS', () => {
|
|
59
|
+
const models = new Map<string, any>()
|
|
60
|
+
enumerate(models, { sockDir, logDir, eventLogDir: logDir })
|
|
61
|
+
expect(models.has(addrOf(KEEP))).toBe(true) // unmarked → observed (live tmux + log present)
|
|
62
|
+
expect(models.has(addrOf(SKIP))).toBe(false) // .pty-host designation → skipped despite live tmux
|
|
63
|
+
for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
|
|
64
|
+
})
|
|
65
|
+
})
|
package/src/shadow/index.ts
CHANGED
|
@@ -17,50 +17,20 @@ import { openSync, closeSync, fstatSync, readSync, existsSync, appendFileSync, w
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transport/index.ts'
|
|
19
19
|
import { getAdapter } from '../launch/index.ts'
|
|
20
|
+
import { ptyHostEnabled } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: the observer skips supervisor-hosted peers
|
|
20
21
|
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
22
|
import type { Runtime } from '../core/constants.ts'
|
|
22
23
|
import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
|
|
24
|
+
// modelToPlainText lives in the leaf render.ts (shared with readyGateModel + the supervisor
|
|
25
|
+
// boot-driver); re-exported here so existing importers of './index.ts' keep resolving.
|
|
26
|
+
import { composerOccupancyFromModel, modelToPlainText } from './render.ts'
|
|
27
|
+
export { composerOccupancyFromModel, modelToPlainText }
|
|
23
28
|
|
|
24
29
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
25
|
-
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
26
30
|
const pidAlive = (pid: number): boolean => {
|
|
27
31
|
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
/** PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose
|
|
31
|
-
* first visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246)
|
|
32
|
-
* visible cell after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it. */
|
|
33
|
-
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
34
|
-
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|
|
35
|
-
let py = -1, px = -1
|
|
36
|
-
for (let y = top; y < bot; y++) {
|
|
37
|
-
const l = b.getLine(y); if (!l) continue
|
|
38
|
-
let fx = -1
|
|
39
|
-
for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
|
|
40
|
-
if (fx < 0) continue
|
|
41
|
-
if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
|
|
42
|
-
}
|
|
43
|
-
if (py < 0) return false
|
|
44
|
-
const l = b.getLine(py)!
|
|
45
|
-
for (let x = px + 1; x < cols; x++) {
|
|
46
|
-
const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
|
|
47
|
-
if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
|
|
48
|
-
}
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The model's viewport as PLAIN text (mimics `capture-pane -p`, the input isInputReady takes). */
|
|
53
|
-
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
54
|
-
const b = term.buffer.active, lines: string[] = []
|
|
55
|
-
for (let y = b.baseY; y < b.baseY + rows; y++) {
|
|
56
|
-
const l = b.getLine(y); if (!l) { lines.push(''); continue }
|
|
57
|
-
let s = ''
|
|
58
|
-
for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
|
|
59
|
-
lines.push(s.replace(/\s+$/, ''))
|
|
60
|
-
}
|
|
61
|
-
return lines.join('\n')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
34
|
interface Obs { addr: string; sock: string; runtime: Runtime; cols: number; rows: number; term: Terminal; fd: number; offset: number }
|
|
65
35
|
|
|
66
36
|
export interface ShadowOptions {
|
|
@@ -84,12 +54,18 @@ function feed(o: Obs): Promise<number> {
|
|
|
84
54
|
return new Promise(res => o.term.write(buf, () => res(buf.length)))
|
|
85
55
|
}
|
|
86
56
|
|
|
87
|
-
function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
57
|
+
export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
88
58
|
let peers: { runtime: Runtime; personality: string }[] = []
|
|
89
59
|
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
90
60
|
const live = new Set<string>()
|
|
91
61
|
for (const p of peers) {
|
|
92
|
-
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
62
|
+
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
63
|
+
// SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
|
|
64
|
+
// LIVE TMUX; a `.pty-host`-designated peer has no tmux to compare against, and during a flip a
|
|
65
|
+
// lingering tmux socket for it would surface false divergences. Skip by the migration DESIGNATION
|
|
66
|
+
// (marker), BEFORE `live` — a peer flipped while enrolled then falls out of `live` and is reaped.
|
|
67
|
+
if (ptyHostEnabled(opts.logDir, addr)) continue
|
|
68
|
+
live.add(addr)
|
|
93
69
|
if (models.has(addr)) continue
|
|
94
70
|
try {
|
|
95
71
|
const sock = buildSocketPath(p.runtime, p.personality, opts.sockDir)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Leaf render helper: @xterm model → plain text. Extracted from shadow/index.ts so consumers
|
|
2
|
+
// (the shadow observer, the Ф1 ready-gate flip, and the Block-2 supervisor boot-driver) share ONE
|
|
3
|
+
// definition without importing the observer's transport/launch graph. Type-only @xterm import →
|
|
4
|
+
// no runtime dependency beyond the Terminal instance the caller already holds; hermetically testable.
|
|
5
|
+
import type { Terminal } from '@xterm/headless'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The model's VISIBLE viewport (baseY..baseY+rows) as PLAIN text — mimics `tmux capture-pane -p`,
|
|
9
|
+
* the surface the runtime adapters' pane predicates (isInputReady / bootDialogKeys) take. Trailing
|
|
10
|
+
* whitespace per line is trimmed (capture-pane does the same), cells coalesced left-to-right.
|
|
11
|
+
*
|
|
12
|
+
* 0-divergence-validated vs capture-pane on READY screens by the ready-gate burn-in; the supervisor
|
|
13
|
+
* boot-driver additionally validates it on BOOT-DIALOG screens (a different screen state the
|
|
14
|
+
* ready-gate observation never saw).
|
|
15
|
+
*/
|
|
16
|
+
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
17
|
+
const b = term.buffer.active,
|
|
18
|
+
lines: string[] = []
|
|
19
|
+
for (let y = b.baseY; y < b.baseY + rows; y++) {
|
|
20
|
+
const l = b.getLine(y)
|
|
21
|
+
if (!l) {
|
|
22
|
+
lines.push('')
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
let s = ''
|
|
26
|
+
for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
|
|
27
|
+
lines.push(s.replace(/\s+$/, ''))
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose first
|
|
36
|
+
* visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246) visible cell
|
|
37
|
+
* after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it by the burn-in. Lives
|
|
38
|
+
* in this leaf (type-only @xterm) so the spawn-flip hosted occupancy guard can reuse it WITHOUT
|
|
39
|
+
* pulling the @xterm-loading observer graph into the warm-deliver path; the Terminal instance is
|
|
40
|
+
* built by the caller (paneLogComposerOccupied dynamic-imports @xterm). Re-exported from
|
|
41
|
+
* shadow/index.ts so the observer's existing importers keep resolving.
|
|
42
|
+
*/
|
|
43
|
+
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
44
|
+
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|
|
45
|
+
let py = -1, px = -1
|
|
46
|
+
for (let y = top; y < bot; y++) {
|
|
47
|
+
const l = b.getLine(y); if (!l) continue
|
|
48
|
+
let fx = -1
|
|
49
|
+
for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
|
|
50
|
+
if (fx < 0) continue
|
|
51
|
+
if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
|
|
52
|
+
}
|
|
53
|
+
if (py < 0) return false
|
|
54
|
+
const l = b.getLine(py)!
|
|
55
|
+
for (let x = px + 1; x < cols; x++) {
|
|
56
|
+
const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
|
|
57
|
+
if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|