@agfpd/iapeer 0.2.64 → 0.2.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 log = `${opts.logDir}/${opts.identity}.log`
80
- if (!existsSync(log)) return opts.fallback
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: opts.cols, rows: opts.rows, allowProposedApi: true, scrollback: 3000 })
102
+ const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
91
103
  await new Promise<void>(res => term.write(buf, () => res()))
92
- return modelToPlainText(term, opts.cols, opts.rows)
104
+ return term
93
105
  } finally {
94
106
  closeSync(fd)
95
107
  }
96
108
  } catch {
97
- return opts.fallback // tmux-fallback is unconditional — never worse than today
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
+ }
@@ -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
- return tmux(sock, 'has-session', '-t', identity).ok
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
+ })
@@ -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); live.add(addr)
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
+ }