@@ -7,10 +7,13 @@ const execFileAsync = promisify(execFile);
77
88/**
99 * Escape a string for safe use inside an AppleScript double-quoted string.
10- * Backslashes and double quotes must be escaped.
10+ * Backslashes, double quotes, and newlines must be escaped.
1111 */
1212function escapeAppleScript ( text : string ) : string {
13- return text . replace ( / \\ / g, '\\\\' ) . replace ( / " / g, '\\"' ) ;
13+ return text
14+ . replace ( / \\ / g, '\\\\' )
15+ . replace ( / " / g, '\\"' )
16+ . replace ( / \r \n | \r | \n / g, '\\n' ) ;
1417}
1518
1619export class TtyWriter {
@@ -19,8 +22,8 @@ export class TtyWriter {
1922 *
2023 * Dispatches to the correct mechanism based on terminal type:
2124 * - tmux: `tmux send-keys`
22- * - iTerm2: AppleScript `write text`
23- * - Terminal.app: System Events `keystroke` + `key code 36` (Return )
25+ * - iTerm2: Two separate AppleScript `write text` calls (text then newline)
26+ * - Terminal.app: Two separate AppleScript `do script` calls (text then newline )
2427 *
2528 * All AppleScript is executed via `execFile('osascript', ['-e', script])`
2629 * to avoid shell interpolation and command injection.
@@ -56,12 +59,13 @@ export class TtyWriter {
5659 await execFileAsync ( 'tmux' , [ 'send-keys' , '-t' , identifier , 'Enter' ] ) ;
5760 }
5861
59- private static async sendViaITerm2 ( tty : string , message : string ) : Promise < void > {
60- const escaped = escapeAppleScript ( message ) ;
61- // Send text WITHOUT a trailing newline to avoid the newline being swallowed
62- // by bracketed paste mode. Then simulate pressing Return separately so that
63- // Claude Code (and other interactive TUIs) treat it as a real submit action.
64- const script = `
62+ /**
63+ * Build an AppleScript that finds an iTerm2 session by TTY and runs a
64+ * command against it. The `sessionCommand` is inserted inside a
65+ * `tell targetSession` block.
66+ */
67+ private static iterm2SessionScript ( tty : string , sessionCommand : string ) : string {
68+ return `
6569tell application "iTerm"
6670 set targetSession to missing value
6771 repeat with w in windows
@@ -77,66 +81,90 @@ tell application "iTerm"
7781 if targetSession is not missing value then exit repeat
7882 end repeat
7983 if targetSession is missing value then return "not_found"
80- tell targetSession to write text "${ escaped } " newline no
81- end tell
82- tell application "iTerm" to activate
83- delay 0.15
84- tell application "System Events"
85- tell process "iTerm2"
86- key code 36
87- end tell
84+ tell targetSession to ${ sessionCommand }
8885end tell
8986return "ok"` ;
87+ }
88+
89+ private static async sendViaITerm2 ( tty : string , message : string ) : Promise < void > {
90+ const escaped = escapeAppleScript ( message ) ;
91+ // Send text and Enter as two separate write text calls so the newline
92+ // is delivered outside the bracketed paste sequence of the message body.
93+ // iTerm2 appends the newline after the paste-end marker (\e[201~), so
94+ // the inner TUI (Claude Code, Codex) sees it as a real submit action.
95+ const textScript = TtyWriter . iterm2SessionScript ( tty , `write text "${ escaped } " newline no` ) ;
9096
91- const { stdout } = await execFileAsync ( 'osascript' , [ '-e' , script ] ) ;
92- if ( stdout . trim ( ) !== 'ok' ) {
97+ const { stdout : textResult } = await execFileAsync ( 'osascript' , [ '-e' , textScript ] ) ;
98+ if ( textResult . trim ( ) !== 'ok' ) {
9399 throw new Error ( `iTerm2 session not found for TTY ${ tty } ` ) ;
94100 }
101+
102+ // Wait for the paste to complete before sending Enter separately
103+ await new Promise ( ( resolve ) => setTimeout ( resolve , 150 ) ) ;
104+
105+ const enterScript = TtyWriter . iterm2SessionScript ( tty , 'write text "" newline yes' ) ;
106+ const { stdout : enterResult } = await execFileAsync ( 'osascript' , [ '-e' , enterScript ] ) ;
107+ if ( enterResult . trim ( ) !== 'ok' ) {
108+ throw new Error ( `iTerm2 session disappeared before Enter could be sent for TTY ${ tty } ` ) ;
109+ }
95110 }
96111
97112 private static async sendViaTerminalApp ( tty : string , message : string ) : Promise < void > {
98113 const escaped = escapeAppleScript ( message ) ;
99- // Use System Events keystroke to type into the foreground process,
100- // NOT Terminal.app's "do script" which runs a new shell command.
101- // First activate Terminal and select the correct tab, then type via System Events.
102- // Send the text first, then wait for the paste/input to complete before pressing
103- // Return separately — this ensures interactive TUIs (like Claude Code) see the
104- // Return as a real submit action, not part of a bracketed paste.
105- const script = `
114+ // Use Terminal.app's `do script` to send text to the correct tab by TTY.
115+ // We avoid System Events `keystroke` + `key code 36` because it requires
116+ // accessibility permissions and unreliably delivers the Return key.
117+ //
118+ // `do script` with `in` targets a specific tab without opening a new one.
119+ // We send text and Enter as two separate calls so the newline arrives
120+ // outside of bracketed paste mode — same pattern as iTerm2 and tmux.
121+ const textScript = `
106122tell application "Terminal"
107- set targetFound to false
123+ set targetTab to missing value
108124 repeat with w in windows
109125 repeat with i from 1 to count of tabs of w
110126 set t to tab i of w
111127 if tty of t is "${ tty } " then
112- set selected tab of w to t
113- set index of w to 1
114- activate
115- set targetFound to true
128+ set targetTab to t
116129 exit repeat
117130 end if
118131 end repeat
119- if targetFound then exit repeat
132+ if targetTab is not missing value then exit repeat
120133 end repeat
121- if not targetFound then return "not_found"
122- end tell
123- delay 0.1
124- tell application "System Events"
125- tell process "Terminal"
126- keystroke "${ escaped } "
127- end tell
128- end tell
129- delay 0.15
130- tell application "System Events"
131- tell process "Terminal"
132- key code 36
133- end tell
134+ if targetTab is missing value then return "not_found"
135+ do script "${ escaped } " in targetTab
134136end tell
135137return "ok"` ;
136138
137- const { stdout } = await execFileAsync ( 'osascript' , [ '-e' , script ] ) ;
138- if ( stdout . trim ( ) !== 'ok' ) {
139+ const { stdout : textResult } = await execFileAsync ( 'osascript' , [ '-e' , textScript ] ) ;
140+ if ( textResult . trim ( ) !== 'ok' ) {
139141 throw new Error ( `Terminal.app tab not found for TTY ${ tty } ` ) ;
140142 }
143+
144+ // Wait for the text to be delivered before sending Enter
145+ await new Promise ( ( resolve ) => setTimeout ( resolve , 150 ) ) ;
146+
147+ const enterScript = `
148+ tell application "Terminal"
149+ set targetTab to missing value
150+ repeat with w in windows
151+ repeat with i from 1 to count of tabs of w
152+ set t to tab i of w
153+ if tty of t is "${ tty } " then
154+ set targetTab to t
155+ exit repeat
156+ end if
157+ end repeat
158+ if targetTab is not missing value then exit repeat
159+ end repeat
160+ if targetTab is missing value then return "not_found"
161+ do script "" in targetTab
162+ end tell
163+ return "ok"` ;
164+
165+ const { stdout : enterResult } = await execFileAsync ( 'osascript' , [ '-e' , enterScript ] ) ;
166+ if ( enterResult . trim ( ) !== 'ok' ) {
167+ throw new Error ( `Terminal.app tab disappeared before Enter could be sent for TTY ${ tty } ` ) ;
168+ }
141169 }
142170}
0 commit comments