Skip to content

Commit 59ab878

Browse files
committed
fix(agent-manager): fix sending in iTerm
1 parent aece9ac commit 59ab878

2 files changed

Lines changed: 135 additions & 57 deletions

File tree

packages/agent-manager/src/__tests__/terminal/TtyWriter.test.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,19 @@ describe('TtyWriter', () => {
7878

7979
await TtyWriter.send(location, 'hello');
8080

81+
// First call: send text without newline
8182
expect(mockedExecFile).toHaveBeenCalledWith(
8283
'osascript',
8384
['-e', expect.stringContaining('write text "hello" newline no')],
8485
expect.any(Function),
8586
);
86-
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
87-
const script = scriptArg[1];
88-
expect(script).toContain('key code 36');
87+
// Second call: send Enter via separate write text with newline
88+
expect(mockedExecFile).toHaveBeenCalledWith(
89+
'osascript',
90+
['-e', expect.stringContaining('write text "" newline yes')],
91+
expect.any(Function),
92+
);
93+
expect(mockedExecFile).toHaveBeenCalledTimes(2);
8994
});
9095

9196
it('escapes special characters in message', async () => {
@@ -100,12 +105,37 @@ describe('TtyWriter', () => {
100105
);
101106
});
102107

108+
it('escapes newlines in message', async () => {
109+
mockExecFileSuccess('ok');
110+
111+
await TtyWriter.send(location, 'line1\nline2');
112+
113+
expect(mockedExecFile).toHaveBeenCalledWith(
114+
'osascript',
115+
['-e', expect.stringContaining('write text "line1\\nline2" newline no')],
116+
expect.any(Function),
117+
);
118+
});
119+
103120
it('throws when session not found', async () => {
104121
mockExecFileSuccess('not_found');
105122

106123
await expect(TtyWriter.send(location, 'test'))
107124
.rejects.toThrow('iTerm2 session not found');
108125
});
126+
127+
it('throws when session disappears before Enter', async () => {
128+
// First call succeeds (text sent), second returns not_found
129+
let callCount = 0;
130+
mockedExecFile.mockImplementation((...args: unknown[]) => {
131+
const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;
132+
callCount++;
133+
cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');
134+
});
135+
136+
await expect(TtyWriter.send(location, 'test'))
137+
.rejects.toThrow('iTerm2 session disappeared before Enter');
138+
});
109139
});
110140

111141
describe('Terminal.app', () => {
@@ -115,16 +145,24 @@ describe('TtyWriter', () => {
115145
tty: '/dev/ttys030',
116146
};
117147

118-
it('sends message via System Events keystroke (not do script)', async () => {
148+
it('sends message via do script (not System Events)', async () => {
119149
mockExecFileSuccess('ok');
120150

121151
await TtyWriter.send(location, 'hello');
122152

123-
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
124-
const script = scriptArg[1];
125-
expect(script).toContain('keystroke "hello"');
126-
expect(script).toContain('key code 36');
127-
expect(script).not.toContain('do script');
153+
// First call: send text via do script
154+
const firstCallArgs = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
155+
const textScript = firstCallArgs[1];
156+
expect(textScript).toContain('do script "hello" in targetTab');
157+
expect(textScript).not.toContain('keystroke');
158+
expect(textScript).not.toContain('key code 36');
159+
160+
// Second call: send Enter via separate do script
161+
const secondCallArgs = (mockedExecFile.mock.calls[1] as unknown[])[1] as string[];
162+
const enterScript = secondCallArgs[1];
163+
expect(enterScript).toContain('do script "" in targetTab');
164+
165+
expect(mockedExecFile).toHaveBeenCalledTimes(2);
128166
});
129167

130168
it('throws when tab not found', async () => {
@@ -133,6 +171,18 @@ describe('TtyWriter', () => {
133171
await expect(TtyWriter.send(location, 'test'))
134172
.rejects.toThrow('Terminal.app tab not found');
135173
});
174+
175+
it('throws when tab disappears before Enter', async () => {
176+
let callCount = 0;
177+
mockedExecFile.mockImplementation((...args: unknown[]) => {
178+
const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;
179+
callCount++;
180+
cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');
181+
});
182+
183+
await expect(TtyWriter.send(location, 'test'))
184+
.rejects.toThrow('Terminal.app tab disappeared before Enter');
185+
});
136186
});
137187

138188
describe('unsupported terminal', () => {

packages/agent-manager/src/terminal/TtyWriter.ts

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
1212
function 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

1619
export 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 `
6569
tell 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}
8885
end tell
8986
return "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 = `
106122
tell 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
134136
end tell
135137
return "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

Comments
 (0)