spexcode 0.4.1 → 0.4.3

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 (106) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/cli.ts +88 -37
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +27 -19
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +54 -43
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +105 -55
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +181 -33
  19. package/spec-cli/src/help.ts +64 -27
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +145 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +25 -121
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +14 -0
  39. package/spec-cli/src/sessions.ts +188 -66
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +12 -7
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  48. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +49 -28
  90. package/spec-eval/src/humanok.ts +1 -1
  91. package/spec-eval/src/matrix.ts +693 -0
  92. package/spec-eval/src/sessioneval.ts +726 -66
  93. package/spec-cli/src/migrate-table.ts +0 -408
  94. package/spec-cli/src/migrate.ts +0 -386
  95. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  96. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  97. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  98. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  99. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  100. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  101. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  102. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  103. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  104. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  105. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  106. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -1,567 +1,367 @@
1
- import * as pty from 'node-pty'
2
- import type { IPty } from 'node-pty'
3
- import { execFile } from 'node:child_process'
1
+ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
4
3
  import { promisify } from 'node:util'
5
- import { listSessions, alive } from './sessions.js'
4
+ import { alive } from './sessions.js'
6
5
 
7
6
  const pexec = promisify(execFile)
8
7
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
9
- // cold fallback size for a session no viewer has ever sized (see lastFit).
10
- const DEFAULT_COLS = 120, DEFAULT_ROWS = 40
8
+ const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url))
11
9
 
12
- // a viewer: anything we can push pane bytes to (a WebSocket, wrapped).
13
- export type Viewer = { send: (data: Buffer) => void }
10
+ export type Viewer = {
11
+ send: (data: Buffer) => void
12
+ commitSize?: (cols: number, rows: number) => void
13
+ }
14
+
15
+ type Subscription = {
16
+ visible: boolean
17
+ lingering: boolean
18
+ cols: number
19
+ rows: number
20
+ bridge?: Bridge
21
+ lingerTimer?: ReturnType<typeof setTimeout>
22
+ restoreTimer?: ReturnType<typeof setTimeout>
23
+ }
14
24
 
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
25
  type Bridge = {
18
- id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean
19
- repaintToken: number
20
- // the size VOTE: whether this client currently asserts window size. Only a bridge some viewer has SIZED
21
- // (visible connect / resize — never a hidden board-load connect) votes; all others carry tmux's
22
- // ignore-size client flag and are size-NEUTRAL, so a foreign backend instance sharing the socket can
23
- // never move a window a human is watching (see setVote).
24
- voting: boolean
25
- // control-mode parser state: an incomplete-line BYTE buffer, the in-flight command block (%begin..%end) with
26
- // its command number, a FIFO of one resolver per command sent (tmux answers in order), and the last
27
- // %layout-change size so a repaint knows the pane already converged and needn't wait for the event.
28
- buf: Buffer
29
- block: Buffer[] | null
30
- blockNum: string
31
- cmdQ: Pending[]
32
- lastLayout?: string
33
- // one waiter for the %layout-change that confirms the wanted size — no timer: `refresh-client -C` is
34
- // PROVEN to always emit exactly one %layout-change carrying the requested WxH (even a same-size no-op), so
35
- // the event is a guaranteed arrival, not a hope that needs a settle-timeout ([[deterministic-convergence]]).
36
- layoutWaiter?: { want: string; resolve: () => void }
37
- // the next repaint must be a FULL frame — the DEC-mode prelude (so xterm mirrors the pane's alt-screen /
38
- // mouse state), not a resize's visible-only re-seed. Set on every (re)attach and re-bind, since a
39
- // (re)connecting viewer's xterm is blank / just reset.
40
- needsFull?: boolean
41
- // Copy-mode freezes the pane's view. While that mode owns the screen, raw %output still describes the
42
- // underlying live grid; repaint owns what viewers see until mode exit snaps them back to the bottom.
43
- paneInMode?: boolean
44
- }
45
- const bridges = new Map<string, Bridge>()
46
- // viewers keyed by session id (not the Bridge), so a subscription outlives any bridge death/respawn.
47
- const subscribers = new Map<string, Set<Viewer>>()
48
-
49
- type PaneMode = {
50
- inMode: boolean
51
- alternate: boolean
52
- mouseStandard: boolean
53
- mouseButton: boolean
54
- mouseAny: boolean
55
- mouseSgr: boolean
56
- scrollPosition: number
57
- paneHeight: number
58
- cursorX: number
59
- cursorY: number
60
- }
61
- // cursor_x/cursor_y are the underlying program's cursor (0-based), NOT the copy-mode cursor — so a frame can
62
- // restore it and any following live %output that moves the cursor RELATIVELY resumes from the right origin.
63
- 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}'
64
- const flag = (v?: string) => v === '1'
65
- function num(v?: string): number {
66
- const n = Number(v)
67
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
68
- }
69
-
70
- // last size each viewer fitted (per session + a global fallback), so pre-warm spawns at the wanted size.
71
- const lastFit = new Map<string, { cols: number; rows: number }>()
72
- let lastFitAny: { cols: number; rows: number } | null = null
73
- function prewarmSize(id: string): { cols: number; rows: number } {
74
- return lastFit.get(id) ?? lastFitAny ?? { cols: DEFAULT_COLS, rows: DEFAULT_ROWS }
75
- }
76
-
77
- // push pane bytes to every viewer of a session (the set survives a bridge swap).
78
- function broadcast(id: string, buf: Buffer): void {
79
- for (const v of subscribers.get(id) ?? []) { try { v.send(buf) } catch { /* drop a wedged viewer */ } }
80
- }
81
-
82
- const isOct = (c: number) => c >= 0x30 && c <= 0x37 // ASCII '0'..'7'
83
-
84
- // %output escaping (MEASURED against tmux 3.4, not assumed): tmux octal-escapes ONLY the C0 control bytes and
85
- // backslash (`\015` `\012` `\033` `\134`, all < 0x80) — every high byte is passed THROUGH RAW. We parse the
86
- // whole control stream as BYTES (never a string), because node-pty's own utf8 decode splits a multi-byte
87
- // character straddling two OS reads into a U+FFFD before we could see it — the corruption this closes. So work
88
- // on the raw `%output` byte segment: a `\NNN` (backslash 0x5C + three octal-digit bytes) becomes that one
89
- // byte, every other byte passes through UNTOUCHED. The result is already the pane's exact UTF-8 byte stream —
90
- // broadcast it verbatim, with NO string round-trip to shatter a wide character (`我` / `┌` / `😀`).
91
- function unescapeOutput(data: Buffer): Buffer {
92
- const out = Buffer.allocUnsafe(data.length)
93
- let j = 0
94
- for (let i = 0; i < data.length; i++) {
95
- const c = data[i]
96
- if (c === 0x5C && i + 3 < data.length && isOct(data[i + 1]) && isOct(data[i + 2]) && isOct(data[i + 3])) {
97
- out[j++] = ((data[i + 1] - 0x30) << 6) | ((data[i + 2] - 0x30) << 3) | (data[i + 3] - 0x30)
98
- i += 3
99
- } else {
100
- out[j++] = c
101
- }
102
- }
103
- return out.subarray(0, j)
26
+ id: string
27
+ viewer: Viewer
28
+ proc: ChildProcessWithoutNullStreams
29
+ cols: number
30
+ rows: number
31
+ ptyPid?: number
32
+ stderr: string
33
+ clientTty?: string
34
+ delivery: 'stream' | 'initial' | 'quarantine'
35
+ refreshBuf: Buffer
36
+ refreshPending: boolean
37
+ refreshRunning: boolean
38
+ refreshOffset?: number
39
+ deliveryTimer?: ReturnType<typeof setTimeout>
40
+ }
41
+
42
+ const subscribers = new Map<string, Map<Viewer, Subscription>>()
43
+ const BSU = Buffer.from('\x1b[?2026h')
44
+ const ESU = Buffer.from('\x1b[?2026l')
45
+ const GEOMETRY_STABILIZATION_MS = 400
46
+ // A hidden but still-alive browser keeps its own client briefly so a quick return is continuous. A dead
47
+ // socket bypasses this window and detaches immediately.
48
+ const LINGER_MS = Number(process.env.SPEXCODE_TERM_LINGER_MS) > 0 ? Number(process.env.SPEXCODE_TERM_LINGER_MS) : 30_000
49
+
50
+ function subscriptionMap(id: string): Map<Viewer, Subscription> {
51
+ let map = subscribers.get(id)
52
+ if (!map) subscribers.set(id, map = new Map())
53
+ return map
54
+ }
55
+
56
+ function currentSubscription(id: string, viewer: Viewer): Subscription | undefined {
57
+ return subscribers.get(id)?.get(viewer)
104
58
  }
105
59
 
106
- // join capture-pane reply lines (each already raw UTF-8 bytes) with CRLF at the byte level — no string round-trip.
107
- const CRLF = Buffer.from('\r\n')
108
- function joinLines(lines: Buffer[]): Buffer {
109
- if (lines.length === 0) return Buffer.alloc(0)
110
- const parts: Buffer[] = []
111
- for (let i = 0; i < lines.length; i++) { if (i) parts.push(CRLF); parts.push(lines[i]) }
112
- return Buffer.concat(parts)
60
+ function isCurrent(bridge: Bridge): boolean {
61
+ return currentSubscription(bridge.id, bridge.viewer)?.bridge === bridge
113
62
  }
114
63
 
115
- async function tmuxRaw(args: string[]): Promise<void> {
116
- try { await pexec('tmux', ['-L', TMUX_SOCK, ...args]) } catch { /* best-effort */ }
64
+ function deliver(bridge: Bridge, data: Buffer): void {
65
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
66
+ if (!subscription || (!subscription.visible && !subscription.lingering)) return
67
+ try { bridge.viewer.send(data) } catch { /* socket close or heartbeat expiry owns removal */ }
117
68
  }
118
- async function tmuxOut(args: string[]): Promise<string> {
69
+
70
+ function commitSize(bridge: Bridge): void {
71
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
72
+ if (!subscription?.visible) return
73
+ try { bridge.viewer.commitSize?.(bridge.cols, bridge.rows) } catch { /* socket close owns removal */ }
74
+ }
75
+
76
+ async function tmux(args: string[]): Promise<string> {
119
77
  try {
120
78
  const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args])
121
79
  return stdout.trim()
122
80
  } catch { return '' }
123
81
  }
124
- // how many clients are attached — pre-warm skips a session a human is already in (avoids a size-fight). Read
125
- // once at reconcile time (before our own control client attaches), so it counts only foreign/human clients.
126
- async function attachedCount(id: string): Promise<number> {
127
- try {
128
- const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'display-message', '-p', '-t', id, '-F', '#{session_attached}'])
129
- return Number(stdout.trim()) || 0
130
- } catch { return 0 }
131
- }
132
-
133
- // mouse on + deep history. set -g is the server default: mouse is inherited live, history-limit applies
134
- // only to panes created afterwards.
135
- let optsEnsured = false
136
- async function ensureTmuxOpts(): Promise<void> {
137
- if (optsEnsured) return
138
- optsEnsured = true
139
- await tmuxRaw(['set', '-g', 'mouse', 'on'])
140
- await tmuxRaw(['set', '-g', 'history-limit', '50000'])
141
- }
142
-
143
- // --- control-mode protocol ---------------------------------------------------
144
- // The one client per session is a tmux control-mode connection (`tmux -CC attach-session`). tmux speaks a
145
- // line protocol on this pty: %output events push pane bytes, %begin/%end frame each command's reply, and
146
- // %layout-change announces the converged size. So resize is deterministic (refresh-client -C, told done by
147
- // %layout-change) and bytes arrive as events — no pty resize + geometry poll, no per-repaint tmux exec.
148
-
149
- // send one control-mode command; resolve with its %begin..%end reply lines. tmux answers in order, so a FIFO
150
- // of resolvers matches each block to its command.
151
- function command(b: Bridge, cmd: string): Promise<Buffer[]> {
152
- return new Promise((resolve) => {
153
- b.cmdQ.push(resolve)
154
- try { b.pty.write(cmd + '\n') } catch { b.cmdQ = b.cmdQ.filter((r) => r !== resolve); resolve([]) }
155
- })
156
- }
157
-
158
- // parse the control stream line by line AT THE BYTE LEVEL (an incomplete tail is held in b.buf until its 0x0A
159
- // arrives). Splitting on the newline byte — never on a decoded string — is what keeps a multi-byte UTF-8
160
- // character intact when it straddles two OS reads: node-pty hands us raw Buffers (encoding:null), so no read
161
- // boundary can shatter a wide char into a U+FFFD before we reassemble the line.
162
- function feed(b: Bridge, chunk: Buffer): void {
163
- b.buf = b.buf.length ? Buffer.concat([b.buf, chunk]) : chunk
164
- let i: number
165
- while ((i = b.buf.indexOf(0x0A)) >= 0) {
166
- let line = b.buf.subarray(0, i)
167
- if (line.length && line[line.length - 1] === 0x0D) line = line.subarray(0, line.length - 1)
168
- b.buf = b.buf.subarray(i + 1)
169
- onLine(b, line)
170
- }
171
- }
172
82
 
173
- // strip the control-mode DCS wrapper (`\x1bP<n>p` on enter, `\x1b\\` on exit) at the byte level — its bytes are
174
- // all ASCII and it only ever brackets the first/last notification, never %output data or capture content.
175
- function stripDcs(line: Buffer): Buffer {
176
- if (line.length >= 2 && line[0] === 0x1b && line[1] === 0x50) { // ESC P … p
177
- const p = line.indexOf(0x70, 2)
178
- if (p >= 0) line = line.subarray(p + 1)
179
- }
180
- if (line.length >= 2 && line[line.length - 2] === 0x1b && line[line.length - 1] === 0x5c) line = line.subarray(0, line.length - 2) // ESC backslash
181
- return line
182
- }
183
-
184
- function onLine(b: Bridge, lineBuf: Buffer): void {
185
- // Inside a command reply everything is verbatim content until %end/%error. Capture-pane body lines are RAW
186
- // pane bytes (real escapes + UTF-8), so they must NOT go through stripDcs: a captured row can legitimately
187
- // END in \x1b\\ — the ST that terminates an OSC 8 hyperlink (`\x1b]8;;\x1b\\`, e.g. a Claude Code URL) — and
188
- // stripDcs's trailing-\x1b\\ strip would eat it, leaving the hyperlink unterminated so xterm never closes it
189
- // and paints the rest of the screen underlined. The DCS-exit wrapper only ever ends a control line at stream
190
- // exit, never a reply body, so the strip belongs to the protocol path below, not here. Classify %end/%error
191
- // on the raw line — but only one whose command number matches this %begin's closes it, so a pane row that
192
- // merely starts with "%end" can't false-close.
193
- if (b.block) {
194
- const h = lineBuf.toString('latin1')
195
- const m = h.match(/^%(?:end|error) \S+ (\d+)/)
196
- if (m && m[1] === b.blockNum) {
197
- const lines = b.block; b.block = null
198
- const resolve = b.cmdQ.shift(); if (resolve) resolve(lines)
199
- } else {
200
- b.block.push(lineBuf) // raw bytes verbatim — escapes (incl. OSC 8 ST) + UTF-8, not to be string-mangled
201
- }
83
+ function onHelperOutput(bridge: Bridge, chunk: Buffer): void {
84
+ if (!isCurrent(bridge)) return
85
+ if (bridge.delivery === 'stream') {
86
+ deliver(bridge, chunk)
202
87
  return
203
88
  }
204
- const line = stripDcs(lineBuf)
205
- // The control PROTOCOL (%begin/%end/%error/%output/%layout-change prefixes, command numbers, layout tokens)
206
- // is pure ASCII, so decode as latin1 for classification only — a total 1-byte↔1-char map, so a byte index
207
- // in `head` is the same byte index in `line`; the DATA is taken from the raw Buffer, never from `head`.
208
- const head = line.toString('latin1')
209
- if (head.startsWith('%output ')) {
210
- if (b.paneInMode) return
211
- const sp = head.indexOf(' ', 8) // skip "%output %<pane> " to the raw (escaped) data
212
- if (sp > 0) broadcast(b.id, unescapeOutput(line.subarray(sp + 1)))
213
- return
214
- }
215
- const beg = head.match(/^%begin \S+ (\d+)/)
216
- if (beg) { b.block = []; b.blockNum = beg[1]; return }
217
- if (head.startsWith('%layout-change ')) {
218
- const m = head.match(/,(\d+x\d+),/) // layout token = checksum,WIDTHxHEIGHT,x,y,… — the window size
219
- onLayout(b, m ? m[1] : undefined)
220
- return
221
- }
222
- // Mode enter/exit emits no pane bytes of its own. Route through repaint only: paneInMode is written solely
223
- // by repaint under its token, so racing mode flips can't leave a stale read as the last freeze-state write.
224
- // ALWAYS repaint — even with a full attach frame pending (needsFull): skipping here left the flip's freeze
225
- // state to a repaint that could be superseded and never land, latching %output frozen. The token machinery
226
- // already dedups (the newest repaint wins); a spare repaint is cheap, a dropped mode flip mutes the bridge.
227
- if (head.startsWith('%pane-mode-changed')) {
228
- void repaint(b)
229
- return
89
+ bridge.refreshBuf = bridge.refreshBuf.length ? Buffer.concat([bridge.refreshBuf, chunk]) : chunk
90
+ releaseInitialRefresh(bridge)
91
+ }
92
+
93
+ function sendControl(bridge: Bridge, message: object): void {
94
+ try { bridge.proc.stdin.write(`${JSON.stringify(message)}\n`) } catch { /* exit recovery owns retry */ }
95
+ }
96
+
97
+ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
98
+ bridge.stderr += chunk.toString('utf8')
99
+ let newline: number
100
+ while ((newline = bridge.stderr.indexOf('\n')) >= 0) {
101
+ const line = bridge.stderr.slice(0, newline)
102
+ bridge.stderr = bridge.stderr.slice(newline + 1)
103
+ const ready = line.match(/^READY (\d+)$/)
104
+ if (ready) {
105
+ bridge.ptyPid = Number(ready[1])
106
+ if (bridge.delivery === 'initial') {
107
+ armDeliveryBoundary(bridge)
108
+ queueRefresh(bridge)
109
+ }
110
+ continue
111
+ }
112
+ const resized = line.match(/^RESIZED (\d+) (\d+)$/)
113
+ if (resized) {
114
+ if (bridge.delivery === 'initial'
115
+ && Number(resized[1]) === bridge.cols
116
+ && Number(resized[2]) === bridge.rows) {
117
+ queueRefresh(bridge)
118
+ }
119
+ } else if (line) {
120
+ console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
121
+ }
230
122
  }
231
- // %exit / %client-detached / window close → the client is gone; pty.onExit drives the re-bind.
232
- }
233
-
234
- function onLayout(b: Bridge, size?: string): void {
235
- if (!size) return
236
- const changed = b.lastLayout !== undefined && b.lastLayout !== size
237
- b.lastLayout = size
238
- const w = b.layoutWaiter
239
- if (w) { b.layoutWaiter = undefined; w.resolve() }
240
- // an UNSOLICITED geometry change (another control client resized the shared window) with no repaint in
241
- // flight: re-seed the viewers at the new size, or they keep painting deltas onto stale geometry (garble).
242
- else if (changed) void repaint(b)
243
- }
244
-
245
- // resolve when the NEXT %layout-change arrives — the pane has re-wrapped at whatever size tmux CONVERGED to.
246
- // DETERMINISTIC, NO TIMER: `refresh-client -C WxH` is measured to ALWAYS emit exactly one %layout-change
247
- // (even a same-size no-op), so the event is a guaranteed arrival. Crucially the waiter accepts ANY announced
248
- // size, never an exact match on the size we asked for: every viewer/peek/prewarm is its own control client
249
- // sharing ONE window under `window-size latest`, so the announcement routinely carries a DIFFERENT client's
250
- // size — an exact-size wait deadlocks the seed frame forever (needsFull latches, %output stays frozen: the
251
- // mute black terminal). Immediate when already converged at `want`. A newer repaint supersedes an older
252
- // waiter by resolving it — the superseded repaint then falls out on its stale token.
253
- function awaitLayout(b: Bridge, want: string): Promise<void> {
254
- if (b.lastLayout === want) return Promise.resolve()
255
- return new Promise((resolve) => {
256
- b.layoutWaiter?.resolve()
257
- b.layoutWaiter = { want, resolve }
258
- })
259
123
  }
260
124
 
261
- // spawn the shared control-mode client for a session (idempotent). Returns null if node-pty can't spawn.
262
- function ensureBridge(id: string, prewarm = false): Bridge | null {
263
- let b = bridges.get(id)
264
- if (b) { if (prewarm) b.prewarmed = true; return b }
265
- // spawn at the last-known viewer size so a pre-warmed bridge already matches the dashboard's pane.
266
- const { cols, rows } = prewarmSize(id)
267
- let p: IPty
125
+ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
126
+ if (subscription.bridge) return { bridge: subscription.bridge, created: false }
127
+ let proc: ChildProcessWithoutNullStreams | undefined
268
128
  try {
269
- // -CC = control mode (event stream); -u + a UTF-8 LANG force UTF-8 output even when the host locale is
270
- // empty (a LaunchAgent gives LANG="" → tmux would substitute `_` for every wide char). encoding:null makes
271
- // onData deliver raw Buffers so a wide char split across two reads can't be pre-decoded into a U+FFFD.
272
- p = pty.spawn('tmux', ['-u', '-CC', '-L', TMUX_SOCK, 'attach-session', '-t', id], {
273
- name: 'xterm-256color', cols, rows, encoding: null,
274
- env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' } as Record<string, string>,
129
+ proc = spawn(process.execPath, [HELPER, id, String(cols), String(rows)], {
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ env: process.env,
275
132
  })
276
- } catch { return null }
277
- b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, voting: false, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
278
- bridges.set(id, b)
279
- const bx = b
280
- p.onData((d) => feed(bx, d as unknown as Buffer)) // encoding:null → d is a Buffer (typings say string)
281
- // every client starts size-NEUTRAL: flag it before any refresh-client -C can enter the FIFO (a bare
282
- // attach asserts nothing — measured: only -C moves a window — so there is no pre-flag race). Sent as a
283
- // stream command, not an attach-time `-f`, so a pre-3.2 tmux degrades to a harmless in-stream %error
284
- // (old size-fight behaviour) instead of a client that cannot attach at all.
285
- void command(b, 'refresh-client -f ignore-size')
286
- // attach-session exited (session died or we detached): drop the bridge, unblock any awaiting command AND any
287
- // %layout-change waiter (no timer backs it now, so a bridge that dies mid-convergence MUST resolve its
288
- // waiter or the awaiting repaint hangs), and if viewers remain kick a reconcile to re-bind fast.
289
- p.onExit(() => {
290
- if (bx.layoutWaiter) { const w = bx.layoutWaiter; bx.layoutWaiter = undefined; w.resolve() }
291
- const q = bx.cmdQ; bx.cmdQ = []; for (const r of q) r([])
292
- if (bridges.get(id) === bx) bridges.delete(id)
293
- if ((subscribers.get(id)?.size ?? 0) > 0) kickSupervisor()
294
- })
295
- return b
296
- }
297
-
298
- // flip this client's size vote — the arbitration that makes ANY number of backend instances share one tmux
299
- // socket without size-fights. tmux's `ignore-size` client flag means "yield while any unflagged client is
300
- // attached" (server-wide, and void when ALL clients are flagged — then everyone counts again, which is what
301
- // keeps the single-backend warm hold working). So: a bridge votes (unflags) from the moment a viewer SIZES
302
- // it, and goes neutral again when its last viewer leaves. A suppressed refresh-client -C still receives its
303
- // one %layout-change (measured — announcing the window's real size), so the deterministic resize wait and
304
- // the accept-any-announced-size rule need no change on either side of the flag.
305
- function setVote(b: Bridge, on: boolean): void {
306
- if (b.voting === on) return
307
- b.voting = on
308
- void command(b, `refresh-client -f ${on ? '!' : ''}ignore-size`)
309
- }
310
-
311
- function killBridge(id: string): void {
312
- const b = bridges.get(id)
313
- if (!b) return
314
- if (b.layoutWaiter) { const w = b.layoutWaiter; b.layoutWaiter = undefined; w.resolve() }
315
- bridges.delete(id)
316
- try { b.pty.kill() } catch { /* already gone */ }
317
- }
318
-
319
- // a browser viewer connects: subscribe it to the (warm or fresh) bridge, then paint one coherent frame at
320
- // the converged size (see repaint), never a guessed-size splice. Two connect shapes:
321
- // - VISIBLE (re)connect — the client could measure its pane and carried its real size on the URL (the
322
- // size-first handshake), so size the bridge to it FIRST and draw that very frame at the correct size.
323
- // - HIDDEN connect — a warm pane is still 0×0, so the client carries no size. DON'T paint a guessed
324
- // prewarm frame now: it'd be undersized and, landing in a still-hidden buffer, would only have to be
325
- // covered the instant the pane becomes visible (the old two-stage scramble). Defer the one first-frame
326
- // paint — purely — to the client's first resize, which fires the moment the pane becomes visible and the
327
- // client measures its real size, so the first frame is drawn at the true visible size. NO timer fallback:
328
- // a pane that never resizes is a pane no one ever looks at (a viewer sends its real size the instant it
329
- // becomes visible), so it needs no frame — the first paint is a pure resize event, zero timer.
330
- export function attachViewer(id: string, v: Viewer, initialSize?: { cols: number; rows: number }): boolean {
331
- let s = subscribers.get(id)
332
- if (!s) subscribers.set(id, s = new Set())
333
- s.add(v)
334
- const b = ensureBridge(id)
335
- if (!b) return false // spawn failed → caller closes the socket → detachViewer prunes this subscriber
336
- b.needsFull = true // a (re)connecting viewer's xterm is blank / just reset → its first frame must be FULL
337
- if (initialSize && initialSize.cols > 0 && initialSize.rows > 0) {
338
- setVote(b, true) // a sized viewer → this client asserts window size
339
- applySize(b, initialSize.cols, initialSize.rows) // resize-then-repaint at the client's true size
133
+ } catch {
134
+ try { proc?.kill() } catch { /* spawn did not complete */ }
135
+ return { bridge: null, created: false }
340
136
  }
341
- // else HIDDEN connect (0×0, no size): paint nothing now — the first frame is driven purely by the client's
342
- // first resize (fires when the pane becomes visible at its true size). No timer, never a guessed frame.
343
- return true
137
+ const bridge: Bridge = {
138
+ id, viewer, proc, cols, rows, stderr: '', delivery: 'initial',
139
+ refreshBuf: Buffer.alloc(0), refreshPending: false, refreshRunning: false,
140
+ }
141
+ subscription.bridge = bridge
142
+ proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
143
+ proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
144
+ let reaped = false
145
+ const gone = () => {
146
+ if (reaped) return
147
+ reaped = true
148
+ const current = currentSubscription(id, viewer)
149
+ if (current?.bridge !== bridge) return
150
+ current.bridge = undefined
151
+ clearDelivery(bridge)
152
+ try { bridge.proc.kill() } catch { /* already gone */ }
153
+ scheduleRestore(id, viewer, current)
154
+ }
155
+ proc.on('exit', gone)
156
+ proc.on('error', gone)
157
+ return { bridge, created: true }
344
158
  }
345
159
 
346
- async function readPaneMode(id: string): Promise<PaneMode> {
347
- const raw = await tmuxOut(['display-message', '-p', '-t', id, '-F', PANE_MODE_FORMAT])
348
- const [inMode, alternate, mouseStandard, mouseButton, mouseAny, mouseSgr, scrollPosition, paneHeight, cursorX, cursorY] = raw.split(',')
349
- return {
350
- inMode: flag(inMode),
351
- alternate: flag(alternate),
352
- mouseStandard: flag(mouseStandard),
353
- mouseButton: flag(mouseButton),
354
- mouseAny: flag(mouseAny),
355
- mouseSgr: flag(mouseSgr),
356
- scrollPosition: num(scrollPosition),
357
- paneHeight: num(paneHeight),
358
- cursorX: num(cursorX),
359
- cursorY: num(cursorY),
360
- }
160
+ function cancelLinger(subscription: Subscription): void {
161
+ if (subscription.lingerTimer) clearTimeout(subscription.lingerTimer)
162
+ subscription.lingerTimer = undefined
163
+ subscription.lingering = false
361
164
  }
362
165
 
363
- function canInjectSgrWheel(mode: PaneMode): boolean {
364
- return mode.mouseSgr && (mode.mouseStandard || mode.mouseButton || mode.mouseAny)
365
- }
366
-
367
- // a control-mode bare attach REPLAYS NOTHING (unlike a raw attach, where tmux resends the pane's whole terminal
368
- // state — alt-screen switch, mouse-tracking modes, …). So a FULL (re)attach frame must RECONSTRUCT that state:
369
- // emit the matching DEC private-mode prelude from the same pane-mode abstraction used for wheel routing. The
370
- // browser xterm mirrors the pane on the ALTERNATE screen and in the app's mouse-tracking mode; live mode
371
- // changes flow through %output naturally.
372
- function paneModePrelude(mode: PaneMode): string {
373
- let s = ''
374
- if (mode.alternate) s += '\x1b[?1049h' // alternate screen (full-screen TUI)
375
- if (mode.mouseStandard) s += '\x1b[?1000h'
376
- if (mode.mouseButton) s += '\x1b[?1002h'
377
- if (mode.mouseAny) s += '\x1b[?1003h'
378
- if (mode.mouseSgr) s += '\x1b[?1006h' // SGR-encoded mouse reports
379
- return s
380
- }
381
-
382
- function wheelMouseReport(up: boolean, col: number, row: number, ticks: number): string {
383
- const button = up ? 64 : 65
384
- return `\x1b[<${button};${col};${row}M`.repeat(ticks)
385
- }
386
-
387
- function capturePaneCommand(b: Bridge, mode: PaneMode): string {
388
- if (!mode.inMode) return `capture-pane -e -p -t ${b.id}`
389
- // tmux capture-pane's default "visible pane" ignores the copy-mode viewport. In copy-mode,
390
- // scroll_position is the offset above the bottom visible screen; capture that history window explicitly.
391
- const rows = Math.max(1, mode.paneHeight || b.rows)
392
- const scroll = mode.scrollPosition
393
- const start = scroll === 0 ? '0' : `-${scroll}`
394
- const end = rows - 1 - scroll
395
- return `capture-pane -e -p -t ${b.id} -S ${start} -E ${end}`
396
- }
397
-
398
- // A wheel always enters at the tmux adapter boundary. If the pane is in copy-mode, or is a normal pane with
399
- // tmux history, tmux scrolls its own copy-mode view and we repaint that view. If the pane is a mouse-owning
400
- // TUI with SGR mouse reports enabled, inject that wheel report. No harness-specific branch exists.
401
- export function forwardWheel(id: string, up: boolean, col: number, row: number, ticks: number): void {
402
- const b = bridges.get(id)
403
- if (!b) return
404
- const c = Math.max(1, Math.floor(col) || 1), r = Math.max(1, Math.floor(row) || 1)
405
- const n = Math.max(1, Math.min(10, Math.floor(ticks) || 1))
406
- void (async () => {
407
- const mode = await readPaneMode(id)
408
- if (mode.inMode || !canInjectSgrWheel(mode)) {
409
- if (up && !mode.inMode) await tmuxRaw(['copy-mode', '-e', '-t', id])
410
- if (up || mode.inMode) {
411
- await tmuxRaw(['send-keys', '-t', id, '-X', '-N', String(n * 5), up ? 'scroll-up' : 'scroll-down'])
412
- await repaint(b)
413
- }
166
+ function cancelRestore(subscription: Subscription): void {
167
+ if (subscription.restoreTimer) clearTimeout(subscription.restoreTimer)
168
+ subscription.restoreTimer = undefined
169
+ }
170
+
171
+ function killBridge(subscription: Subscription): void {
172
+ cancelLinger(subscription)
173
+ cancelRestore(subscription)
174
+ const bridge = subscription.bridge
175
+ if (!bridge) return
176
+ subscription.bridge = undefined
177
+ clearDelivery(bridge)
178
+ try { bridge.proc.stdin.end() } catch { /* already gone */ }
179
+ const kill = setTimeout(() => { try { bridge.proc.kill() } catch { /* already gone */ } }, 250)
180
+ kill.unref()
181
+ }
182
+
183
+ function beginDelivery(bridge: Bridge): void {
184
+ if (bridge.delivery === 'stream') bridge.refreshBuf = Buffer.alloc(0)
185
+ bridge.delivery = 'initial'
186
+ bridge.refreshOffset = undefined
187
+ armDeliveryBoundary(bridge)
188
+ }
189
+
190
+ function armDeliveryBoundary(bridge: Bridge): void {
191
+ if (bridge.deliveryTimer) clearTimeout(bridge.deliveryTimer)
192
+ bridge.deliveryTimer = setTimeout(() => finishDelivery(bridge), GEOMETRY_STABILIZATION_MS)
193
+ bridge.deliveryTimer.unref()
194
+ }
195
+
196
+ function clearDelivery(bridge: Bridge): void {
197
+ bridge.delivery = 'stream'
198
+ if (bridge.deliveryTimer) clearTimeout(bridge.deliveryTimer)
199
+ bridge.deliveryTimer = undefined
200
+ bridge.refreshBuf = Buffer.alloc(0)
201
+ bridge.refreshPending = false
202
+ bridge.refreshOffset = undefined
203
+ }
204
+
205
+ function completeTransactionEnd(buffer: Buffer, offset: number): number | undefined {
206
+ const begin = buffer.indexOf(BSU, offset)
207
+ const end = begin >= 0 ? buffer.indexOf(ESU, begin + BSU.length) : -1
208
+ return end >= 0 ? end + ESU.length : undefined
209
+ }
210
+
211
+ function releaseInitialRefresh(bridge: Bridge): void {
212
+ if (bridge.delivery !== 'initial' || bridge.refreshOffset === undefined) return
213
+ const end = completeTransactionEnd(bridge.refreshBuf, bridge.refreshOffset)
214
+ if (end === undefined) return
215
+ const batch = bridge.refreshBuf.subarray(0, end)
216
+ bridge.refreshBuf = bridge.refreshBuf.subarray(end)
217
+ bridge.delivery = 'quarantine'
218
+ commitSize(bridge)
219
+ if (batch.length) deliver(bridge, batch)
220
+ }
221
+
222
+ function finishDelivery(bridge: Bridge): void {
223
+ if (bridge.delivery === 'stream' || !isCurrent(bridge)) return
224
+ const failOpen = bridge.delivery === 'initial'
225
+ const batch = bridge.refreshBuf
226
+ clearDelivery(bridge)
227
+ if (!failOpen && !batch.length) return
228
+ // The control commit and following binary frame are one browser render transaction, including fail-open.
229
+ commitSize(bridge)
230
+ deliver(bridge, batch)
231
+ }
232
+
233
+ async function refreshBridge(bridge: Bridge): Promise<boolean> {
234
+ if (!isCurrent(bridge) || !bridge.ptyPid) return false
235
+ const tty = await clientTty(bridge)
236
+ if (!tty) return false
237
+ await tmux(['refresh-client', '-t', tty])
238
+ return true
239
+ }
240
+
241
+ function queueRefresh(bridge: Bridge): void {
242
+ if (bridge.delivery !== 'initial') return
243
+ bridge.refreshPending = true
244
+ if (bridge.refreshRunning) return
245
+ bridge.refreshPending = false
246
+ bridge.refreshRunning = true
247
+ void refreshBridge(bridge).then((refreshed) => {
248
+ bridge.refreshRunning = false
249
+ if (!isCurrent(bridge) || bridge.delivery !== 'initial') return
250
+ if (bridge.refreshPending) {
251
+ queueRefresh(bridge)
414
252
  return
415
253
  }
416
- await tmuxRaw(['send-keys', '-t', id, '-l', '--', wheelMouseReport(up, c, r, n)])
417
- })()
418
- }
419
-
420
- // every (re)attach and resize routes here. Deterministic, event-driven, zero polling: set the size with
421
- // refresh-client -C, wait to be TOLD it converged by the guaranteed %layout-change (NO timer), then seed one
422
- // coherent frame from a capture-pane at that size. A per-bridge token supersedes a stale run at every await.
423
- // The capture frame broadcasts synchronously at its block-end, so any %output that follows in the stream
424
- // lands AFTER the frame and is never overwritten by it — the frame is the attach seed, %output the live tail.
425
- //
426
- // A frame is a COMPLETE reconstruction of the pane's terminal state at the converged size — so the live
427
- // %output that follows renders coherently on top of it. A `capture-pane` seed carries only the GRID (cells +
428
- // their attributes + hyperlinks, byte-verbatim); the frame wraps the rest of the state around it, in stream
429
- // order:
430
- // modes — alt-screen + mouse tracking, reconstructed from the pane's live flags (FULL frames only — a
431
- // plain resize keeps the modes the browser already holds; control mode never re-emits them).
432
- // pen — reset SGR + close any open OSC 8 hyperlink, so no attribute/hyperlink state leaks across the
433
- // clear from the prior frame (xterm renders an unclosed hyperlink as a whole-screen underline).
434
- // clear — blank the viewport (`\x1b[H\x1b[2J`, never the scrollback `\x1b[3J`).
435
- // grid — the captured rows, joined at the BYTE level so a wide char / an OSC 8 ST is never string-mangled.
436
- // cursor — put the cursor where the pane REALLY has it, so a relative live redraw resumes from the right
437
- // origin. An inline TUI (Ink) erases its previous frame by moving up from where it left the cursor
438
- // — which sits on the input line, above trailing hint rows; a frame that left the cursor at the
439
- // body's end would make the next redraw erase the wrong rows and double the bottom UI.
440
- // Each live-view rendering bug was ONE missing piece of this reconstruction (a mangled grid byte, a leaked
441
- // hyperlink, a dropped cursor); building the whole state in one place is what keeps them all fixed.
442
- function reconstructFrame(mode: PaneMode, lines: Buffer[], full: boolean): Buffer {
443
- const modes = full ? paneModePrelude(mode) : ''
444
- const pen = '\x1b[m\x1b]8;;\x1b\\'
445
- const clear = '\x1b[H\x1b[2J'
446
- const cursor = `\x1b[${mode.cursorY + 1};${mode.cursorX + 1}H`
447
- return Buffer.concat([Buffer.from(modes + pen + clear, 'utf8'), joinLines(lines), Buffer.from(cursor, 'utf8')])
448
- }
449
-
450
- // every (re)attach and resize routes here to broadcast one reconstructed frame (see reconstructFrame). The
451
- // frame reflects the pane at its command boundary, so live %output that follows lands after it, never under it.
452
- async function repaint(b: Bridge): Promise<void> {
453
- const token = ++b.repaintToken
454
- const want = `${b.cols}x${b.rows}`
455
- const full = !!b.needsFull // consumed only when the frame lands (below), so a superseding repaint still sees it
456
- await command(b, `refresh-client -C ${want}`)
457
- if (token !== b.repaintToken) return
458
- await awaitLayout(b, want)
459
- if (token !== b.repaintToken) return
460
- const mode = await readPaneMode(b.id)
461
- if (token !== b.repaintToken) return
462
- b.paneInMode = mode.inMode
463
- b.cmdQ.push((lines) => {
464
- if (token !== b.repaintToken) return
465
- if (full) b.needsFull = false // cleared only once the full frame actually reaches a viewer
466
- broadcast(b.id, reconstructFrame(mode, lines, full))
254
+ if (!refreshed) return
255
+ bridge.refreshOffset = bridge.refreshBuf.length
256
+ releaseInitialRefresh(bridge)
467
257
  })
468
- const cap = capturePaneCommand(b, mode)
469
- try { b.pty.write(cap + '\n') } catch { b.cmdQ.pop() }
470
- }
471
-
472
- export function detachViewer(id: string, v: Viewer): void {
473
- const s = subscribers.get(id)
474
- if (!s) return
475
- s.delete(v)
476
- if (s.size > 0) return
477
- // last viewer gone drop the registry entry, then release the client unless it's kept warm. An empty
478
- // subscriber set is the single authority for "no one watching" (used here and in the supervisor reap).
479
- subscribers.delete(id)
480
- const b = bridges.get(id)
481
- if (b && !b.prewarmed) killBridge(id)
482
- else if (b) setVote(b, false) // kept warm → back to size-neutral: an unwatched client must not out-vote a watched one
483
- }
484
- // a viewer fitted xterm → record the size as the last-known fit (even with no bridge yet, for pre-warm)
485
- // and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame). `full` (a
486
- // resize right after the viewer reset its xterm) forces the next frame to be a FULL one — mode prelude +
487
- // history — so a just-reset terminal re-enters the pane's alt-screen / mouse modes, not just its visible screen.
488
- export function resizeBridge(id: string, cols: number, rows: number, full = false): void {
489
- if (!(cols > 0 && rows > 0)) return
490
- lastFit.set(id, { cols, rows }); lastFitAny = { cols, rows }
491
- const b = bridges.get(id)
492
- if (b) { if (full) b.needsFull = true; setVote(b, true); applySize(b, cols, rows) }
493
- }
494
- // resize the client + repaint WITHOUT recording a viewer fit — the primitive both a real resize and the
495
- // supervisor's pre-sizing share, so the supervisor can't clobber lastFit/lastFitAny with a stale value.
496
- function applySize(b: Bridge, cols: number, rows: number): void {
497
- b.cols = cols; b.rows = rows
498
- void repaint(b)
499
- }
500
-
501
- // one reconcile pass: warm a bridge per live session, re-bind a watched session whose client died, reap a
502
- // dead+unwatched bridge. Re-bind lives here (not pty.onExit) because this pass is alive-gated and
503
- // rate-limited, so a flaky session can't storm respawns.
504
- async function reconcileOnce(): Promise<void> {
505
- const live = new Set<string>()
506
- for (const s of await listSessions()) {
507
- if (!(await alive(s.id))) continue
508
- live.add(s.id)
509
- // already ours → keep warm and resize a stale warm bridge to the last-known viewer size off-screen,
510
- // so a first open finds the pane already at its size. TWO staleness guards, by vote state: a NEUTRAL
511
- // client's hold is suppressed while any sized viewer votes on the socket, so its own client size would
512
- // read "converged" after one suppressed attempt and wedge the hold forever — compare against the
513
- // WINDOW's real size (lastLayout) instead, retrying each tick (one suppressed no-op command) until the
514
- // first tick after the socket goes quiet: deferred, not lost. A VOTING client keeps the client-size
515
- // guard: its -C lands, and when two voting instances watch ONE session the window is genuinely
516
- // contended — latest assert wins and STOPS (a window-truth guard would re-assert every tick and turn
517
- // that contention into a visible size ping-pong war).
518
- const existing = bridges.get(s.id)
519
- if (existing) {
520
- existing.prewarmed = true
521
- const want = prewarmSize(s.id)
522
- const stale = existing.voting
523
- ? want.cols !== existing.cols || want.rows !== existing.rows
524
- : existing.lastLayout !== `${want.cols}x${want.rows}`
525
- if (stale) applySize(existing, want.cols, want.rows)
526
- continue
527
- }
528
- // no bridge for a live session: viewers waiting → re-bind and repaint (nothing else re-arms an idle
529
- // pane); else pre-warm an idle detached session, but only if no human client is already attached.
530
- if ((subscribers.get(s.id)?.size ?? 0) > 0) {
531
- const b = ensureBridge(s.id, true)
532
- if (b) void repaint(b)
533
- } else if ((await attachedCount(s.id)) === 0) {
534
- ensureBridge(s.id, true)
258
+ }
259
+
260
+ async function clientTty(bridge: Bridge): Promise<string | undefined> {
261
+ if (bridge.clientTty) return bridge.clientTty
262
+ if (!bridge.ptyPid) return undefined
263
+ const clients = await tmux(['list-clients', '-t', bridge.id, '-F', '#{client_pid} #{client_tty}'])
264
+ for (const line of clients.split('\n')) {
265
+ const space = line.indexOf(' ')
266
+ if (space > 0 && Number(line.slice(0, space)) === bridge.ptyPid) {
267
+ return (bridge.clientTty = line.slice(space + 1).trim())
535
268
  }
536
269
  }
537
- for (const [id, b] of bridges) {
538
- if (live.has(id)) continue
539
- if ((subscribers.get(id)?.size ?? 0) === 0) killBridge(id) // dead + unwatched → release
540
- else b.prewarmed = false // dead but still watched → serve until they leave
270
+ return undefined
271
+ }
272
+
273
+ function resize(bridge: Bridge, cols: number, rows: number): void {
274
+ if (bridge.cols === cols && bridge.rows === rows) return
275
+ beginDelivery(bridge)
276
+ bridge.cols = cols
277
+ bridge.rows = rows
278
+ sendControl(bridge, { t: 'resize', cols, rows })
279
+ }
280
+
281
+ export function attachViewer(id: string, viewer: Viewer): void {
282
+ const map = subscriptionMap(id)
283
+ const previous = map.get(viewer)
284
+ if (previous) killBridge(previous)
285
+ map.set(viewer, { visible: false, lingering: false, cols: 0, rows: 0 })
286
+ }
287
+
288
+ export function hideViewer(id: string, viewer: Viewer): void {
289
+ const subscription = currentSubscription(id, viewer)
290
+ if (!subscription) return
291
+ subscription.visible = false
292
+ if (!subscription.bridge) return
293
+ subscription.lingering = true
294
+ if (subscription.lingerTimer) return
295
+ subscription.lingerTimer = setTimeout(() => {
296
+ subscription.lingerTimer = undefined
297
+ if (!subscription.visible && currentSubscription(id, viewer) === subscription) killBridge(subscription)
298
+ }, LINGER_MS)
299
+ subscription.lingerTimer.unref()
300
+ }
301
+
302
+ export function detachViewer(id: string, viewer: Viewer): void {
303
+ const map = subscribers.get(id)
304
+ const subscription = map?.get(viewer)
305
+ if (!map || !subscription) return
306
+ // A closed/dead socket is not a hidden live tab: remove its native client now, never after linger.
307
+ killBridge(subscription)
308
+ map.delete(viewer)
309
+ if (map.size === 0) subscribers.delete(id)
310
+ }
311
+
312
+ export function resizeBridge(id: string, viewer: Viewer, colsValue: number, rowsValue: number): void {
313
+ const cols = Math.floor(colsValue), rows = Math.floor(rowsValue)
314
+ if (!(cols > 0 && rows > 0)) return
315
+ const subscription = currentSubscription(id, viewer)
316
+ if (!subscription) return
317
+ const seamless = subscription.lingering && !!subscription.bridge
318
+ && subscription.bridge.cols === cols && subscription.bridge.rows === rows
319
+ cancelLinger(subscription)
320
+ subscription.visible = true
321
+ subscription.cols = cols
322
+ subscription.rows = rows
323
+ const { bridge, created } = ensureBridge(id, viewer, subscription, cols, rows)
324
+ if (!bridge || created || seamless) return
325
+ if (bridge.cols === cols && bridge.rows === rows) {
326
+ beginDelivery(bridge)
327
+ queueRefresh(bridge)
328
+ } else {
329
+ resize(bridge, cols, rows)
541
330
  }
542
331
  }
543
332
 
544
- // serialize reconcile passes (one running, one queued), so a burst of onExit kicks collapses to one rerun.
545
- let reconciling = false
546
- let reconcilePending = false
547
- async function runReconcile(): Promise<void> {
548
- if (reconciling) { reconcilePending = true; return }
549
- reconciling = true
550
- try { await reconcileOnce() } catch { /* transient git/tmux hiccup; the periodic tick retries */ }
551
- reconciling = false
552
- if (reconcilePending) { reconcilePending = false; void runReconcile() }
333
+ const MAX_INPUT_BYTES = 64 * 1024
334
+
335
+ export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
336
+ const subscription = currentSubscription(id, viewer)
337
+ if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
338
+ sendControl(subscription.bridge, { t: 'input', data })
339
+ return true
340
+ }
341
+
342
+ async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
343
+ subscription.restoreTimer = undefined
344
+ if (currentSubscription(id, viewer) !== subscription || subscription.bridge || !subscription.visible || !(await alive(id))) return
345
+ if (!(subscription.cols > 0 && subscription.rows > 0)) return
346
+ ensureBridge(id, viewer, subscription, subscription.cols, subscription.rows)
347
+ }
348
+
349
+ function scheduleRestore(id: string, viewer: Viewer, subscription: Subscription): void {
350
+ if (currentSubscription(id, viewer) !== subscription || !subscription.visible || subscription.bridge || subscription.restoreTimer) return
351
+ subscription.restoreTimer = setTimeout(() => void restoreBridge(id, viewer, subscription), 750)
352
+ subscription.restoreTimer.unref()
553
353
  }
554
354
 
555
355
  let supervising = false
556
356
  export function superviseBridges(intervalMs = 4000): void {
557
357
  if (supervising) return
558
358
  supervising = true
559
- void ensureTmuxOpts()
560
- const tick = () => { void runReconcile(); setTimeout(tick, intervalMs) }
359
+ const tick = () => {
360
+ for (const [id, viewers] of subscribers) {
361
+ for (const [viewer, subscription] of viewers) scheduleRestore(id, viewer, subscription)
362
+ }
363
+ const timer = setTimeout(tick, intervalMs)
364
+ timer.unref()
365
+ }
561
366
  tick()
562
367
  }
563
-
564
- // a watched bridge's client died — recover now instead of waiting a full tick (alive-gated + serialized).
565
- function kickSupervisor(): void {
566
- if (supervising) void runReconcile()
567
- }