spexcode 0.1.6 → 0.2.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.
Files changed (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +49 -13
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -8,16 +8,60 @@ const pexec = promisify(execFile)
8
8
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
9
9
  // cold fallback size for a session no viewer has ever sized (see lastFit).
10
10
  const DEFAULT_COLS = 120, DEFAULT_ROWS = 40
11
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
12
11
 
13
12
  // a viewer: anything we can push pane bytes to (a WebSocket, wrapped).
14
13
  export type Viewer = { send: (data: Buffer) => void }
15
14
 
16
- type Bridge = { id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean; clientTty?: string; repaintToken: number }
15
+ // resolver for one control-mode command's %begin..%end reply lines (raw bytes a capture-pane body is UTF-8).
16
+ type Pending = (lines: Buffer[]) => void
17
+ type Bridge = {
18
+ id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean
19
+ repaintToken: number
20
+ // control-mode parser state: an incomplete-line BYTE buffer, the in-flight command block (%begin..%end) with
21
+ // its command number, a FIFO of one resolver per command sent (tmux answers in order), and the last
22
+ // %layout-change size so a repaint knows the pane already converged and needn't wait for the event.
23
+ buf: Buffer
24
+ block: Buffer[] | null
25
+ blockNum: string
26
+ cmdQ: Pending[]
27
+ lastLayout?: string
28
+ // one waiter for the %layout-change that confirms the wanted size — no timer: `refresh-client -C` is
29
+ // PROVEN to always emit exactly one %layout-change carrying the requested WxH (even a same-size no-op), so
30
+ // the event is a guaranteed arrival, not a hope that needs a settle-timeout ([[deterministic-convergence]]).
31
+ layoutWaiter?: { want: string; resolve: () => void }
32
+ // the next repaint must be a FULL frame — the DEC-mode prelude (so xterm mirrors the pane's alt-screen /
33
+ // mouse state), not a resize's visible-only re-seed. Set on every (re)attach and re-bind, since a
34
+ // (re)connecting viewer's xterm is blank / just reset.
35
+ needsFull?: boolean
36
+ // Copy-mode freezes the pane's view. While that mode owns the screen, raw %output still describes the
37
+ // underlying live grid; repaint owns what viewers see until mode exit snaps them back to the bottom.
38
+ paneInMode?: boolean
39
+ }
17
40
  const bridges = new Map<string, Bridge>()
18
41
  // viewers keyed by session id (not the Bridge), so a subscription outlives any bridge death/respawn.
19
42
  const subscribers = new Map<string, Set<Viewer>>()
20
43
 
44
+ type PaneMode = {
45
+ inMode: boolean
46
+ alternate: boolean
47
+ mouseStandard: boolean
48
+ mouseButton: boolean
49
+ mouseAny: boolean
50
+ mouseSgr: boolean
51
+ scrollPosition: number
52
+ paneHeight: number
53
+ cursorX: number
54
+ cursorY: number
55
+ }
56
+ // cursor_x/cursor_y are the underlying program's cursor (0-based), NOT the copy-mode cursor — so a frame can
57
+ // restore it and any following live %output that moves the cursor RELATIVELY resumes from the right origin.
58
+ const PANE_MODE_FORMAT = '#{pane_in_mode},#{alternate_on},#{mouse_standard_flag},#{mouse_button_flag},#{mouse_any_flag},#{mouse_sgr_flag},#{scroll_position},#{pane_height},#{cursor_x},#{cursor_y}'
59
+ const flag = (v?: string) => v === '1'
60
+ function num(v?: string): number {
61
+ const n = Number(v)
62
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
63
+ }
64
+
21
65
  // last size each viewer fitted (per session + a global fallback), so pre-warm spawns at the wanted size.
22
66
  const lastFit = new Map<string, { cols: number; rows: number }>()
23
67
  let lastFitAny: { cols: number; rows: number } | null = null
@@ -25,10 +69,55 @@ function prewarmSize(id: string): { cols: number; rows: number } {
25
69
  return lastFit.get(id) ?? lastFitAny ?? { cols: DEFAULT_COLS, rows: DEFAULT_ROWS }
26
70
  }
27
71
 
72
+ // push pane bytes to every viewer of a session (the set survives a bridge swap).
73
+ function broadcast(id: string, buf: Buffer): void {
74
+ for (const v of subscribers.get(id) ?? []) { try { v.send(buf) } catch { /* drop a wedged viewer */ } }
75
+ }
76
+
77
+ const isOct = (c: number) => c >= 0x30 && c <= 0x37 // ASCII '0'..'7'
78
+
79
+ // %output escaping (MEASURED against tmux 3.4, not assumed): tmux octal-escapes ONLY the C0 control bytes and
80
+ // backslash (`\015` `\012` `\033` `\134`, all < 0x80) — every high byte is passed THROUGH RAW. We parse the
81
+ // whole control stream as BYTES (never a string), because node-pty's own utf8 decode splits a multi-byte
82
+ // character straddling two OS reads into a U+FFFD before we could see it — the corruption this closes. So work
83
+ // on the raw `%output` byte segment: a `\NNN` (backslash 0x5C + three octal-digit bytes) becomes that one
84
+ // byte, every other byte passes through UNTOUCHED. The result is already the pane's exact UTF-8 byte stream —
85
+ // broadcast it verbatim, with NO string round-trip to shatter a wide character (`我` / `┌` / `😀`).
86
+ function unescapeOutput(data: Buffer): Buffer {
87
+ const out = Buffer.allocUnsafe(data.length)
88
+ let j = 0
89
+ for (let i = 0; i < data.length; i++) {
90
+ const c = data[i]
91
+ if (c === 0x5C && i + 3 < data.length && isOct(data[i + 1]) && isOct(data[i + 2]) && isOct(data[i + 3])) {
92
+ out[j++] = ((data[i + 1] - 0x30) << 6) | ((data[i + 2] - 0x30) << 3) | (data[i + 3] - 0x30)
93
+ i += 3
94
+ } else {
95
+ out[j++] = c
96
+ }
97
+ }
98
+ return out.subarray(0, j)
99
+ }
100
+
101
+ // join capture-pane reply lines (each already raw UTF-8 bytes) with CRLF at the byte level — no string round-trip.
102
+ const CRLF = Buffer.from('\r\n')
103
+ function joinLines(lines: Buffer[]): Buffer {
104
+ if (lines.length === 0) return Buffer.alloc(0)
105
+ const parts: Buffer[] = []
106
+ for (let i = 0; i < lines.length; i++) { if (i) parts.push(CRLF); parts.push(lines[i]) }
107
+ return Buffer.concat(parts)
108
+ }
109
+
28
110
  async function tmuxRaw(args: string[]): Promise<void> {
29
111
  try { await pexec('tmux', ['-L', TMUX_SOCK, ...args]) } catch { /* best-effort */ }
30
112
  }
31
- // how many clients are attached — pre-warm skips a session a human is already in (avoids a size-fight).
113
+ async function tmuxOut(args: string[]): Promise<string> {
114
+ try {
115
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args])
116
+ return stdout.trim()
117
+ } catch { return '' }
118
+ }
119
+ // how many clients are attached — pre-warm skips a session a human is already in (avoids a size-fight). Read
120
+ // once at reconcile time (before our own control client attaches), so it counts only foreign/human clients.
32
121
  async function attachedCount(id: string): Promise<number> {
33
122
  try {
34
123
  const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'display-message', '-p', '-t', id, '-F', '#{session_attached}'])
@@ -46,7 +135,125 @@ async function ensureTmuxOpts(): Promise<void> {
46
135
  await tmuxRaw(['set', '-g', 'history-limit', '50000'])
47
136
  }
48
137
 
49
- // spawn the shared tmux client for a session (idempotent). Returns null if node-pty can't spawn.
138
+ // --- control-mode protocol ---------------------------------------------------
139
+ // The one client per session is a tmux control-mode connection (`tmux -CC attach-session`). tmux speaks a
140
+ // line protocol on this pty: %output events push pane bytes, %begin/%end frame each command's reply, and
141
+ // %layout-change announces the converged size. So resize is deterministic (refresh-client -C, told done by
142
+ // %layout-change) and bytes arrive as events — no pty resize + geometry poll, no per-repaint tmux exec.
143
+
144
+ // send one control-mode command; resolve with its %begin..%end reply lines. tmux answers in order, so a FIFO
145
+ // of resolvers matches each block to its command.
146
+ function command(b: Bridge, cmd: string): Promise<Buffer[]> {
147
+ return new Promise((resolve) => {
148
+ b.cmdQ.push(resolve)
149
+ try { b.pty.write(cmd + '\n') } catch { b.cmdQ = b.cmdQ.filter((r) => r !== resolve); resolve([]) }
150
+ })
151
+ }
152
+
153
+ // parse the control stream line by line AT THE BYTE LEVEL (an incomplete tail is held in b.buf until its 0x0A
154
+ // arrives). Splitting on the newline byte — never on a decoded string — is what keeps a multi-byte UTF-8
155
+ // character intact when it straddles two OS reads: node-pty hands us raw Buffers (encoding:null), so no read
156
+ // boundary can shatter a wide char into a U+FFFD before we reassemble the line.
157
+ function feed(b: Bridge, chunk: Buffer): void {
158
+ b.buf = b.buf.length ? Buffer.concat([b.buf, chunk]) : chunk
159
+ let i: number
160
+ while ((i = b.buf.indexOf(0x0A)) >= 0) {
161
+ let line = b.buf.subarray(0, i)
162
+ if (line.length && line[line.length - 1] === 0x0D) line = line.subarray(0, line.length - 1)
163
+ b.buf = b.buf.subarray(i + 1)
164
+ onLine(b, line)
165
+ }
166
+ }
167
+
168
+ // strip the control-mode DCS wrapper (`\x1bP<n>p` on enter, `\x1b\\` on exit) at the byte level — its bytes are
169
+ // all ASCII and it only ever brackets the first/last notification, never %output data or capture content.
170
+ function stripDcs(line: Buffer): Buffer {
171
+ if (line.length >= 2 && line[0] === 0x1b && line[1] === 0x50) { // ESC P … p
172
+ const p = line.indexOf(0x70, 2)
173
+ if (p >= 0) line = line.subarray(p + 1)
174
+ }
175
+ if (line.length >= 2 && line[line.length - 2] === 0x1b && line[line.length - 1] === 0x5c) line = line.subarray(0, line.length - 2) // ESC backslash
176
+ return line
177
+ }
178
+
179
+ function onLine(b: Bridge, lineBuf: Buffer): void {
180
+ // Inside a command reply everything is verbatim content until %end/%error. Capture-pane body lines are RAW
181
+ // pane bytes (real escapes + UTF-8), so they must NOT go through stripDcs: a captured row can legitimately
182
+ // END in \x1b\\ — the ST that terminates an OSC 8 hyperlink (`\x1b]8;;\x1b\\`, e.g. a Claude Code URL) — and
183
+ // stripDcs's trailing-\x1b\\ strip would eat it, leaving the hyperlink unterminated so xterm never closes it
184
+ // and paints the rest of the screen underlined. The DCS-exit wrapper only ever ends a control line at stream
185
+ // exit, never a reply body, so the strip belongs to the protocol path below, not here. Classify %end/%error
186
+ // on the raw line — but only one whose command number matches this %begin's closes it, so a pane row that
187
+ // merely starts with "%end" can't false-close.
188
+ if (b.block) {
189
+ const h = lineBuf.toString('latin1')
190
+ const m = h.match(/^%(?:end|error) \S+ (\d+)/)
191
+ if (m && m[1] === b.blockNum) {
192
+ const lines = b.block; b.block = null
193
+ const resolve = b.cmdQ.shift(); if (resolve) resolve(lines)
194
+ } else {
195
+ b.block.push(lineBuf) // raw bytes verbatim — escapes (incl. OSC 8 ST) + UTF-8, not to be string-mangled
196
+ }
197
+ return
198
+ }
199
+ const line = stripDcs(lineBuf)
200
+ // The control PROTOCOL (%begin/%end/%error/%output/%layout-change prefixes, command numbers, layout tokens)
201
+ // is pure ASCII, so decode as latin1 for classification only — a total 1-byte↔1-char map, so a byte index
202
+ // in `head` is the same byte index in `line`; the DATA is taken from the raw Buffer, never from `head`.
203
+ const head = line.toString('latin1')
204
+ if (head.startsWith('%output ')) {
205
+ if (b.paneInMode) return
206
+ const sp = head.indexOf(' ', 8) // skip "%output %<pane> " to the raw (escaped) data
207
+ if (sp > 0) broadcast(b.id, unescapeOutput(line.subarray(sp + 1)))
208
+ return
209
+ }
210
+ const beg = head.match(/^%begin \S+ (\d+)/)
211
+ if (beg) { b.block = []; b.blockNum = beg[1]; return }
212
+ if (head.startsWith('%layout-change ')) {
213
+ const m = head.match(/,(\d+x\d+),/) // layout token = checksum,WIDTHxHEIGHT,x,y,… — the window size
214
+ onLayout(b, m ? m[1] : undefined)
215
+ return
216
+ }
217
+ // Mode enter/exit emits no pane bytes of its own. Route through repaint only: paneInMode is written solely
218
+ // by repaint under its token, so racing mode flips can't leave a stale read as the last freeze-state write.
219
+ // ALWAYS repaint — even with a full attach frame pending (needsFull): skipping here left the flip's freeze
220
+ // state to a repaint that could be superseded and never land, latching %output frozen. The token machinery
221
+ // already dedups (the newest repaint wins); a spare repaint is cheap, a dropped mode flip mutes the bridge.
222
+ if (head.startsWith('%pane-mode-changed')) {
223
+ void repaint(b)
224
+ return
225
+ }
226
+ // %exit / %client-detached / window close → the client is gone; pty.onExit drives the re-bind.
227
+ }
228
+
229
+ function onLayout(b: Bridge, size?: string): void {
230
+ if (!size) return
231
+ const changed = b.lastLayout !== undefined && b.lastLayout !== size
232
+ b.lastLayout = size
233
+ const w = b.layoutWaiter
234
+ if (w) { b.layoutWaiter = undefined; w.resolve() }
235
+ // an UNSOLICITED geometry change (another control client resized the shared window) with no repaint in
236
+ // flight: re-seed the viewers at the new size, or they keep painting deltas onto stale geometry (garble).
237
+ else if (changed) void repaint(b)
238
+ }
239
+
240
+ // resolve when the NEXT %layout-change arrives — the pane has re-wrapped at whatever size tmux CONVERGED to.
241
+ // DETERMINISTIC, NO TIMER: `refresh-client -C WxH` is measured to ALWAYS emit exactly one %layout-change
242
+ // (even a same-size no-op), so the event is a guaranteed arrival. Crucially the waiter accepts ANY announced
243
+ // size, never an exact match on the size we asked for: every viewer/peek/prewarm is its own control client
244
+ // sharing ONE window under `window-size latest`, so the announcement routinely carries a DIFFERENT client's
245
+ // size — an exact-size wait deadlocks the seed frame forever (needsFull latches, %output stays frozen: the
246
+ // mute black terminal). Immediate when already converged at `want`. A newer repaint supersedes an older
247
+ // waiter by resolving it — the superseded repaint then falls out on its stale token.
248
+ function awaitLayout(b: Bridge, want: string): Promise<void> {
249
+ if (b.lastLayout === want) return Promise.resolve()
250
+ return new Promise((resolve) => {
251
+ b.layoutWaiter?.resolve()
252
+ b.layoutWaiter = { want, resolve }
253
+ })
254
+ }
255
+
256
+ // spawn the shared control-mode client for a session (idempotent). Returns null if node-pty can't spawn.
50
257
  function ensureBridge(id: string, prewarm = false): Bridge | null {
51
258
  let b = bridges.get(id)
52
259
  if (b) { if (prewarm) b.prewarmed = true; return b }
@@ -54,24 +261,25 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
54
261
  const { cols, rows } = prewarmSize(id)
55
262
  let p: IPty
56
263
  try {
57
- // -u + a UTF-8 LANG force this client to emit UTF-8 even when the host locale is empty (a LaunchAgent
58
- // gives LANG="" → tmux substitutes `_` for every wide char).
59
- p = pty.spawn('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], {
60
- name: 'xterm-256color', cols, rows,
264
+ // -CC = control mode (event stream); -u + a UTF-8 LANG force UTF-8 output even when the host locale is
265
+ // empty (a LaunchAgent gives LANG="" → tmux would substitute `_` for every wide char). encoding:null makes
266
+ // onData deliver raw Buffers so a wide char split across two reads can't be pre-decoded into a U+FFFD.
267
+ p = pty.spawn('tmux', ['-u', '-CC', '-L', TMUX_SOCK, 'attach-session', '-t', id], {
268
+ name: 'xterm-256color', cols, rows, encoding: null,
61
269
  env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' } as Record<string, string>,
62
270
  })
63
271
  } catch { return null }
64
- b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0 }
272
+ b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
65
273
  bridges.set(id, b)
66
- // tmux output → broadcast as raw bytes to every viewer in `subscribers` (which survives a bridge swap).
67
- p.onData((data) => {
68
- const buf = Buffer.from(data, 'utf8')
69
- for (const v of subscribers.get(id) ?? []) { try { v.send(buf) } catch { /* drop a wedged viewer */ } }
70
- })
71
- // attach-session exited (session died or we detached): drop the bridge, and if viewers remain kick a
72
- // reconcile to re-bind fast instead of waiting a full tick (the kick is alive-gated + serialized).
274
+ const bx = b
275
+ p.onData((d) => feed(bx, d as unknown as Buffer)) // encoding:null → d is a Buffer (typings say string)
276
+ // attach-session exited (session died or we detached): drop the bridge, unblock any awaiting command AND any
277
+ // %layout-change waiter (no timer backs it now, so a bridge that dies mid-convergence MUST resolve its
278
+ // waiter or the awaiting repaint hangs), and if viewers remain kick a reconcile to re-bind fast.
73
279
  p.onExit(() => {
74
- if (bridges.get(id) === b) bridges.delete(id)
280
+ if (bx.layoutWaiter) { const w = bx.layoutWaiter; bx.layoutWaiter = undefined; w.resolve() }
281
+ const q = bx.cmdQ; bx.cmdQ = []; for (const r of q) r([])
282
+ if (bridges.get(id) === bx) bridges.delete(id)
75
283
  if ((subscribers.get(id)?.size ?? 0) > 0) kickSupervisor()
76
284
  })
77
285
  return b
@@ -80,69 +288,163 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
80
288
  function killBridge(id: string): void {
81
289
  const b = bridges.get(id)
82
290
  if (!b) return
291
+ if (b.layoutWaiter) { const w = b.layoutWaiter; b.layoutWaiter = undefined; w.resolve() }
83
292
  bridges.delete(id)
84
293
  try { b.pty.kill() } catch { /* already gone */ }
85
294
  }
86
295
 
87
- // a browser viewer connects: subscribe it to the (warm or fresh) bridge, then settleAndRepaint for one
88
- // coherent frame (a refresh-client down the same pty, never a spliced capture-pane snapshot).
89
- export function attachViewer(id: string, v: Viewer): boolean {
296
+ // a browser viewer connects: subscribe it to the (warm or fresh) bridge, then paint one coherent frame at
297
+ // the converged size (see repaint), never a guessed-size splice. Two connect shapes:
298
+ // - VISIBLE (re)connect the client could measure its pane and carried its real size on the URL (the
299
+ // size-first handshake), so size the bridge to it FIRST and draw that very frame at the correct size.
300
+ // - HIDDEN connect — a warm pane is still 0×0, so the client carries no size. DON'T paint a guessed
301
+ // prewarm frame now: it'd be undersized and, landing in a still-hidden buffer, would only have to be
302
+ // covered the instant the pane becomes visible (the old two-stage scramble). Defer the one first-frame
303
+ // paint — purely — to the client's first resize, which fires the moment the pane becomes visible and the
304
+ // client measures its real size, so the first frame is drawn at the true visible size. NO timer fallback:
305
+ // a pane that never resizes is a pane no one ever looks at (a viewer sends its real size the instant it
306
+ // becomes visible), so it needs no frame — the first paint is a pure resize event, zero timer.
307
+ export function attachViewer(id: string, v: Viewer, initialSize?: { cols: number; rows: number }): boolean {
90
308
  let s = subscribers.get(id)
91
309
  if (!s) subscribers.set(id, s = new Set())
92
310
  s.add(v)
93
311
  const b = ensureBridge(id)
94
312
  if (!b) return false // spawn failed → caller closes the socket → detachViewer prunes this subscriber
95
- void settleAndRepaint(b)
313
+ b.needsFull = true // a (re)connecting viewer's xterm is blank / just reset → its first frame must be FULL
314
+ if (initialSize && initialSize.cols > 0 && initialSize.rows > 0) {
315
+ applySize(b, initialSize.cols, initialSize.rows) // resize-then-repaint at the client's true size
316
+ }
317
+ // else HIDDEN connect (0×0, no size): paint nothing now — the first frame is driven purely by the client's
318
+ // first resize (fires when the pane becomes visible at its true size). No timer, never a guessed frame.
96
319
  return true
97
320
  }
98
- // our attach client's tty, matched by pid (b.pty.pid === client_pid) and cached. refresh-client must
99
- // target OUR client so the redraw hits only the dashboard's pty, not a human sharing the same session.
100
- async function clientTty(b: Bridge): Promise<string | null> {
101
- if (b.clientTty) return b.clientTty
102
- try {
103
- const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'list-clients', '-t', b.id, '-F', '#{client_pid} #{client_tty}'])
104
- for (const line of stdout.split('\n')) {
105
- const sp = line.indexOf(' ')
106
- if (sp > 0 && Number(line.slice(0, sp)) === b.pty.pid) return (b.clientTty = line.slice(sp + 1).trim())
107
- }
108
- } catch { /* client not registered yet; a size-changing open resize will repaint instead */ }
109
- return null
110
- }
111
- // force a full coherent repaint of our client down the shared pty. On a fresh respawn the new client may
112
- // not be registered yet (clientTty briefly null), so retry until it resolves, bounded (~0.5s); a newer
113
- // token supersedes us so we never clobber a fresher size.
114
- async function repaint(b: Bridge, token: number): Promise<void> {
115
- for (let i = 0; i < 24; i++) {
116
- if (token !== b.repaintToken) return
117
- const tty = await clientTty(b)
118
- if (tty) { await tmuxRaw(['refresh-client', '-t', tty]); return }
119
- await sleep(20)
321
+
322
+ async function readPaneMode(id: string): Promise<PaneMode> {
323
+ const raw = await tmuxOut(['display-message', '-p', '-t', id, '-F', PANE_MODE_FORMAT])
324
+ const [inMode, alternate, mouseStandard, mouseButton, mouseAny, mouseSgr, scrollPosition, paneHeight, cursorX, cursorY] = raw.split(',')
325
+ return {
326
+ inMode: flag(inMode),
327
+ alternate: flag(alternate),
328
+ mouseStandard: flag(mouseStandard),
329
+ mouseButton: flag(mouseButton),
330
+ mouseAny: flag(mouseAny),
331
+ mouseSgr: flag(mouseSgr),
332
+ scrollPosition: num(scrollPosition),
333
+ paneHeight: num(paneHeight),
334
+ cursorX: num(cursorX),
335
+ cursorY: num(cursorY),
120
336
  }
121
337
  }
122
- // tmux's actual pane geometry for our session — the ground truth we wait on before repainting.
123
- async function paneSize(b: Bridge): Promise<{ cols: number; rows: number } | null> {
124
- try {
125
- const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'display-message', '-p', '-t', b.id, '-F', '#{pane_width}x#{pane_height}'])
126
- const m = stdout.trim().match(/^(\d+)x(\d+)$/)
127
- if (m) return { cols: Number(m[1]), rows: Number(m[2]) }
128
- } catch { /* session momentarily ungettable; treat as not-yet-settled */ }
129
- return null
130
- }
131
- // every (re)attach and resize routes here. A per-bridge token coalesces a burst (attach + open-time
132
- // resize) to one run: settle, poll tmux's real pane geometry until it equals the size we asked for, then
133
- // fire a single refresh-client. A newer token supersedes us at every checkpoint. Bounded (~0.5s).
134
- async function settleAndRepaint(b: Bridge): Promise<void> {
338
+
339
+ function canInjectSgrWheel(mode: PaneMode): boolean {
340
+ return mode.mouseSgr && (mode.mouseStandard || mode.mouseButton || mode.mouseAny)
341
+ }
342
+
343
+ // a control-mode bare attach REPLAYS NOTHING (unlike a raw attach, where tmux resends the pane's whole terminal
344
+ // state alt-screen switch, mouse-tracking modes, …). So a FULL (re)attach frame must RECONSTRUCT that state:
345
+ // emit the matching DEC private-mode prelude from the same pane-mode abstraction used for wheel routing. The
346
+ // browser xterm mirrors the pane on the ALTERNATE screen and in the app's mouse-tracking mode; live mode
347
+ // changes flow through %output naturally.
348
+ function paneModePrelude(mode: PaneMode): string {
349
+ let s = ''
350
+ if (mode.alternate) s += '\x1b[?1049h' // alternate screen (full-screen TUI)
351
+ if (mode.mouseStandard) s += '\x1b[?1000h'
352
+ if (mode.mouseButton) s += '\x1b[?1002h'
353
+ if (mode.mouseAny) s += '\x1b[?1003h'
354
+ if (mode.mouseSgr) s += '\x1b[?1006h' // SGR-encoded mouse reports
355
+ return s
356
+ }
357
+
358
+ function wheelMouseReport(up: boolean, col: number, row: number, ticks: number): string {
359
+ const button = up ? 64 : 65
360
+ return `\x1b[<${button};${col};${row}M`.repeat(ticks)
361
+ }
362
+
363
+ function capturePaneCommand(b: Bridge, mode: PaneMode): string {
364
+ if (!mode.inMode) return `capture-pane -e -p -t ${b.id}`
365
+ // tmux capture-pane's default "visible pane" ignores the copy-mode viewport. In copy-mode,
366
+ // scroll_position is the offset above the bottom visible screen; capture that history window explicitly.
367
+ const rows = Math.max(1, mode.paneHeight || b.rows)
368
+ const scroll = mode.scrollPosition
369
+ const start = scroll === 0 ? '0' : `-${scroll}`
370
+ const end = rows - 1 - scroll
371
+ return `capture-pane -e -p -t ${b.id} -S ${start} -E ${end}`
372
+ }
373
+
374
+ // A wheel always enters at the tmux adapter boundary. If the pane is in copy-mode, or is a normal pane with
375
+ // tmux history, tmux scrolls its own copy-mode view and we repaint that view. If the pane is a mouse-owning
376
+ // TUI with SGR mouse reports enabled, inject that wheel report. No harness-specific branch exists.
377
+ export function forwardWheel(id: string, up: boolean, col: number, row: number, ticks: number): void {
378
+ const b = bridges.get(id)
379
+ if (!b) return
380
+ const c = Math.max(1, Math.floor(col) || 1), r = Math.max(1, Math.floor(row) || 1)
381
+ const n = Math.max(1, Math.min(10, Math.floor(ticks) || 1))
382
+ void (async () => {
383
+ const mode = await readPaneMode(id)
384
+ if (mode.inMode || !canInjectSgrWheel(mode)) {
385
+ if (up && !mode.inMode) await tmuxRaw(['copy-mode', '-e', '-t', id])
386
+ if (up || mode.inMode) {
387
+ await tmuxRaw(['send-keys', '-t', id, '-X', '-N', String(n * 5), up ? 'scroll-up' : 'scroll-down'])
388
+ await repaint(b)
389
+ }
390
+ return
391
+ }
392
+ await tmuxRaw(['send-keys', '-t', id, '-l', '--', wheelMouseReport(up, c, r, n)])
393
+ })()
394
+ }
395
+
396
+ // every (re)attach and resize routes here. Deterministic, event-driven, zero polling: set the size with
397
+ // refresh-client -C, wait to be TOLD it converged by the guaranteed %layout-change (NO timer), then seed one
398
+ // coherent frame from a capture-pane at that size. A per-bridge token supersedes a stale run at every await.
399
+ // The capture frame broadcasts synchronously at its block-end, so any %output that follows in the stream
400
+ // lands AFTER the frame and is never overwritten by it — the frame is the attach seed, %output the live tail.
401
+ //
402
+ // A frame is a COMPLETE reconstruction of the pane's terminal state at the converged size — so the live
403
+ // %output that follows renders coherently on top of it. A `capture-pane` seed carries only the GRID (cells +
404
+ // their attributes + hyperlinks, byte-verbatim); the frame wraps the rest of the state around it, in stream
405
+ // order:
406
+ // modes — alt-screen + mouse tracking, reconstructed from the pane's live flags (FULL frames only — a
407
+ // plain resize keeps the modes the browser already holds; control mode never re-emits them).
408
+ // pen — reset SGR + close any open OSC 8 hyperlink, so no attribute/hyperlink state leaks across the
409
+ // clear from the prior frame (xterm renders an unclosed hyperlink as a whole-screen underline).
410
+ // clear — blank the viewport (`\x1b[H\x1b[2J`, never the scrollback `\x1b[3J`).
411
+ // grid — the captured rows, joined at the BYTE level so a wide char / an OSC 8 ST is never string-mangled.
412
+ // cursor — put the cursor where the pane REALLY has it, so a relative live redraw resumes from the right
413
+ // origin. An inline TUI (Ink) erases its previous frame by moving up from where it left the cursor
414
+ // — which sits on the input line, above trailing hint rows; a frame that left the cursor at the
415
+ // body's end would make the next redraw erase the wrong rows and double the bottom UI.
416
+ // Each live-view rendering bug was ONE missing piece of this reconstruction (a mangled grid byte, a leaked
417
+ // hyperlink, a dropped cursor); building the whole state in one place is what keeps them all fixed.
418
+ function reconstructFrame(mode: PaneMode, lines: Buffer[], full: boolean): Buffer {
419
+ const modes = full ? paneModePrelude(mode) : ''
420
+ const pen = '\x1b[m\x1b]8;;\x1b\\'
421
+ const clear = '\x1b[H\x1b[2J'
422
+ const cursor = `\x1b[${mode.cursorY + 1};${mode.cursorX + 1}H`
423
+ return Buffer.concat([Buffer.from(modes + pen + clear, 'utf8'), joinLines(lines), Buffer.from(cursor, 'utf8')])
424
+ }
425
+
426
+ // every (re)attach and resize routes here to broadcast one reconstructed frame (see reconstructFrame). The
427
+ // frame reflects the pane at its command boundary, so live %output that follows lands after it, never under it.
428
+ async function repaint(b: Bridge): Promise<void> {
135
429
  const token = ++b.repaintToken
136
- await sleep(30) // coalesce an attach+resize burst to the final size
137
- for (let i = 0; i < 24; i++) {
138
- if (token !== b.repaintToken) return // superseded by a newer attach/resize → let it win
139
- const sz = await paneSize(b)
140
- if (sz && sz.cols === b.cols && sz.rows === b.rows) break
141
- await sleep(20)
142
- }
430
+ const want = `${b.cols}x${b.rows}`
431
+ const full = !!b.needsFull // consumed only when the frame lands (below), so a superseding repaint still sees it
432
+ await command(b, `refresh-client -C ${want}`)
433
+ if (token !== b.repaintToken) return
434
+ await awaitLayout(b, want)
143
435
  if (token !== b.repaintToken) return
144
- await repaint(b, token)
436
+ const mode = await readPaneMode(b.id)
437
+ if (token !== b.repaintToken) return
438
+ b.paneInMode = mode.inMode
439
+ b.cmdQ.push((lines) => {
440
+ if (token !== b.repaintToken) return
441
+ if (full) b.needsFull = false // cleared only once the full frame actually reaches a viewer
442
+ broadcast(b.id, reconstructFrame(mode, lines, full))
443
+ })
444
+ const cap = capturePaneCommand(b, mode)
445
+ try { b.pty.write(cap + '\n') } catch { b.cmdQ.pop() }
145
446
  }
447
+
146
448
  export function detachViewer(id: string, v: Viewer): void {
147
449
  const s = subscribers.get(id)
148
450
  if (!s) return
@@ -154,29 +456,24 @@ export function detachViewer(id: string, v: Viewer): void {
154
456
  const b = bridges.get(id)
155
457
  if (b && !b.prewarmed) killBridge(id)
156
458
  }
157
- // raw terminal input (keystrokes + mouse) straight into the shared tmux client.
158
- export function writeViewer(id: string, data: Buffer): void {
159
- bridges.get(id)?.pty.write(data.toString('utf8'))
160
- }
161
459
  // a viewer fitted xterm → record the size as the last-known fit (even with no bridge yet, for pre-warm)
162
- // and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame).
163
- export function resizeBridge(id: string, cols: number, rows: number): void {
460
+ // and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame). `full` (a
461
+ // resize right after the viewer reset its xterm) forces the next frame to be a FULL one — mode prelude +
462
+ // history — so a just-reset terminal re-enters the pane's alt-screen / mouse modes, not just its visible screen.
463
+ export function resizeBridge(id: string, cols: number, rows: number, full = false): void {
164
464
  if (!(cols > 0 && rows > 0)) return
165
465
  lastFit.set(id, { cols, rows }); lastFitAny = { cols, rows }
166
466
  const b = bridges.get(id)
167
- if (b) applySize(b, cols, rows)
467
+ if (b) { if (full) b.needsFull = true; applySize(b, cols, rows) }
168
468
  }
169
469
  // resize the client + repaint WITHOUT recording a viewer fit — the primitive both a real resize and the
170
470
  // supervisor's pre-sizing share, so the supervisor can't clobber lastFit/lastFitAny with a stale value.
171
471
  function applySize(b: Bridge, cols: number, rows: number): void {
172
- if (cols !== b.cols || rows !== b.rows) {
173
- b.cols = cols; b.rows = rows
174
- try { b.pty.resize(cols, rows) } catch { /* dead pty; next fit/tick retries */ }
175
- }
176
- void settleAndRepaint(b)
472
+ b.cols = cols; b.rows = rows
473
+ void repaint(b)
177
474
  }
178
475
 
179
- // one reconcile pass: warm a bridge per live session, re-bind a watched session whose pty died, reap a
476
+ // one reconcile pass: warm a bridge per live session, re-bind a watched session whose client died, reap a
180
477
  // dead+unwatched bridge. Re-bind lives here (not pty.onExit) because this pass is alive-gated and
181
478
  // rate-limited, so a flaky session can't storm respawns.
182
479
  async function reconcileOnce(): Promise<void> {
@@ -193,11 +490,11 @@ async function reconcileOnce(): Promise<void> {
193
490
  if (want.cols !== existing.cols || want.rows !== existing.rows) applySize(existing, want.cols, want.rows)
194
491
  continue
195
492
  }
196
- // no bridge for a live session: viewers waiting → re-bind and settleAndRepaint (nothing else re-arms an
197
- // idle pane); else pre-warm an idle detached session, but only if no human client is already attached.
493
+ // no bridge for a live session: viewers waiting → re-bind and repaint (nothing else re-arms an idle
494
+ // pane); else pre-warm an idle detached session, but only if no human client is already attached.
198
495
  if ((subscribers.get(s.id)?.size ?? 0) > 0) {
199
496
  const b = ensureBridge(s.id, true)
200
- if (b) void settleAndRepaint(b)
497
+ if (b) void repaint(b)
201
498
  } else if ((await attachedCount(s.id)) === 0) {
202
499
  ensureBridge(s.id, true)
203
500
  }
@@ -229,7 +526,7 @@ export function superviseBridges(intervalMs = 4000): void {
229
526
  tick()
230
527
  }
231
528
 
232
- // a watched bridge's pty died — recover now instead of waiting a full tick (alive-gated + serialized).
529
+ // a watched bridge's client died — recover now instead of waiting a full tick (alive-gated + serialized).
233
530
  function kickSupervisor(): void {
234
531
  if (supervising) void runReconcile()
235
532
  }