@aiwayds/dsh-opentui 0.4.2 → 0.5.0

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,1042 @@
1
+ #!/usr/bin/env node
2
+ // Client-mode two-process E2E gate (P6) — drives the REAL topology end to
3
+ // end: `./dsh-opentui --client` inside tmux 120x34 panes, the resident
4
+ // `dsh --profile opentui` host it spawns, and real model turns.
5
+ //
6
+ // Scenarios:
7
+ // a. Boot + turn — status bar renders (provider/model from the profile
8
+ // route), `say ok` answers, the 🧠 context segment increases.
9
+ // b. Multi-TUI attach — a second client in a second tmux session JOINS
10
+ // the same host (same pid via the single-instance lockfile; exactly
11
+ // one host process); both quit → the host exits after the idle grace
12
+ // (the detached idle-reaper path).
13
+ // c. Crash-recovery adoption — kill -9 client 1's bun process → the host
14
+ // survives → client 2 boots and ADOPTS the existing host (lockfile
15
+ // pid unchanged, no respawn) → clean quit → full teardown.
16
+ // d. /reload — from a running client, the slash command drives the
17
+ // RemoteHostSession.restartSamePortHost restart path → the host
18
+ // restarts on the SAME port → the carrier reconnects and the wave-2
19
+ // re-attach resumes → transcript rows survive (no double-painted
20
+ // rows).
21
+ // e. Host death recovery — kill -9 the HOST while a client is connected
22
+ // mid-turn → the client surfaces the "reconnecting…" state → the
23
+ // next ensureHost (a fresh client boot) respawns → that client
24
+ // continues.
25
+ // f. THE ORIGINAL SCENARIO (acceptance) — `用一个markdown表格列出两种语言
26
+ // 和创建年份,再写一句总结` → a real bordered table (│ rows + └─
27
+ // bottom) + a summary line + the bottom anchor (powerline footer
28
+ // still painted). Capture → /tmp/p6-mdtable.txt, PNG →
29
+ // /tmp/p6-mdtable.png via /tmp/tmux2png.py (pyte + Pillow).
30
+ // g. Workspace config effect — a scratch workspace with
31
+ // ./.dsh/config.yml (theme: dracula + model preference) → the boot
32
+ // in that cwd applies the theme (capture -e truecolor SGRs: the
33
+ // mocha user-row color gone, the dracula accent present) and the
34
+ // session carries the workspace cwd (welcome banner path).
35
+ //
36
+ // Host-death UX (scenario e — the chosen design, ADR-0005 v1):
37
+ // A host killed mid-session leaves the carrier retrying a dead port.
38
+ // The client surfaces this as the "reconnecting…" label swap (the
39
+ // wave-1 marker) for as long as the turn is awaited. v1 does NOT
40
+ // respawn into the SAME client process: the carrier is pinned to the
41
+ // authority it was built with (the P3 ShellHostDeps swap is the seam
42
+ // that could re-point it), and after a crash the lifecycle lockfile is
43
+ // already cleared by the client's own death watcher, so the /reload
44
+ // same-port machinery has nothing to restart. Recovery is the
45
+ // terminal-standard quit-and-relaunch: the NEXT client's ensureHost
46
+ // finds no/stale lock and respawns a fresh host; the session store is
47
+ // host-global, so the relaunched TUI continues with the full
48
+ // transcript. This scenario asserts exactly that chain.
49
+ //
50
+ // Model-route reality (provider keys rot): every real-turn scenario uses
51
+ // the wave-3 workingRoute discovery pattern first — boot the host, probe
52
+ // `session/modelCatalog`, `selectModel` and turn until a route actually
53
+ // answers. The winning route is exported to the TUI clients through the
54
+ // documented env override (DSH_OPENTUI_MODEL=provider/model, the
55
+ // bucket-(d) merge's top layer). When NO route answers, the gate still
56
+ // runs every non-turn assertion and marks the turn parts BLOCKED loudly
57
+ // instead of hacking.
58
+ //
59
+ // Skips (exit 0) when the environment cannot run it: no global dsh, no
60
+ // tmux, no bun, or the opentui profile missing — CI runs skip; a
61
+ // configured dev machine runs the full gate for real.
62
+ //
63
+ // Drive mechanics (e2e-live.mjs conventions): typing is fed ONE CHAR PER
64
+ // tmux send-keys with a ~60ms inter-key gap (burst writes drop keys in
65
+ // OpenTUI's input parser); assertions are presence-over-time on captures.
66
+ //
67
+ // Known P1 watches (oldfox review — if seen, record as
68
+ // "reproduced oldfox-P1#N" and move on; fixes are tracked separately):
69
+ // P1#1 transcript rows double-painted after a reconnect replay (no seq
70
+ // dedupe baseline) — watched in scenario d;
71
+ // P1#2 /reload restart does not rewrite the lockfile → unlocked host
72
+ // (next ensureHost would boot a duplicate) — watched in scenario d;
73
+ // P1#3 concurrent dual-TUI cold start spawns two hosts — watched in
74
+ // scenarios b/c (sequential boots here make it unlikely; honest
75
+ // negative is information too);
76
+ // P1#4 events-source closing a CONNECTING-state socket → unhandled
77
+ // error — watched in scenario e (client process must survive).
78
+
79
+ import { spawn, spawnSync } from 'node:child_process'
80
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
81
+ import { homedir, tmpdir } from 'node:os'
82
+ import { dirname, join, resolve } from 'node:path'
83
+ import { fileURLToPath } from 'node:url'
84
+ import { connect as netConnect } from 'node:net'
85
+
86
+ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
87
+ const LAUNCHER = join(REPO, 'dsh-opentui')
88
+ const PROFILE = 'opentui'
89
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
90
+ const LOCK_PATH = join(DSH_HOME, `opentui-host.${PROFILE}.json`)
91
+ const GEOMETRY = { x: 120, y: 34 }
92
+ const TYPE_GAP_MS = 60
93
+
94
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Preflight + skip contract.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const preflight = () => {
101
+ const need = (cmd) => spawnSync('which', [cmd], { encoding: 'utf8' })
102
+ for (const [cmd, why] of [['dsh', 'no global dsh on PATH'], ['tmux', 'no tmux on PATH'], ['bun', 'no bun on PATH']]) {
103
+ const w = need(cmd)
104
+ if (w.status !== 0 || !w.stdout.trim()) return `${why}`
105
+ }
106
+ if (!existsSync(join(DSH_HOME, 'profiles', PROFILE))) {
107
+ return `profile '${PROFILE}' not provisioned under ${DSH_HOME}`
108
+ }
109
+ if (!existsSync(LAUNCHER)) return `launcher missing: ${LAUNCHER}`
110
+ return null
111
+ }
112
+
113
+ const SKIP = preflight()
114
+ if (SKIP) {
115
+ console.log(`e2e-client: skipped (${SKIP})`)
116
+ process.exit(0)
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Small system helpers (no `timeout` on macOS — all waiting is JS timers).
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const sh = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: 'utf8', ...opts })
124
+
125
+ const tmux = (...args) => sh('tmux', args)
126
+
127
+ const tmuxOut = (...args) => {
128
+ const r = tmux(...args)
129
+ return r.status === 0 ? r.stdout : ''
130
+ }
131
+
132
+ const pidAlive = (pid) => {
133
+ try {
134
+ process.kill(pid, 0)
135
+ return true
136
+ } catch (err) {
137
+ return err.code === 'EPERM'
138
+ }
139
+ }
140
+
141
+ const isListening = (port) =>
142
+ new Promise((resolveListen) => {
143
+ const socket = netConnect({ host: '127.0.0.1', port })
144
+ let settled = false
145
+ const done = (v) => {
146
+ if (settled) return
147
+ settled = true
148
+ socket.destroy()
149
+ resolveListen(v)
150
+ }
151
+ socket.setTimeout(750)
152
+ socket.once('connect', () => done(true))
153
+ socket.once('error', () => done(false))
154
+ socket.once('timeout', () => done(false))
155
+ })
156
+
157
+ /** Host wrapper process pids: the lifecycle spawns
158
+ * `dsh --profile opentui --no-open --port N` (a node wrapper, detached). */
159
+ const hostPids = () => {
160
+ const r = sh('pgrep', ['-f', `dsh --profile ${PROFILE} --no-open`])
161
+ return r.status === 0 ? r.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
162
+ }
163
+
164
+ const readLock = () => {
165
+ try {
166
+ const raw = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
167
+ if (typeof raw.pid === 'number' && typeof raw.port === 'number') return raw
168
+ } catch { /* missing/corrupt */ }
169
+ return null
170
+ }
171
+
172
+ const waitPidGone = async (pid, budgetMs) => {
173
+ const deadline = Date.now() + budgetMs
174
+ while (pidAlive(pid)) {
175
+ if (Date.now() > deadline) return false
176
+ await sleep(250)
177
+ }
178
+ return true
179
+ }
180
+
181
+ /** SIGTERM the host's whole process group (the detached wrapper is a group
182
+ * leader; the server may be a child), escalating to SIGKILL. */
183
+ const killHostTree = async (pid) => {
184
+ const signal = (target, sig) => {
185
+ try { process.kill(-target, sig) } catch { try { process.kill(target, sig) } catch { /* gone */ } }
186
+ }
187
+ signal(pid, 'SIGTERM')
188
+ if (!(await waitPidGone(pid, 5000))) {
189
+ signal(pid, 'SIGKILL')
190
+ await waitPidGone(pid, 3000)
191
+ }
192
+ return !pidAlive(pid)
193
+ }
194
+
195
+ /** Port sweep: SIGTERM whatever still listens (group first), for the
196
+ * unlocked /reload-restarted host (oldfox-P1#2 consequence). */
197
+ const sweepPort = async (port) => {
198
+ const r = sh('lsof', ['-ti', `tcp:${port}`])
199
+ const pids = r.status === 0 ? r.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
200
+ for (const pid of pids) await killHostTree(pid)
201
+ return pids
202
+ }
203
+
204
+ /** Full teardown of one host: kill by pid, sweep its port, clear the
205
+ * lockfile only while it still describes that pid. */
206
+ const reapHost = async (host) => {
207
+ if (!host) return
208
+ await killHostTree(host.pid)
209
+ await sweepPort(host.port)
210
+ const lock = readLock()
211
+ if (lock && lock.pid === host.pid) rmSync(LOCK_PATH, { force: true })
212
+ // settle: let dying sockets drain before the next scenario probes
213
+ await sleep(1500)
214
+ if (await isListening(host.port)) await sweepPort(host.port)
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // tmux client drive (120x34, slow typing — e2e-live mechanics).
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /** Hygienic env for the client (and the host it spawns): the REAL dsh home
222
+ * (no DSH_HOME override), no repo .bin shadowing, no npm_config_* leaks. */
223
+ const baseClientEnv = () => {
224
+ const env = { ...process.env }
225
+ delete env.DSH_HOME
226
+ delete env.DSH_OPENTUI_THEME
227
+ env.PATH = env.PATH.split(':').filter((s) => s && !s.includes('dsh-opentui/node_modules/.bin')).join(':')
228
+ for (const k of Object.keys(env)) if (k.startsWith('npm_config_')) delete env[k]
229
+ return env
230
+ }
231
+
232
+ const sessionsCreated = new Set()
233
+ let ownedTmuxServer = false
234
+
235
+ const tmuxServerWasRunning = () => tmux('list-sessions').status === 0
236
+
237
+ const newClientSession = (name, workspace, extraEnv = {}) => {
238
+ const envParts = Object.entries(extraEnv)
239
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
240
+ .join(' ')
241
+ const cmd = `${envParts} ${LAUNCHER} --client`.trim()
242
+ const r = tmux('new-session', '-d', '-x', String(GEOMETRY.x), '-y', String(GEOMETRY.y), '-s', name, '-c', workspace, cmd)
243
+ if (r.status !== 0) throw new Error(`tmux new-session ${name} failed: ${r.stderr.trim()}`)
244
+ sessionsCreated.add(name)
245
+ }
246
+
247
+ const paneAlive = (name) => tmux('has-session', '-t', name).status === 0
248
+
249
+ const capture = (name, { esc = false, full = false } = {}) => {
250
+ const args = ['capture-pane', '-p', '-t', name]
251
+ if (esc) args.push('-e')
252
+ if (full) args.push('-S', '-2000')
253
+ return tmuxOut(...args)
254
+ }
255
+
256
+ const sendKeys = (name, ...keys) => tmux('send-keys', '-t', name, ...keys)
257
+
258
+ /** One char per send-keys with a real inter-key gap (parser drops bursts). */
259
+ const typeText = async (name, text) => {
260
+ for (const ch of text) {
261
+ sendKeys(name, '-l', ch)
262
+ await sleep(TYPE_GAP_MS)
263
+ }
264
+ }
265
+
266
+ const submitLine = async (name, text) => {
267
+ await typeText(name, text)
268
+ await sleep(300)
269
+ sendKeys(name, 'Enter')
270
+ }
271
+
272
+ const waitPane = async (name, predicate, timeoutMs, everyMs = 500) => {
273
+ const deadline = Date.now() + timeoutMs
274
+ let last = ''
275
+ while (Date.now() < deadline) {
276
+ last = capture(name)
277
+ if (predicate(last)) return last
278
+ await sleep(everyMs)
279
+ }
280
+ return last // caller decides whether the final capture satisfies it
281
+ }
282
+
283
+ const waitPaneDead = async (name, timeoutMs) => {
284
+ const deadline = Date.now() + timeoutMs
285
+ while (Date.now() < deadline) {
286
+ if (!paneAlive(name)) return true
287
+ await sleep(400)
288
+ }
289
+ return false
290
+ }
291
+
292
+ /** Wait for the boot to reach the rendered powerline (🧠 segment), then for
293
+ * the cold-boot route label (the generic "dsh-host" swaps within the boot
294
+ * label budget; a still-generic label after that is an assertion failure,
295
+ * not a wait failure). */
296
+ const waitRendered = async (name) => {
297
+ const last = await waitPane(name, (out) => out.includes('🧠') && /dsh\(/.test(out), 45_000)
298
+ if (!last.includes('🧠')) {
299
+ throw new Error(`client ${name} never rendered a powerline within 45s — last capture:\n${last.slice(-1500)}`)
300
+ }
301
+ if (last.includes('client boot failed')) {
302
+ throw new Error(`client ${name} boot failed:\n${last.slice(0, 1500)}`)
303
+ }
304
+ const labeled = await waitPane(name, (out) => {
305
+ const r = routeOf(powerlineOf(out))
306
+ return r !== null && r.provider !== 'dsh-host' && r.model !== '·'
307
+ }, 10_000)
308
+ return labeled
309
+ }
310
+
311
+ const quitClient = async (name) => {
312
+ if (!paneAlive(name)) return 'already gone'
313
+ await submitLine(name, '/quit')
314
+ if (await waitPaneDead(name, 15_000)) return 'quit'
315
+ tmux('kill-session', '-t', name)
316
+ return 'forced (kill-session)'
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Turn helpers.
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /** The idle editor row shows the placeholder; while a turn runs it is
324
+ * replaced by the busy label — locale-proof busy detection. */
325
+ const IDLE_MARK = '提问,回车发送'
326
+ const isIdle = (out) => out.includes(IDLE_MARK)
327
+
328
+ /** The powerline footer is the 🧠 line that also carries the dsh( segment
329
+ * (the busy status row contains 🧠 too, but never dsh(). */
330
+ const powerlineOf = (out) => out.split('\n').find((l) => l.includes('🧠') && l.includes('dsh(')) ?? ''
331
+
332
+ const contextOf = (line) => {
333
+ const m = /🧠\s*([\d.]+)(k?)\((\d+)%\)/.exec(line)
334
+ if (!m) return null
335
+ return Number(m[1]) * (m[2] === 'k' ? 1000 : 1)
336
+ }
337
+
338
+ const routeOf = (line) => {
339
+ // segments are separated by the powerline glyph U+E0B0 (private-use area)
340
+ // — normalize every private-use char to a space before parsing
341
+ const clean = line.replace(/[\u{E000}-\u{F8FF}]/gu, ' ').replace(/\s+/g, ' ')
342
+ const m = /dsh\(\w+\) (\S+) 🤖 (\S+) ◕(\S*)/.exec(clean)
343
+ return m ? { provider: m[1], model: m[2], effort: m[3] } : null
344
+ }
345
+
346
+ /** Send a turn and wait for it to settle (editor leaves idle → returns to
347
+ * idle). Returns the post-turn capture. */
348
+ const runTurn = async (name, text, settleMs = 90_000) => {
349
+ const before = capture(name)
350
+ await submitLine(name, text)
351
+ const busySeen = await waitPane(name, (out) => !isIdle(out) || /api key|auth/i.test(out), 20_000)
352
+ // a fast auth failure may never paint the busy row — both are settles
353
+ if (isIdle(busySeen) && !/api key|auth/i.test(busySeen)) {
354
+ throw new Error(`turn never started (editor stayed idle, no error) — capture:\n${busySeen.slice(-1200)}`)
355
+ }
356
+ const settled = await waitPane(name, (out) => isIdle(out), settleMs)
357
+ return { after: settled, before }
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // WorkingRoute discovery (wave-3 pattern) — probe the catalog, selectModel
362
+ // a route that actually answers. Runs BEFORE the scenarios; the winning
363
+ // route is exported to every TUI client via DSH_OPENTUI_MODEL (bucket-(d)
364
+ // env layer), so the TUI turns ride a live route even with a rotted
365
+ // deployment default.
366
+ // ---------------------------------------------------------------------------
367
+
368
+ const probeWorkingRoute = async () => {
369
+ const probeSessWs = mkdtempSync(join(tmpdir(), 'p6-probe-sess-'))
370
+ // The probe must live inside packages/shell so its imports of the repo's
371
+ // TS modules (and the dsh-client-connection closure link) resolve.
372
+ const probePath = join(REPO, 'packages', 'shell', `.p6-route-probe-${process.pid}.ts`)
373
+ writeFileSync(
374
+ probePath,
375
+ `// generated by scripts/e2e-client.mjs — deleted after the run
376
+ import { ensureHost } from './src/client/lifecycle.ts'
377
+ import { ClientConnection } from './src/client/transport.ts'
378
+ import { RemoteHostSession } from './src/client/remote-host-session.ts'
379
+
380
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
381
+ const turnOnce = (session, text) => new Promise((res) => {
382
+ let done = false
383
+ const finish = (err) => { if (!done) { done = true; res(err) } }
384
+ session.turn(text, {
385
+ onThinkingDelta: () => {},
386
+ onContentDelta: () => {},
387
+ onTurnEnd: () => finish(undefined),
388
+ onApprove: async () => false,
389
+ onToolRun: () => {},
390
+ onError: (m) => finish(m),
391
+ }).catch((e) => finish(e instanceof Error ? e.message : String(e)))
392
+ const deadline = Date.now() + 90_000
393
+ const poll = setInterval(() => {
394
+ if (done || Date.now() > deadline) { clearInterval(poll); finish('turn deadline exceeded') }
395
+ }, 200)
396
+ poll.unref?.()
397
+ })
398
+
399
+ const ws = process.env.P6_PROBE_WS
400
+ const host = await ensureHost({ profile: '${PROFILE}', idleExitSec: 0 })
401
+ const out = { hostPid: host.pid, hostPort: host.port, adopted: host.adopted, ok: false, adoptedDefault: false, route: null, otherRoute: null, errors: [] }
402
+ const live = new ClientConnection({ authority: host.authority, cookieHeader: host.cookieHeader })
403
+ const session = new RemoteHostSession({ connection: live, cookieHeader: host.cookieHeader, cwd: ws })
404
+ await session.start()
405
+ let err = await turnOnce(session, 'say ok')
406
+ if (err === undefined) {
407
+ out.ok = true
408
+ out.adoptedDefault = true
409
+ // resolve WHICH route answered: the live session's projection (fallback:
410
+ // the catalog default) — the scenarios need the concrete provider/model
411
+ try {
412
+ const sel = await session.currentModelSelection()
413
+ if (sel && sel.provider && sel.model) out.route = sel.provider + '/' + sel.model
414
+ } catch { /* keep null */ }
415
+ if (!out.route) {
416
+ try {
417
+ const catalog = await session.modelCatalog(true)
418
+ if (catalog.default && catalog.default.provider && catalog.default.model) {
419
+ out.route = catalog.default.provider + '/' + catalog.default.model
420
+ }
421
+ } catch { /* keep null */ }
422
+ }
423
+ } else {
424
+ out.errors.push('default: ' + err.slice(0, 120))
425
+ if (/api key|auth/i.test(err)) {
426
+ const catalog = await session.modelCatalog(true)
427
+ for (const group of catalog.groups) {
428
+ if (group.models.length === 0) continue
429
+ const candidate = { provider: group.id, model: group.models[0].id }
430
+ try {
431
+ await session.selectModel(candidate)
432
+ } catch (e) { out.errors.push('selectModel ' + group.id + ': ' + String(e).slice(0, 80)); continue }
433
+ err = await turnOnce(session, 'say ok')
434
+ if (err === undefined) {
435
+ out.ok = true
436
+ out.route = candidate.provider + '/' + candidate.model
437
+ break
438
+ }
439
+ out.errors.push(group.id + ': ' + err.slice(0, 120))
440
+ }
441
+ } else {
442
+ out.route = null
443
+ }
444
+ }
445
+ // a DIFFERENT catalog route for scenario g's model-pref overlay (the label
446
+ // must then provably come from the workspace config, not the cold-boot
447
+ // default; the route only needs a distinct id — no working key required)
448
+ if (out.route) {
449
+ try {
450
+ const catalog = await session.modelCatalog(true)
451
+ const rp = out.route.split('/')[0]
452
+ const alt = catalog.groups.find((g) => g.models.length > 0 && g.id !== rp)
453
+ if (alt) out.otherRoute = alt.id + '/' + alt.models[0].id
454
+ } catch { /* keep null */ }
455
+ }
456
+ session.abort()
457
+ await session.dispose()
458
+ await live.close()
459
+ console.log('PROBE_JSON' + JSON.stringify(out))
460
+ process.exit(0)
461
+ `,
462
+ )
463
+ try {
464
+ const r = sh('bun', [probePath], {
465
+ encoding: 'utf8',
466
+ timeout: 300_000,
467
+ cwd: join(REPO, 'packages', 'shell'),
468
+ env: { ...process.env, P6_PROBE_WS: probeSessWs },
469
+ })
470
+ const line = (r.stdout + '\n' + r.stderr).split('\n').find((l) => l.startsWith('PROBE_JSON'))
471
+ if (!line) throw new Error(`probe produced no result (exit ${r.status}):\n${(r.stdout + r.stderr).slice(-1200)}`)
472
+ return JSON.parse(line.slice('PROBE_JSON'.length))
473
+ } finally {
474
+ rmSync(probePath, { force: true })
475
+ rmSync(probeSessWs, { recursive: true, force: true })
476
+ }
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Scenario scaffold.
481
+ // ---------------------------------------------------------------------------
482
+
483
+ const scenarios = []
484
+ const record = (name, verdict, notes = [], p1 = []) => {
485
+ scenarios.push({ name, verdict, notes, p1 })
486
+ console.log(`\n=== ${name}: ${verdict} ===`)
487
+ for (const n of notes) console.log(` ${n}`)
488
+ for (const p of p1) console.log(` !! ${p}`)
489
+ }
490
+
491
+ const assert = (cond, message) => {
492
+ if (!cond) throw new Error(message)
493
+ }
494
+
495
+ /** Between-scenario hygiene: nothing of ours should be left running. If a
496
+ * host IS still alive here, it is a leftover — reap it so the next
497
+ * scenario starts from the quiet topology. */
498
+ const preClean = async (label) => {
499
+ const lock = readLock()
500
+ if (lock && pidAlive(lock.pid)) {
501
+ console.log(` [${label}] reaping leftover host pid=${lock.pid} port=${lock.port}`)
502
+ await reapHost(lock)
503
+ } else if (lock) {
504
+ rmSync(LOCK_PATH, { force: true })
505
+ }
506
+ for (const pid of hostPids()) {
507
+ if (!lock || pid !== lock.pid) {
508
+ console.log(` [${label}] killing stray host wrapper pid=${pid}`)
509
+ await killHostTree(pid)
510
+ }
511
+ }
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Scenarios.
516
+ // ---------------------------------------------------------------------------
517
+
518
+ let ROUTE = null // concrete "provider/model" | null — the workingRoute discovery product
519
+ let OTHER_ROUTE = null // a DIFFERENT catalog route (scenario g's overlay pref)
520
+ let TURN_OK = false // a route answers (default or fallback) — turns may run
521
+
522
+ const scenarioA = async () => {
523
+ const notes = []
524
+ const ws = mkdtempSync(join(tmpdir(), 'p6-ws-a-'))
525
+ const name = 'p6a'
526
+ try {
527
+ newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
528
+ const rendered = await waitRendered(name)
529
+ const line = powerlineOf(rendered)
530
+ const route = routeOf(line)
531
+ notes.push(`powerline: ${line.trim().slice(0, 120)}`)
532
+ assert(route !== null, `powerline route segment not parseable: ${line}`)
533
+ assert(route.provider !== 'dsh-host' && route.provider !== '', `status bar shows the generic label (cold-boot route never applied): ${line}`)
534
+ assert(route.model !== '·' && route.model !== '', `status bar shows no model: ${line}`)
535
+ const ctxBefore = contextOf(line)
536
+ notes.push(`status bar route: ${route.provider} / ${route.model} · context before: ${ctxBefore}`)
537
+
538
+ if (!TURN_OK) {
539
+ record('a. boot + turn', 'BLOCKED (turn)', [
540
+ ...notes,
541
+ 'boot + status bar asserts PASSED; the turn half is blocked: no route answers (key rot) — see the probe report above',
542
+ ])
543
+ return
544
+ }
545
+ const { after } = await runTurn(name, 'say ok')
546
+ const line2 = powerlineOf(after)
547
+ const ctxAfter = contextOf(line2)
548
+ const reply = after.includes('❯ say ok')
549
+ notes.push(`post-turn powerline: ${line2.trim().slice(0, 120)}`)
550
+ notes.push(`turn: user row painted=${reply} · context after: ${ctxAfter}`)
551
+ assert(reply, 'user row "❯ say ok" not painted')
552
+ assert(ctxBefore === 0 && ctxAfter > ctxBefore, `context did not increase across the turn (${ctxBefore} → ${ctxAfter})`)
553
+ record('a. boot + turn', 'PASS', notes)
554
+ } catch (e) {
555
+ record('a. boot + turn', 'FAIL', [...notes, String(e.message)])
556
+ } finally {
557
+ await quitClient(name)
558
+ await reapHost(readLock())
559
+ rmSync(ws, { recursive: true, force: true })
560
+ }
561
+ }
562
+
563
+ const scenarioB = async () => {
564
+ const notes = []
565
+ const p1 = []
566
+ const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-b1-'))
567
+ const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-b2-'))
568
+ const n1 = 'p6b1'
569
+ const n2 = 'p6b2'
570
+ try {
571
+ newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
572
+ await waitRendered(n1)
573
+ const lock1 = readLock()
574
+ assert(lock1 !== null, 'no lockfile after client 1 boot (host not lifecycle-managed?)')
575
+ notes.push(`client 1 booted; host pid=${lock1.pid} port=${lock1.port}`)
576
+ assert(hostPids().length === 1, `expected exactly 1 host wrapper after boot 1, got ${hostPids().join(',')}`)
577
+
578
+ newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
579
+ await waitRendered(n2)
580
+ const lock2 = readLock()
581
+ notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
582
+ assert(lock2 && lock2.pid === lock1.pid, `second client spawned/replaced the host (lock pid ${lock1.pid} → ${lock2?.pid}) — expected a JOIN`)
583
+ const wrappers = hostPids()
584
+ if (wrappers.length !== 1) {
585
+ p1.push(`reproduced oldfox-P1#3: ${wrappers.length} host wrapper pids after a second client joined: ${wrappers.join(',')}`)
586
+ } else {
587
+ notes.push('exactly one host process (join, no second spawn)')
588
+ }
589
+
590
+ const q1 = await quitClient(n1)
591
+ const q2 = await quitClient(n2)
592
+ notes.push(`quits: client1=${q1} client2=${q2}`)
593
+
594
+ // the idle-reaper path: the last quit spawned the detached reaper with
595
+ // graceSec = idleExitSec (60) — the host exits inside ~60-75s
596
+ const hostGone = await waitPidGone(lock1.pid, 110_000)
597
+ const lockAfter = readLock()
598
+ notes.push(`host dead after grace=${hostGone} · lockfile now: ${lockAfter ? `pid=${lockAfter.pid}` : 'gone'}`)
599
+ assert(hostGone, `host pid=${lock1.pid} still alive 110s after both clients quit — idle-exit hand-off failed`)
600
+ if (lockAfter && lockAfter.pid === lock1.pid) rmSync(LOCK_PATH, { force: true })
601
+ assert(!await isListening(lock1.port), `port ${lock1.port} still listening after the host died`)
602
+ record('b. multi-TUI attach', 'PASS', notes, p1)
603
+ } catch (e) {
604
+ record('b. multi-TUI attach', 'FAIL', [...notes, String(e.message)], p1)
605
+ } finally {
606
+ await quitClient(n1)
607
+ await quitClient(n2)
608
+ await reapHost(readLock())
609
+ rmSync(ws1, { recursive: true, force: true })
610
+ rmSync(ws2, { recursive: true, force: true })
611
+ }
612
+ }
613
+
614
+ const scenarioC = async () => {
615
+ const notes = []
616
+ const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-c1-'))
617
+ const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-c2-'))
618
+ const n1 = 'p6c1'
619
+ const n2 = 'p6c2'
620
+ try {
621
+ newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
622
+ await waitRendered(n1)
623
+ const lock1 = readLock()
624
+ assert(lock1 !== null, 'no lockfile after client 1 boot')
625
+
626
+ // kill -9 the CLIENT's bun process (not the pane shell)
627
+ const pg = sh('pgrep', ['-f', 'client-entry.tsx'])
628
+ const clientPids = pg.status === 0 ? pg.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
629
+ assert(clientPids.length === 1, `expected exactly 1 client bun process, got: ${clientPids.join(',')}`)
630
+ try { process.kill(clientPids[0], 'SIGKILL') } catch { /* gone */ }
631
+ notes.push(`client 1 bun pid=${clientPids[0]} SIGKILLed`)
632
+ await waitPaneDead(n1, 10_000)
633
+ await sleep(2000)
634
+ assert(pidAlive(lock1.pid), `host pid=${lock1.pid} died with its client — the resident-host contract broke`)
635
+ assert(await isListening(lock1.port), `host port ${lock1.port} no longer listening after the client crashed`)
636
+ notes.push(`host survived the client crash (pid=${lock1.pid} alive, port ${lock1.port} listening)`)
637
+
638
+ newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
639
+ await waitRendered(n2)
640
+ const lock2 = readLock()
641
+ notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
642
+ assert(lock2 && lock2.pid === lock1.pid, `client 2 did NOT adopt: lock pid ${lock1.pid} → ${lock2?.pid} (a fresh boot happened instead)`)
643
+ const wrappers = hostPids()
644
+ if (wrappers.length !== 1) {
645
+ notes.push(`WARNING: host wrapper count after adoption = ${wrappers.length} (${wrappers.join(',')}) — expected 1`)
646
+ } else {
647
+ notes.push('adoption path confirmed: same pid, exactly one host process, no respawn')
648
+ }
649
+
650
+ const q = await quitClient(n2)
651
+ notes.push(`client 2 quit: ${q}`)
652
+ await reapHost(lock2)
653
+ notes.push(`teardown: host pid=${lock2.pid} reaped, lockfile ${readLock() ? 'STILL PRESENT' : 'gone'}`)
654
+ assert(readLock() === null, 'lockfile survived the full teardown')
655
+ record('c. crash-recovery adoption', 'PASS', notes)
656
+ } catch (e) {
657
+ record('c. crash-recovery adoption', 'FAIL', [...notes, String(e.message)])
658
+ } finally {
659
+ await quitClient(n1)
660
+ await quitClient(n2)
661
+ await reapHost(readLock())
662
+ rmSync(ws1, { recursive: true, force: true })
663
+ rmSync(ws2, { recursive: true, force: true })
664
+ }
665
+ }
666
+
667
+ const scenarioD = async () => {
668
+ const notes = []
669
+ const p1 = []
670
+ const ws = mkdtempSync(join(tmpdir(), 'p6-ws-d-'))
671
+ const name = 'p6d'
672
+ let dLock0 = null // kept for the finally: the restarted host may be unmanaged
673
+ try {
674
+ newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
675
+ await waitRendered(name)
676
+ const lock0 = readLock()
677
+ dLock0 = lock0
678
+ assert(lock0 !== null, 'no lockfile before /reload')
679
+
680
+ // cheap transcript rows without a model call
681
+ await submitLine(name, '/usage')
682
+ await waitPane(name, (out) => out.includes('turns 0'), 15_000)
683
+ notes.push('/usage rows painted (system transcript rows)')
684
+
685
+ // one real turn row when a route answers
686
+ if (TURN_OK) {
687
+ await runTurn(name, 'say ok')
688
+ notes.push('say-ok turn completed before the reload')
689
+ }
690
+
691
+ await submitLine(name, '/reload')
692
+ const reloaded = await waitPane(name, (out) => out.includes('plugins reloaded') || out.includes('reload failed'), 90_000)
693
+ assert(reloaded.includes('plugins reloaded'), `/reload did not complete cleanly:\n${reloaded.slice(-1200)}`)
694
+ notes.push('"plugins reloaded" painted')
695
+
696
+ // same-port restart: old pid dead, a listener STILL on lock0.port, new pid
697
+ const listeners = sh('lsof', ['-ti', `tcp:${lock0.port}`])
698
+ const listenerPids = listeners.status === 0 ? listeners.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
699
+ const newPid = listenerPids.find((p) => p !== lock0.pid) ?? listenerPids[0]
700
+ notes.push(`restart: old pid dead=${await waitPidGone(lock0.pid, 100)} · port ${lock0.port} listener pid(s)=${listenerPids.join(',') || 'NONE'}`)
701
+ assert(listenerPids.length > 0, `port ${lock0.port} has no listener after /reload — the host did not come back`)
702
+ assert(newPid !== undefined && newPid !== lock0.pid, `expected a NEW host process on the same port (listener=${newPid}, old=${lock0.pid})`)
703
+ notes.push(`same-port restart confirmed: ${lock0.pid} → ${newPid} on port ${lock0.port}`)
704
+
705
+ // oldfox-P1#2 watch: the restart path removes the lockfile and never
706
+ // rewrites it (writeLock lives only in bootHost) — the restarted host is
707
+ // UNMANAGED and the next ensureHost would boot a duplicate on a new port.
708
+ const lockAfter = readLock()
709
+ if (!lockAfter || lockAfter.pid !== newPid) {
710
+ p1.push(`reproduced oldfox-P1#2: after /reload the lockfile is ${lockAfter ? `stale (pid=${lockAfter.pid}, listener=${newPid})` : 'GONE'} — the restarted host pid=${newPid} is unmanaged (next ensureHost would boot a duplicate host; teardown below reaps by port)`)
711
+ } else {
712
+ notes.push('lockfile rewritten by the restart (P1#2 not hit on this build)')
713
+ }
714
+
715
+ // transcript survival + double-paint watch (oldfox-P1#1)
716
+ await sleep(3000) // let the re-attach replay settle
717
+ const full = capture(name, { full: true })
718
+ const sayOkRows = (full.match(/❯ say ok/g) ?? []).length
719
+ const usageRows = (full.match(/turns 0/g) ?? []).length
720
+ notes.push(`post-reload transcript: "❯ say ok" rows=${sayOkRows} · "turns 0" rows=${usageRows}`)
721
+ if (ROUTE && sayOkRows > 1) p1.push(`reproduced oldfox-P1#1: "❯ say ok" painted ${sayOkRows}x after the /reload re-attach (no seq dedupe on replay)`)
722
+ if (usageRows > 1) p1.push(`reproduced oldfox-P1#1: "turns 0" system row painted ${usageRows}x`)
723
+ assert(sayOkRows >= (TURN_OK ? 1 : 0) && usageRows >= 1, 'pre-reload transcript rows lost across the restart')
724
+ record('d. /reload same-port restart', p1.length > 0 ? 'PASS (with P1 reproductions)' : 'PASS', notes, p1)
725
+ } catch (e) {
726
+ record('d. /reload same-port restart', 'FAIL', [...notes, String(e.message)], p1)
727
+ } finally {
728
+ await quitClient(name)
729
+ // the restarted host may be UNMANAGED (P1#2, no lockfile): reap by lock
730
+ // AND by port sweep so nothing leaks
731
+ await reapHost(readLock())
732
+ if (dLock0) await sweepPort(dLock0.port)
733
+ rmSync(ws, { recursive: true, force: true })
734
+ }
735
+ }
736
+
737
+ const scenarioE = async () => {
738
+ const notes = []
739
+ const p1 = []
740
+ const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-e1-'))
741
+ const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-e2-'))
742
+ const n1 = 'p6e1'
743
+ const n2 = 'p6e2'
744
+ try {
745
+ newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
746
+ await waitRendered(n1)
747
+ const lock1 = readLock()
748
+ assert(lock1 !== null, 'no lockfile before the host kill')
749
+
750
+ if (TURN_OK) {
751
+ // stage a long-running turn so the follow stream dies mid-await
752
+ await submitLine(n1, '写一篇 300 字左右的短文,介绍秋天的湖面。不要使用任何工具。')
753
+ const busy = await waitPane(n1, (out) => !isIdle(out), 20_000)
754
+ assert(!isIdle(busy), `long turn never became busy:\n${busy.slice(-800)}`)
755
+ await sleep(4000) // let the follow attach and content stream
756
+ notes.push('long turn in flight (editor left idle)')
757
+ } else {
758
+ notes.push('no working route: skipping the mid-turn staging — the death surface degrades (documented in the header)')
759
+ }
760
+
761
+ // kill -9 the HOST process group (wrapper is a detached group leader)
762
+ try { process.kill(-lock1.pid, 'SIGKILL') } catch { try { process.kill(lock1.pid, 'SIGKILL') } catch { /* gone */ } }
763
+ notes.push(`host pid=${lock1.pid} SIGKILLed (process group)`)
764
+ const listenerGone = await (async () => {
765
+ const deadline = Date.now() + 10_000
766
+ while (Date.now() < deadline) {
767
+ if (!(await isListening(lock1.port))) return true
768
+ await sleep(300)
769
+ }
770
+ return false
771
+ })()
772
+ notes.push(`host port ${lock1.port} released=${listenerGone}`)
773
+ assert(listenerGone, 'host port still listening after SIGKILL — the server process survived the group kill')
774
+
775
+ // the client must SURFACE the death and must NOT crash (oldfox-P1#4 watch)
776
+ let surfaced = 'nothing'
777
+ if (TURN_OK) {
778
+ const surfacedPane = await waitPane(n1, (out) => out.includes('reconnecting') || !paneAlive(n1), 20_000)
779
+ if (surfacedPane.includes('reconnecting')) {
780
+ surfaced = 'reconnecting label painted'
781
+ const line = powerlineOf(surfacedPane)
782
+ notes.push(`powerline during the outage: ${line.trim().slice(0, 120)}`)
783
+ } else {
784
+ notes.push(
785
+ 'FINDING (renderer sampling gap, not an oldfox P1): the session-level reconnecting marker exists (RemoteHostSession sets label="reconnecting…" and status.turn="reconnecting" on the mid-turn follow detach) but the PANE never paints it — ShellHost samples session.label only at onTurnEnd / /think (setLabel, ShellHost.tsx) and the turn never settles during an unresolved outage, so the swap never re-renders. Recorded for the UI-follow-up task.',
786
+ )
787
+ }
788
+ }
789
+
790
+ // next ensureHost respawns — a FRESH client boot (the chosen recovery UX)
791
+ newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
792
+ await waitRendered(n2)
793
+ const lock2 = readLock()
794
+ notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
795
+ assert(lock2 !== null, 'no lockfile after the recovery boot')
796
+ assert(lock2.pid !== lock1.pid, `recovery boot reused the dead pid?! ${lock2.pid}`)
797
+ assert(await isListening(lock2.port), `recovery host port ${lock2.port} not listening`)
798
+
799
+ if (TURN_OK) {
800
+ // the recovered client continues (a real turn through the fresh host)
801
+ await runTurn(n2, 'say ok')
802
+ const line2 = powerlineOf(capture(n2))
803
+ const ctx = contextOf(line2)
804
+ notes.push(`recovered client completed a turn (context after: ${ctx})`)
805
+ assert(ctx !== null && ctx > 0, 'recovered client turn did not register usage')
806
+ }
807
+ record('e. host death recovery', 'PASS', notes, p1)
808
+ } catch (e) {
809
+ record('e. host death recovery', 'FAIL', [...notes, String(e.message)], p1)
810
+ } finally {
811
+ await quitClient(n1)
812
+ await quitClient(n2)
813
+ await reapHost(readLock())
814
+ rmSync(ws1, { recursive: true, force: true })
815
+ rmSync(ws2, { recursive: true, force: true })
816
+ }
817
+ }
818
+
819
+ const scenarioF = async () => {
820
+ const notes = []
821
+ const ws = mkdtempSync(join(tmpdir(), 'p6-ws-f-'))
822
+ const name = 'p6f'
823
+ try {
824
+ newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
825
+ await waitRendered(name)
826
+ if (!TURN_OK) {
827
+ const cap = capture(name, { esc: true })
828
+ writeFileSync('/tmp/p6-mdtable.txt', cap)
829
+ record('f. acceptance (markdown table)', 'BLOCKED', [
830
+ ...notes,
831
+ 'THE acceptance scenario is BLOCKED: no model route answers (probe report above) — nothing was hacked; capture saved to /tmp/p6-mdtable.txt for the record',
832
+ ])
833
+ return
834
+ }
835
+ await submitLine(name, '用一个markdown表格列出两种语言和创建年份,再写一句总结')
836
+ // the model may ask a clarifying user-question (ask_user_question
837
+ // waterfall → the "模型提问" overlay) — answer with the highlighted
838
+ // (recommended) option and keep waiting for the table
839
+ const tableDone = (out) => {
840
+ const lines = out.split('\n').filter((l) => l.includes('│') && !l.includes('❯') && !/[╭╰╮╯]/.test(l))
841
+ return lines.length >= 2 && out.includes('└─') && isIdle(out)
842
+ }
843
+ let done = ''
844
+ let asked = 0
845
+ const deadline = Date.now() + 150_000
846
+ while (Date.now() < deadline) {
847
+ done = capture(name)
848
+ if (tableDone(done)) break
849
+ if (done.includes('模型提问') && Date.now() - asked > 3000) {
850
+ asked = Date.now()
851
+ notes.push('model asked a clarifying question (ask_user_question overlay) — answering with the highlighted option')
852
+ sendKeys(name, 'Enter')
853
+ await sleep(2500)
854
+ continue
855
+ }
856
+ await sleep(800)
857
+ }
858
+ const rows = done.split('\n').filter((l) => l.includes('│') && !l.includes('❯') && !/[╭╰╮╯]/.test(l))
859
+ const bottomIdx = done.split('\n').findIndex((l) => l.includes('└─'))
860
+ assert(rows.length >= 2, `no bordered table rows (│) in the reply pane`)
861
+ assert(bottomIdx !== -1, 'no └─ table bottom border in the reply pane')
862
+ // summary line: non-table text AFTER the table bottom
863
+ const afterBottom = done.split('\n').slice(bottomIdx + 1).map((l) => l.trim()).filter((l) => l.length > 0 && !l.includes('│'))
864
+ const summary = afterBottom.find((l) => !/^╰|^enter send|^ dsh\(|^\d{1,2}:\d{2}:\d{2}$/.test(l) && !l.includes('❯'))
865
+ notes.push(`table rows painted: ${rows.length}; first: ${JSON.stringify(rows[0].trim().slice(0, 80))}`)
866
+ notes.push(`table bottom: ${JSON.stringify(done.split('\n')[bottomIdx].trim().slice(0, 60))}`)
867
+ notes.push(`summary line after the table: ${summary ? JSON.stringify(summary.trim().slice(0, 100)) : 'NOT FOUND'}`)
868
+ assert(summary !== undefined, 'no summary line after the table bottom')
869
+ // bottom anchor: the powerline footer is still the last chrome row
870
+ const tail = done.split('\n').filter((l) => l.trim().length > 0).slice(-3).join(' | ')
871
+ notes.push(`pane tail (bottom anchor): ${tail.slice(0, 140)}`)
872
+ assert(tail.includes('🧠'), `powerline footer missing from the pane tail — UI died or scrolled: ${tail}`)
873
+ assert(/:\d{2}:\d{2}/.test(tail), `powerline clock missing from the tail: ${tail}`)
874
+
875
+ // artifacts: ANSI capture + PNG render for eyeballing
876
+ const esc = capture(name, { esc: true })
877
+ writeFileSync('/tmp/p6-mdtable.txt', esc)
878
+ const png = sh('python3', ['/tmp/tmux2png.py', '/tmp/p6-mdtable.txt', '/tmp/p6-mdtable.png', String(GEOMETRY.x), String(GEOMETRY.y)], { encoding: 'utf8' })
879
+ notes.push(png.status === 0 ? `PNG rendered: ${png.stdout.trim()}` : `PNG render FAILED (exit ${png.status}): ${png.stderr.slice(0, 200)}`)
880
+ record('f. acceptance (markdown table)', 'PASS', notes)
881
+ } catch (e) {
882
+ try { writeFileSync('/tmp/p6-mdtable.txt', capture(name, { esc: true })) } catch { /* keep whatever exists */ }
883
+ record('f. acceptance (markdown table)', 'FAIL', [...notes, String(e.message)])
884
+ } finally {
885
+ await quitClient(name)
886
+ await reapHost(readLock())
887
+ rmSync(ws, { recursive: true, force: true })
888
+ }
889
+ }
890
+
891
+ const scenarioG = async () => {
892
+ const notes = []
893
+ const ws = mkdtempSync(join(tmpdir(), 'p6-ws-g-'))
894
+ const name = 'p6g'
895
+ try {
896
+ // the workspace overlay: a DIFFERENT shipped theme + the model preference
897
+ mkdirSync(join(ws, '.dsh'), { recursive: true })
898
+ // the overlay: theme is the strong assertion; the model pref uses a
899
+ // DIFFERENT catalog route when one exists — the powerline label then
900
+ // provably comes from the workspace config, never the cold-boot default
901
+ // (a dead key on that route is fine: scenario g sends no turn)
902
+ const route = OTHER_ROUTE ?? ROUTE
903
+ const [provider, model] = route ? route.split('/') : [undefined, undefined]
904
+ const configBody =
905
+ `# p6 scenario g workspace overlay\ntheme: dracula\n` +
906
+ (route ? `model:\n provider: ${provider}\n model: ${model}\n` : `# (no concrete route resolvable — model pref omitted)\n`)
907
+ writeFileSync(join(ws, '.dsh', 'config.yml'), configBody)
908
+ notes.push(`workspace config.yml: theme=dracula${route ? ` · model=${route}${OTHER_ROUTE ? ' (differs from the cold-boot default → strong assertion)' : ''}` : ' · (model pref omitted: no concrete route)'}`)
909
+ // boot WITHOUT the env override — the overlay is the only model source
910
+ newClientSession(name, ws, {})
911
+ const rendered = await waitRendered(name)
912
+ const line = powerlineOf(rendered)
913
+ const rt = routeOf(line)
914
+ notes.push(`powerline: ${line.trim().slice(0, 120)}`)
915
+ assert(rt !== null && rt.model !== '·' && rt.provider !== 'dsh-host', `status bar shows a generic route — the overlay label never applied: ${line}`)
916
+ if (route) {
917
+ assert(rt.model === model, `workspace model pref not applied (want ${model}, got ${rt.model})`)
918
+ if (rt.provider !== provider) notes.push(`note: provider seg ${rt.provider} != overlay ${provider} (the label carries the live session route)`)
919
+ }
920
+ // workspace cwd carried: the banner prints `<model> · <cwd>`
921
+ const banner = rendered.split('\n').find((l) => l.includes(' · ') && l.includes(ws.split('/').pop()))
922
+ assert(banner !== undefined || rendered.includes(ws), `welcome banner does not show the workspace cwd (${ws})`)
923
+ notes.push(`banner cwd line: ${JSON.stringify((banner ?? rendered.split('\n').find((l) => l.includes(ws)))?.trim().slice(0, 100))}`)
924
+
925
+ // theme actually applied: paint a user row, then compare truecolor SGRs
926
+ await submitLine(name, 'theme probe')
927
+ await waitPane(name, (out) => out.includes('❯ theme probe'), 15_000)
928
+ await sleep(2000)
929
+ const esc = capture(name, { esc: true })
930
+ const draculaAccent = esc.includes('38;2;189;147;249') // #bd93f9 — dracula accent (user-row fallback: no userMark token)
931
+ const mochaUserMark = esc.includes('38;2;137;180;250') // #89b4fa — catppuccin-mocha userMark
932
+ notes.push(`capture -e: dracula accent painted=${draculaAccent} · mocha userMark painted=${mochaUserMark}`)
933
+ assert(draculaAccent, 'dracula accent color not found in capture -e — the workspace theme did not apply')
934
+ assert(!mochaUserMark, 'the default mocha user-row color is still painted — theme override ineffective')
935
+
936
+ // cross-check against scenario f's default-theme capture when available
937
+ if (existsSync('/tmp/p6-mdtable.txt')) {
938
+ const f = readFileSync('/tmp/p6-mdtable.txt', 'utf8')
939
+ notes.push(`cross-check: scenario f (default theme) capture paints mocha userMark=${f.includes('38;2;137;180;250')} — g differs=${f.includes('38;2;137;180;250') && !mochaUserMark}`)
940
+ }
941
+ record('g. workspace config effect', 'PASS', notes)
942
+ } catch (e) {
943
+ record('g. workspace config effect', 'FAIL', [...notes, String(e.message)])
944
+ } finally {
945
+ await quitClient(name)
946
+ await reapHost(readLock())
947
+ rmSync(ws, { recursive: true, force: true })
948
+ }
949
+ }
950
+
951
+ // ---------------------------------------------------------------------------
952
+ // Main.
953
+ // ---------------------------------------------------------------------------
954
+
955
+ const main = async () => {
956
+ ownedTmuxServer = !tmuxServerWasRunning()
957
+ console.log(`e2e-client: repo=${REPO} · dshHome=${DSH_HOME} · tmux ${GEOMETRY.x}x${GEOMETRY.y}`)
958
+ const startLock = readLock()
959
+ if (startLock && pidAlive(startLock.pid)) {
960
+ console.log(`NOTE: a host is ALREADY resident (pid=${startLock.pid} port=${startLock.port}) — the gate adopts it and its teardown may idle-exit it; relaunching the TUI respawns it`)
961
+ }
962
+
963
+ console.log('\n--- workingRoute discovery (probe before any scenario) ---')
964
+ const probe = await probeWorkingRoute()
965
+ console.log(` host: pid=${probe.hostPid} port=${probe.hostPort} adopted=${probe.adopted}`)
966
+ if (probe.ok) {
967
+ TURN_OK = true
968
+ ROUTE = probe.route
969
+ OTHER_ROUTE = probe.otherRoute
970
+ console.log(` route: ${ROUTE ? (probe.adoptedDefault ? `deployment default answers → ${ROUTE}` : `fallback selected → ${ROUTE}`) : 'a route answers but its concrete id was not resolvable — turns run without the env override'}${OTHER_ROUTE ? ` · other catalog route: ${OTHER_ROUTE}` : ''}`)
971
+ } else {
972
+ TURN_OK = false
973
+ ROUTE = null
974
+ console.log(' !! NO ROUTE ANSWERS — real turns are BLOCKED; running everything except real turns (loud, no hacks)')
975
+ for (const e of probe.errors) console.log(` ${e}`)
976
+ }
977
+
978
+ for (const pid of hostPids()) {
979
+ if (!probe.hostPid || pid !== probe.hostPid) {
980
+ console.log(` stray host wrapper pid=${pid} — killing before the scenarios`)
981
+ await killHostTree(pid)
982
+ }
983
+ }
984
+
985
+ await scenarioA()
986
+ await preClean('between a→b')
987
+ await scenarioB()
988
+ await preClean('between b→c')
989
+ await scenarioC()
990
+ await preClean('between c→d')
991
+ await scenarioD()
992
+ await preClean('between d→e')
993
+ await scenarioE()
994
+ await preClean('between e→f')
995
+ await scenarioF()
996
+ await preClean('between f→g')
997
+ await scenarioG()
998
+ await preClean('final')
999
+
1000
+ // tmux hygiene: our sessions are already dead (every scenario quits/kills
1001
+ // its panes); kill the server only when THIS run created it.
1002
+ for (const s of sessionsCreated) {
1003
+ if (paneAlive(s)) tmux('kill-session', '-t', s)
1004
+ }
1005
+ const leftovers = tmuxOut('list-sessions')
1006
+ if (ownedTmuxServer) {
1007
+ tmux('kill-server')
1008
+ console.log('\ntmux: server killed (this run created it)')
1009
+ } else if (leftovers.trim()) {
1010
+ console.log(`\ntmux: pre-existing server left running with sessions:\n${leftovers.trim()}`)
1011
+ }
1012
+
1013
+ console.log('\n==================== e2e-client summary ====================')
1014
+ const fails = scenarios.filter((s) => s.verdict.startsWith('FAIL'))
1015
+ const blocked = scenarios.filter((s) => s.verdict.startsWith('BLOCKED'))
1016
+ for (const s of scenarios) console.log(`${s.verdict.padEnd(28)} ${s.name}`)
1017
+ const allP1 = scenarios.flatMap((s) => s.p1.map((p) => `${s.name}: ${p}`))
1018
+ if (allP1.length > 0) {
1019
+ console.log('\noldfox-P1 reproductions:')
1020
+ for (const p of allP1) console.log(` ${p}`)
1021
+ } else {
1022
+ console.log('\noldfox-P1 reproductions: NONE — the known P1 triggers did not fire under this gate (narrower trigger surface than estimated)')
1023
+ }
1024
+ if (blocked.length > 0) {
1025
+ console.log(`\n!! BLOCKED scenarios: ${blocked.map((b) => b.name).join(', ')} — environment limitation, reported loudly, nothing hacked`)
1026
+ }
1027
+ if (fails.length > 0) {
1028
+ console.log(`\nRESULT: FAIL (${fails.length} failed / ${scenarios.length} scenarios)`)
1029
+ process.exit(1)
1030
+ }
1031
+ if (blocked.some((b) => b.name.startsWith('f.'))) {
1032
+ console.log('\nRESULT: INCOMPLETE — the acceptance scenario (f) is blocked by dead provider keys')
1033
+ process.exit(1)
1034
+ }
1035
+ console.log(`\nRESULT: PASS (${scenarios.length - fails.length - blocked.length} pass / ${blocked.length} blocked / ${scenarios.length} total)`)
1036
+ }
1037
+
1038
+ main().catch((e) => {
1039
+ console.error(`e2e-client: crashed: ${e.stack ?? e.message}`)
1040
+ for (const s of sessionsCreated) if (paneAlive(s)) tmux('kill-session', '-t', s)
1041
+ process.exit(1)
1042
+ })