@agfpd/iapeer 0.2.77 → 0.2.79

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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/package.json +21 -2
  4. package/src/cli/index.ts +95 -78
  5. package/src/connect/index.ts +4 -4
  6. package/src/core/constants.ts +3 -3
  7. package/src/core/normalize.ts +2 -2
  8. package/src/create/index.ts +1 -1
  9. package/src/daemon/index.ts +5 -5
  10. package/src/daemon/main.ts +9 -9
  11. package/src/enable/index.ts +19 -18
  12. package/src/enable/provisionCommand.ts +10 -10
  13. package/src/identity/index.ts +2 -2
  14. package/src/identity/profileStandard.ts +8 -8
  15. package/src/init/index.ts +10 -10
  16. package/src/install/index.ts +2 -2
  17. package/src/install/signing.ts +3 -4
  18. package/src/launch/adapters/claude.ts +7 -7
  19. package/src/launch/adapters/codex.ts +5 -6
  20. package/src/launch/canary.ts +8 -8
  21. package/src/launch/cmdlog.ts +3 -3
  22. package/src/launch/composeSystemPrompt.ts +2 -1
  23. package/src/launch/index.ts +5 -6
  24. package/src/launch/invocation.ts +1 -1
  25. package/src/launch/launchd.ts +14 -15
  26. package/src/launch/launchdRun.ts +6 -6
  27. package/src/launch/nativeMemory.ts +9 -9
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/launch/readyGateModel.ts +3 -3
  30. package/src/launch/types.ts +7 -7
  31. package/src/lifecycle/eventlog.ts +2 -2
  32. package/src/lifecycle/index.ts +21 -21
  33. package/src/lifecycle/queue.ts +1 -1
  34. package/src/onboard/index.ts +19 -13
  35. package/src/onboard/memory.ts +5 -5
  36. package/src/onboard/steps.ts +8 -9
  37. package/src/provision/index.ts +2 -3
  38. package/src/runtime/deploy.ts +2 -2
  39. package/src/runtime/index.ts +4 -4
  40. package/src/shadow/gate.ts +6 -6
  41. package/src/shadow/index.ts +2 -2
  42. package/src/status/index.ts +11 -11
  43. package/src/storage/index.ts +1 -1
  44. package/src/supervisor/daemon.ts +20 -13
  45. package/src/transport/index.ts +11 -11
  46. package/src/update/index.ts +4 -4
  47. package/src/cli/cli.test.ts +0 -603
  48. package/src/cli/listTui.test.ts +0 -71
  49. package/src/codec/codec.test.ts +0 -271
  50. package/src/connect/connect.test.ts +0 -163
  51. package/src/core/constants.test.ts +0 -32
  52. package/src/core/normalize.test.ts +0 -98
  53. package/src/create/create.test.ts +0 -143
  54. package/src/daemon/daemon-http.test.ts +0 -114
  55. package/src/daemon/daemon.test.ts +0 -267
  56. package/src/daemon/main.test.ts +0 -413
  57. package/src/enable/enable.test.ts +0 -95
  58. package/src/enable/provisionCommand.test.ts +0 -107
  59. package/src/identity/identity.test.ts +0 -279
  60. package/src/identity/profileStandard.test.ts +0 -204
  61. package/src/init/init.test.ts +0 -215
  62. package/src/init/runtime-resolve.test.ts +0 -49
  63. package/src/install/install.test.ts +0 -66
  64. package/src/install/signing.test.ts +0 -84
  65. package/src/launch/bootdeliver.test.ts +0 -108
  66. package/src/launch/bootstrap.test.ts +0 -56
  67. package/src/launch/canary.test.ts +0 -346
  68. package/src/launch/cmdlog.test.ts +0 -94
  69. package/src/launch/codexHooksTrust.test.ts +0 -248
  70. package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
  71. package/src/launch/composeSystemPrompt.test.ts +0 -145
  72. package/src/launch/hostComposerOccupied.test.ts +0 -63
  73. package/src/launch/invocation.test.ts +0 -174
  74. package/src/launch/launch.test.ts +0 -359
  75. package/src/launch/launchd.test.ts +0 -499
  76. package/src/launch/nativeMemory.test.ts +0 -193
  77. package/src/launch/ptyHost.test.ts +0 -38
  78. package/src/launch/readyGateModel.test.ts +0 -72
  79. package/src/launch/sockdir.test.ts +0 -74
  80. package/src/lifecycle/eventlog.test.ts +0 -114
  81. package/src/lifecycle/lifecycle.test.ts +0 -1056
  82. package/src/lifecycle/queue.test.ts +0 -185
  83. package/src/onboard/memory.test.ts +0 -157
  84. package/src/onboard/onboard.test.ts +0 -39
  85. package/src/onboard/steps.test.ts +0 -201
  86. package/src/provision/provision.test.ts +0 -288
  87. package/src/registry/registry.test.ts +0 -432
  88. package/src/runtime/runtime.test.ts +0 -226
  89. package/src/runtime/update.test.ts +0 -192
  90. package/src/shadow/enumerate-skip.test.ts +0 -67
  91. package/src/shadow/gate.test.ts +0 -286
  92. package/src/shadow/install.test.ts +0 -153
  93. package/src/shadow/shadow.test.ts +0 -42
  94. package/src/status/status.test.ts +0 -208
  95. package/src/storage/peers-home.test.ts +0 -34
  96. package/src/storage/rotatelog.test.ts +0 -44
  97. package/src/storage/storage.test.ts +0 -65
  98. package/src/supervisor/attached-marker.test.ts +0 -84
  99. package/src/supervisor/boot.test.ts +0 -66
  100. package/src/supervisor/client.attach.test.ts +0 -107
  101. package/src/supervisor/daemon.test.ts +0 -213
  102. package/src/supervisor/deliver.test.ts +0 -58
  103. package/src/supervisor/panelog.test.ts +0 -95
  104. package/src/supervisor/protocol.test.ts +0 -222
  105. package/src/supervisor/selfargv.test.ts +0 -22
  106. package/src/supervisor/spinner-strip.test.ts +0 -52
  107. package/src/transport/compactDone.test.ts +0 -144
  108. package/src/transport/composerQueue.test.ts +0 -204
  109. package/src/transport/hostControl.test.ts +0 -87
  110. package/src/transport/hostDeliver.test.ts +0 -137
  111. package/src/transport/hostLive.test.ts +0 -50
  112. package/src/transport/routesend-wake.test.ts +0 -357
  113. package/src/update/update.test.ts +0 -208
@@ -1,66 +0,0 @@
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
- })
@@ -1,107 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
- import { mkdtempSync, rmSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import { killSession, sessionAlive, startSupervisorDaemon } from './index.ts'
6
-
7
- // SURFACE-5 regression — the interactive attach-and-stay loop. The other supervisor tests attach a
8
- // RAW socket; none drives the real `runSupervisorClient` under a TTY, so the canary's first live
9
- // `iapeer attach` exposed a bug invisible to them: runSupervisorClient resolved its Promise the
10
- // instant Bun.connect returned (setup done), the CLI caller ran `await runSupervisorClient(); return
11
- // 0` → runCli's `.then(process.exit)`, and the just-attached client was torn down within
12
- // milliseconds (handshake-then-exit). The fix makes the client BLOCK (never-resolve) after setup so
13
- // process.exit from a terminal path owns termination — exactly the detached `daemon` case's guard.
14
- //
15
- // This is the headless verifier of that fix AND the missing surface-5 automation: allocate a REAL
16
- // Bun-native pty, run the actual client CLI in it, assert it STAYS attached + renders live frames +
17
- // Ctrl-] detaches cleanly while the session keeps running. Arthur's live attach is the final human
18
- // seal; this test makes every iteration of the fix verifiable WITHOUT pulling him in.
19
- // Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
20
- const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
21
- const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
22
- const d = bunPty ? describe : describe.skip
23
-
24
- const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
25
- async function waitFor(pred: () => boolean, timeoutMs = 4000, step = 50): Promise<boolean> {
26
- const end = Date.now() + timeoutMs
27
- while (Date.now() < end) {
28
- if (pred()) return true
29
- await sleep(step)
30
- }
31
- return false
32
- }
33
-
34
- // Spawn the real attach client under a real pty (its stdin/stdout is a TTY → raw-mode path runs).
35
- interface PtyClient {
36
- out(): string
37
- exited(): boolean
38
- sendKey(byte: number): void
39
- }
40
- function spawnAttachClient(runDir: string, session: string): PtyClient {
41
- let buf = ''
42
- let done = false
43
- const proc = Bun.spawn(['bun', 'src/supervisor/index.ts', 'attach', session, '--run-dir', runDir], {
44
- cwd: process.cwd(),
45
- env: { ...process.env },
46
- // a real Bun-native pty pair: the client sees a TTY on stdin (isTTY → setRawMode runs).
47
- terminal: {
48
- cols: 80,
49
- rows: 24,
50
- name: 'xterm-256color',
51
- data(_t: unknown, data: Uint8Array | string) {
52
- buf += Buffer.from(data as Uint8Array).toString('latin1')
53
- },
54
- },
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun pty spawn opts not yet in @types
56
- } as any)
57
- proc.exited.then(() => {
58
- done = true
59
- })
60
- return {
61
- out: () => buf,
62
- exited: () => done,
63
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- proc.terminal is the pty master writer
64
- sendKey: (byte: number) => void (proc as any).terminal.write(Buffer.from([byte])),
65
- }
66
- }
67
-
68
- d('runSupervisorClient interactive attach (surface-5, Bun-native pty)', () => {
69
- let runDir: string
70
- const session = 'ptsattach'
71
-
72
- beforeAll(() => {
73
- runDir = mkdtempSync(join(tmpdir(), 'iapeer-attach-'))
74
- startSupervisorDaemon({ session, runtime: 'tick', runDir })
75
- })
76
- afterAll(() => {
77
- try {
78
- killSession(runDir, session)
79
- } catch {
80
- /* */
81
- }
82
- rmSync(runDir, { recursive: true, force: true })
83
- })
84
-
85
- test('the client STAYS attached after the handshake and keeps rendering live frames', async () => {
86
- expect(sessionAlive(runDir, session)).toBe(true)
87
- const c = spawnAttachClient(runDir, session)
88
-
89
- // handshake: the attach line lands (connect + attach + terminal-setup succeeded).
90
- expect(await waitFor(() => /\[supervisor\] attached to/.test(c.out()))).toBe(true)
91
-
92
- // THE BUG was a handshake-then-exit within ~ms. Assert the client is STILL attached well past
93
- // that window AND that live child output (STREAMTICK from the tick runtime) is reaching it —
94
- // proving the attach-and-stay loop holds, not just that the process happens to linger.
95
- await sleep(2000)
96
- expect(c.exited()).toBe(false) // RED on the pre-fix early-resolve (exited ~ms after handshake)
97
- expect(/STREAMTICK_\d+/.test(c.out())).toBe(true) // live frames flow through the held attach
98
-
99
- // Ctrl-] (0x1d) detaches: the client exits cleanly, prints the DETACH message (not the
100
- // misleading "session ended"), and the session keeps running.
101
- c.sendKey(0x1d)
102
- expect(await waitFor(() => c.exited())).toBe(true)
103
- expect(/detached/.test(c.out())).toBe(true)
104
- expect(/session ended/.test(c.out())).toBe(false) // detach must not claim the session ended
105
- expect(sessionAlive(runDir, session)).toBe(true) // detach kept the daemon alive
106
- })
107
- })
@@ -1,213 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
- import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import { FRAME_DATA, FRAME_RESIZE, frame, makeFramer, sizePayload } from './protocol.ts'
6
- import { killSession, listSessions, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
7
-
8
- // Integration: a REAL Bun-native pty daemon over a real unix socket, driven by the deterministic
9
- // `tick` runtime (no claude/codex needed) — hermetic (temp run dir, throwaway session). Proves
10
- // boris's Block-2 acceptance points: spawn + responder-served boot + attach repaint + live
11
- // forward + detach-survival + REATTACH FRESHNESS (the heavy-reattach bug the port-deps fixed).
12
- // Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
13
- const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
14
- const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
15
- const d = bunPty ? describe : describe.skip
16
-
17
- const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
18
- async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 50): Promise<boolean> {
19
- const end = Date.now() + timeoutMs
20
- while (Date.now() < end) {
21
- if (pred()) return true
22
- await sleep(step)
23
- }
24
- return false
25
- }
26
- const maxTick = (s: string): number => {
27
- let m = -1
28
- for (const x of s.matchAll(/STREAMTICK_(\d+)/g)) m = Math.max(m, Number(x[1]))
29
- return m
30
- }
31
-
32
- interface Client {
33
- text(): string
34
- close(): void
35
- }
36
- async function attach(runDir: string, session: string, cols = 80, rows = 24): Promise<Client> {
37
- const frames: Buffer[] = []
38
- const framer = makeFramer((t, p) => {
39
- if (t === FRAME_DATA) frames.push(Buffer.from(p))
40
- })
41
- const sock = await Bun.connect({
42
- unix: sockPath(runDir, session),
43
- socket: {
44
- open(s) {
45
- s.write(frame(FRAME_RESIZE, sizePayload(cols, rows)))
46
- },
47
- data(_s, dd) {
48
- framer(dd)
49
- },
50
- },
51
- })
52
- return {
53
- text: () => Buffer.concat(frames).toString('latin1'),
54
- close: () => {
55
- try {
56
- sock.end()
57
- } catch {
58
- /* */
59
- }
60
- },
61
- }
62
- }
63
-
64
- d('supervisor daemon (Bun-native pty, tick runtime)', () => {
65
- let runDir: string
66
- const session = 'ptstest'
67
-
68
- beforeAll(() => {
69
- runDir = mkdtempSync(join(tmpdir(), 'iapeer-supervisor-'))
70
- })
71
- afterAll(() => {
72
- try {
73
- killSession(runDir, session)
74
- } catch {
75
- /* */
76
- }
77
- rmSync(runDir, { recursive: true, force: true })
78
- })
79
-
80
- test('spawns a detached daemon that comes up live', () => {
81
- const r = startSupervisorDaemon({ session, runtime: 'tick', runDir })
82
- expect(r.state).toBe('started')
83
- expect(sessionAlive(runDir, session)).toBe(true)
84
- expect(listSessions(runDir).some(s => s.session === session && s.alive)).toBe(true)
85
- })
86
-
87
- test('attach forwards live child output (the pty conduit works end-to-end)', async () => {
88
- const c = await attach(runDir, session)
89
- const got = await waitFor(() => /STREAMTICK_\d+/.test(c.text()))
90
- expect(got).toBe(true) // live STREAMTICK_N stream reached the client
91
- c.close()
92
- })
93
-
94
- test('detach keeps the daemon alive (detach-persistence)', async () => {
95
- await sleep(150)
96
- expect(sessionAlive(runDir, session)).toBe(true)
97
- })
98
-
99
- test('REATTACH repaints the CURRENT frame, not a stale one (port-dep #2 freshness)', async () => {
100
- const first = await attach(runDir, session)
101
- await waitFor(() => maxTick(first.text()) >= 0)
102
- const base = maxTick(first.text())
103
- first.close()
104
- await sleep(800) // the stream advances while detached
105
- const second = await attach(runDir, session)
106
- // the reattach repaint (serialize of the CURRENT screen) must reflect ticks emitted AFTER the
107
- // first detach — a stale close-moment frame would show base, not a higher index.
108
- const fresh = await waitFor(() => maxTick(second.text()) > base, 5000)
109
- expect(fresh).toBe(true)
110
- second.close()
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
- })
177
-
178
- // FINDING-2 — restore the SERVE geometry on last detach. The daemon resizes the child pty to a
179
- // client's size on attach and (before this) never restored it, so an unattached session stuck at a
180
- // departed client's size → the fixed-geometry warm-deliver/ready-gate viewport model mismatched the
181
- // pty. Drive it with a child that prints its live pty winsize ("ROWS COLS") to the pane-log: attach
182
- // small → pty shrinks; detach → pty restores to the serve geometry.
183
- test('FINDING-2: last detach restores the serve geometry (no sticking at the client size)', async () => {
184
- const gSession = 'ptsgeo'
185
- const paneLog = join(runDir, 'geo-pane.log')
186
- const lastSize = (): string => {
187
- const t = existsSync(paneLog) ? readFileSync(paneLog, 'utf8') : ''
188
- const m = [...t.matchAll(/(\d+) (\d+)/g)]
189
- return m.length ? `${m[m.length - 1][1]}x${m[m.length - 1][2]}` : ''
190
- }
191
- try {
192
- const r = startSupervisorDaemon({
193
- session: gSession,
194
- runtime: 'tick',
195
- runDir,
196
- // serve geometry 200x50 (distinct from defaults); child reports its live pty winsize as "rows cols"
197
- serve: { argv: ['sh', '-c', 'while :; do stty size 2>/dev/null; sleep 0.3; done'], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, paneLogPath: paneLog, cols: 200, rows: 50 },
198
- })
199
- expect(r.state).toBe('started')
200
- expect(await waitFor(() => lastSize() === '50 200'.replace(' ', 'x'), 6000)).toBe(true) // at serve geometry
201
- const c = await attach(runDir, gSession, 90, 30) // attach at a DIFFERENT size → pty resizes to it
202
- expect(await waitFor(() => lastSize() === '30x90', 6000)).toBe(true) // pty shrank to the client size
203
- c.close() // last client gone
204
- expect(await waitFor(() => lastSize() === '50x200', 6000)).toBe(true) // RESTORED to serve geometry
205
- } finally {
206
- try {
207
- killSession(runDir, gSession)
208
- } catch {
209
- /* */
210
- }
211
- }
212
- })
213
- })
@@ -1,58 +0,0 @@
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
- })
@@ -1,95 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import { spawnSync } from 'node:child_process'
3
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { Terminal } from '@xterm/headless'
7
- import { modelToPlainText } from '../shadow/render.ts'
8
- import { killSession, startSupervisorDaemon } from './index.ts'
9
-
10
- // Slice c — the supervisor pane-log must be a drop-in for tmux pipe-pane: the SAME raw child bytes to
11
- // <identity>.log (so observer/shadow/readyGateViewport read a served session identically), AND a model
12
- // built from it must render what tmux's capture-pane shows. Live tmux + Bun-native pty → guarded.
13
- const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
14
- const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
15
- const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
16
- const d = tmuxAvailable && bunPty ? describe : describe.skip
17
-
18
- const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
19
- const readBuf = (p: string): Buffer => (existsSync(p) ? readFileSync(p) : Buffer.alloc(0))
20
- const modelOf = (bytes: Buffer, cols: number, rows: number): Promise<string> => {
21
- const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 1000 })
22
- return new Promise<string>(res => t.write(bytes, () => res(modelToPlainText(t, cols, rows))))
23
- }
24
-
25
- d('supervisor pane-log ≡ tmux pipe-pane', () => {
26
- test('RAW-BYTE-IDENTITY: the same child yields byte-identical logs (LF/CRLF + multibyte + ANSI)', async () => {
27
- const dir = mkdtempSync(join(tmpdir(), 'panelog-bp-'))
28
- const tmuxSock = join(dir, 't.sock')
29
- const logTmux = join(dir, 'tmux.log')
30
- const logSup = join(dir, 'sup.log')
31
- const runDir = join(dir, 'run')
32
- // bare \n, \r\n, multibyte ❯ › … é (the latin1 grabli), ANSI SGR — the byte classes that diverge
33
- const child = ['sh', '-c', `sleep 0.5; printf 'L1\\nL2\\r\\ng:❯›…é\\n\\033[31mRED\\033[0m\\nTAB\\tEND\\n'; sleep 1`]
34
- try {
35
- spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'p', '-x', '80', '-y', '24', ...child], { stdio: 'ignore' })
36
- spawnSync('tmux', ['-S', tmuxSock, 'pipe-pane', '-o', '-t', 'p', `cat >> '${logTmux}'`], { stdio: 'ignore' })
37
- startSupervisorDaemon({ session: 'bp', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
38
- await sleep(2300) // both children: sleep .5 + emit + sleep 1 ≈ exit ~1.5s; tmux's cat EOF-flushes
39
- const a = readBuf(logTmux)
40
- const b = readBuf(logSup)
41
- expect(b.length).toBeGreaterThan(0)
42
- expect(b.equals(a)).toBe(true) // byte-for-byte — raw Buffer passthrough, shared onlcr default
43
- expect(b.includes(Buffer.from('❯›…é', 'utf8'))).toBe(true) // multibyte survived (no latin1 re-encode)
44
- } finally {
45
- spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
46
- try {
47
- killSession(runDir, 'bp')
48
- } catch {
49
- /* */
50
- }
51
- await sleep(300)
52
- rmSync(dir, { recursive: true, force: true })
53
- }
54
- })
55
-
56
- test('BINDING: a model from the supervisor pane-log renders what tmux capture-pane shows', async () => {
57
- const dir = mkdtempSync(join(tmpdir(), 'panelog-bind-'))
58
- const tmuxSock = join(dir, 't.sock')
59
- const logSup = join(dir, 'sup.log')
60
- const runDir = join(dir, 'run')
61
- const COLS = 80,
62
- ROWS = 24
63
- // a static frame (clear+home, three lines incl. the ❯ ready glyph), then idle so capture-pane sees it
64
- const child = ['sh', '-c', `printf '\\033[2J\\033[HLine one\\r\\n ❯ ready prompt\\r\\nthird line ok\\r\\n'; sleep 30`]
65
- try {
66
- spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'b', '-x', String(COLS), '-y', String(ROWS), ...child], { stdio: 'ignore' })
67
- startSupervisorDaemon({ session: 'bind', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
68
- // let the frame render in both
69
- let cap = ''
70
- for (let i = 0; i < 30 && !/❯ ready prompt/.test(cap); i++) {
71
- await sleep(200)
72
- cap = spawnSync('tmux', ['-S', tmuxSock, 'capture-pane', '-p', '-t', 'b'], { encoding: 'utf8' }).stdout || ''
73
- }
74
- const modelSup = await modelOf(readBuf(logSup), COLS, ROWS)
75
- // tmux capture-pane render (its own emulator) == @xterm model of the SUPERVISOR's pane-log
76
- const norm = (s: string): string =>
77
- s
78
- .split('\n')
79
- .map(l => l.replace(/\s+$/, ''))
80
- .join('\n')
81
- .replace(/\n+$/, '')
82
- expect(norm(modelSup)).toBe(norm(cap))
83
- expect(modelSup).toContain('❯ ready prompt') // the ready glyph survived into the model
84
- } finally {
85
- spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
86
- try {
87
- killSession(runDir, 'bind')
88
- } catch {
89
- /* */
90
- }
91
- await sleep(300)
92
- rmSync(dir, { recursive: true, force: true })
93
- }
94
- })
95
- })