spexcode 0.1.6 → 0.2.1
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.
- package/README.md +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
58
|
-
// gives LANG="" → tmux
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
p.onData((
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
197
|
-
//
|
|
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
|
|
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
|
|
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
|
}
|