spexcode 0.4.0 → 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.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
|
@@ -1,567 +1,367 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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 {
|
|
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
|
-
|
|
10
|
-
const DEFAULT_COLS = 120, DEFAULT_ROWS = 40
|
|
8
|
+
const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url))
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
let
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 {
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
}
|