1717 * of the shell that launched it.
1818 */
1919import { spawn } from 'node:child_process'
20+ import { randomBytes } from 'node:crypto'
2021import { mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs'
2122import { tmpdir } from 'node:os'
2223import { dirname , join } from 'node:path'
@@ -41,12 +42,24 @@ const RUN_POLL_INTERVAL_MS = 250
4142/**
4243 * Field separator for `-F` output. Printable on purpose: tmux 3.4 and 3.5 print a control
4344 * character as its octal escape, so a control-character separator arrived as the text `\037` and
44- * no line split. No tmux escapes these characters. No proper prefix of the separator is also a
45- * suffix of it, so it can only be found where it was written or wholly inside a field: a field
46- * holding it changes the line's field count, and that line is dropped rather than misread .
45+ * no line split. No tmux escapes these characters. Fields are untrusted text (a directory or
46+ * window name can hold the separator, or a newline), so records are also framed per call: see
47+ * { @link framedFormat} .
4748 */
4849const FIELD = '<~sim~>'
4950
51+ /**
52+ * A `-F` format for these fields whose every record starts and ends with a marker made fresh for
53+ * this one call. tmux prints a newline inside a field as is, so a directory named
54+ * `a\nuser:0.0<~sim~>…` would otherwise end one record early and forge another. Nobody outside
55+ * this call knows the marker, so no field can forge a framed record, and the halves of a record a
56+ * newline split are each unframed and dropped.
57+ */
58+ function framedFormat ( fields : string [ ] ) : { format : string ; frame : string } {
59+ const frame = `<~${ randomBytes ( 8 ) . toString ( 'hex' ) } ~>`
60+ return { format : `${ frame } ${ fields . join ( FIELD ) } ${ frame } ` , frame }
61+ }
62+
5063export interface TmuxCommandResult {
5164 ok : boolean
5265 stdout : string
@@ -129,18 +142,20 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
129142}
130143
131144/**
132- * Parses `list-clients`/`list-panes` output into records.
145+ * Parses `list-clients`/`list-panes` output into records: only lines framed whole by this call's
146+ * marker, each with exactly the fields asked for.
133147 *
134148 * Split on a dedicated separator rather than whitespace: window names and
135149 * working directories contain spaces, and a path with a space would otherwise
136150 * shift every later field by one.
137151 */
138- export function parseFormatLines ( stdout : string , fields : number ) : string [ ] [ ] {
152+ export function parseFormatLines ( stdout : string , fields : number , frame : string ) : string [ ] [ ] {
139153 return stdout
140154 . split ( '\n' )
141- . map ( ( line ) => line . trimEnd ( ) )
142- . filter ( ( line ) => line . length > 0 )
143- . map ( ( line ) => line . split ( FIELD ) )
155+ . filter (
156+ ( line ) => line . length >= frame . length * 2 && line . startsWith ( frame ) && line . endsWith ( frame )
157+ )
158+ . map ( ( line ) => line . slice ( frame . length , line . length - frame . length ) . split ( FIELD ) )
144159 . filter ( ( parts ) => parts . length === fields )
145160}
146161
@@ -206,11 +221,11 @@ export async function resolveAttachment(
206221 shellPid : number ,
207222 env : NodeJS . ProcessEnv
208223) : Promise < TmuxAttachment | null > {
209- const format = [ '#{client_pid}' , '#{client_tty}' , '#{client_session}' ] . join ( FIELD )
224+ const { format, frame } = framedFormat ( [ '#{client_pid}' , '#{client_tty}' , '#{client_session}' ] )
210225 const listed = await runTmux ( [ 'list-clients' , '-F' , format ] , env )
211226 if ( ! listed . ok ) return null
212227
213- const clients = parseFormatLines ( listed . stdout , 3 )
228+ const clients = parseFormatLines ( listed . stdout , 3 , frame )
214229 if ( clients . length === 0 ) return null
215230
216231 const parents = await listProcessParents ( )
@@ -226,28 +241,27 @@ export async function resolveAttachment(
226241
227242/** The active pane of a session, as a target usable by every other call. */
228243export async function activePane ( session : string , env : NodeJS . ProcessEnv ) : Promise < string | null > {
229- const result = await runTmux (
230- [ 'display-message' , '-p' , '-t' , session , '#{session_name}:#{window_index}.#{pane_index}' ] ,
231- env
232- )
233- const target = result . stdout . trim ( )
234- return result . ok && target ? target : null
244+ const { format, frame } = framedFormat ( [ '#{session_name}:#{window_index}.#{pane_index}' ] )
245+ const result = await runTmux ( [ 'display-message' , '-p' , '-t' , session , format ] , env )
246+ if ( ! result . ok ) return null
247+ const [ target ] = parseFormatLines ( result . stdout , 1 , frame ) [ 0 ] ?? [ ]
248+ return target || null
235249}
236250
237251export async function listPanes (
238252 session : string ,
239253 env : NodeJS . ProcessEnv
240254) : Promise < TerminalPaneState [ ] > {
241- const format = [
255+ const { format, frame } = framedFormat ( [
242256 '#{session_name}:#{window_index}.#{pane_index}' ,
243257 '#{window_name}' ,
244258 '#{pane_current_command}' ,
245259 '#{pane_current_path}' ,
246260 '#{pane_active}' ,
247- ] . join ( FIELD )
261+ ] )
248262 const result = await runTmux ( [ 'list-panes' , '-s' , '-t' , session , '-F' , format ] , env )
249263 if ( ! result . ok ) return [ ]
250- return parseFormatLines ( result . stdout , 5 ) . map (
264+ return parseFormatLines ( result . stdout , 5 , frame ) . map (
251265 ( [ target , windowName , command , cwd , active ] ) : TerminalPaneState => ( {
252266 target,
253267 windowName,
0 commit comments