@ai-devkit/agent-manager 0.22.0 → 0.22.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.
@@ -15,6 +15,11 @@ function mockExecFileSuccess(stdout = '') {
15
15
  cb(null, {
16
16
  stdout
17
17
  }, '');
18
+ return {
19
+ stdin: {
20
+ end: vi.fn()
21
+ }
22
+ };
18
23
  });
19
24
  }
20
25
  function mockExecFileError(message) {
@@ -33,23 +38,36 @@ describe('TtyWriter', ()=>{
33
38
  identifier: 'main:0.1',
34
39
  tty: '/dev/ttys030'
35
40
  };
36
- it('sends message and Enter as separate tmux send-keys calls', async ()=>{
41
+ it('pastes message in bracketed paste mode and sends Enter separately', async ()=>{
37
42
  mockExecFileSuccess();
38
- await TtyWriter.send(location, 'continue');
39
- expect(mockedExecFile).toHaveBeenCalledWith('tmux', [
40
- 'send-keys',
43
+ const message = 'line 1\nline 2\n';
44
+ await TtyWriter.send(location, message);
45
+ const loadArgs = mockedExecFile.mock.calls[0]?.[1];
46
+ const bufferName = loadArgs[2];
47
+ expect(bufferName).toMatch(/^ai-devkit-send-/);
48
+ expect(mockedExecFile).toHaveBeenNthCalledWith(1, 'tmux', [
49
+ 'load-buffer',
50
+ '-b',
51
+ bufferName,
52
+ '-'
53
+ ], expect.any(Function));
54
+ expect(mockedExecFile.mock.results[0]?.value.stdin.end).toHaveBeenCalledWith(message);
55
+ expect(mockedExecFile).toHaveBeenNthCalledWith(2, 'tmux', [
56
+ 'paste-buffer',
41
57
  '-t',
42
58
  'main:0.1',
43
- '-l',
44
- 'continue'
59
+ '-b',
60
+ bufferName,
61
+ '-p',
62
+ '-d'
45
63
  ], expect.any(Function));
46
- expect(mockedExecFile).toHaveBeenCalledWith('tmux', [
64
+ expect(mockedExecFile).toHaveBeenNthCalledWith(3, 'tmux', [
47
65
  'send-keys',
48
66
  '-t',
49
67
  'main:0.1',
50
68
  'Enter'
51
69
  ], expect.any(Function));
52
- expect(mockedExecFile).toHaveBeenCalledTimes(2);
70
+ expect(mockedExecFile).toHaveBeenCalledTimes(3);
53
71
  });
54
72
  it('throws on tmux failure', async ()=>{
55
73
  mockExecFileError('tmux not running');
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/__tests__/terminal/TtyWriter.test.ts"],"sourcesContent":["import type { Mock } from 'vitest';\n\nimport { TtyWriter } from '../../terminal/TtyWriter.js';\nimport { TerminalType } from '../../terminal/TerminalFocusManager.js';\nimport type { TerminalLocation } from '../../terminal/TerminalFocusManager.js';\nimport { execFile } from 'child_process';\n\nvi.mock('child_process', async () => {\n const actual = await vi.importActual<typeof import('child_process')>('child_process');\n return {\n ...actual,\n execFile: vi.fn(),\n };\n});\n\nconst mockedExecFile = execFile as unknown as Mock;\n\nfunction mockExecFileSuccess(stdout = '') {\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n cb(null, { stdout }, '');\n });\n}\n\nfunction mockExecFileError(message: string) {\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: null, stderr: string) => void;\n cb(new Error(message), null, '');\n });\n}\n\ndescribe('TtyWriter', () => {\n beforeEach(() => {\n vi.clearAllMocks();\n });\n\n describe('tmux', () => {\n const location: TerminalLocation = {\n type: TerminalType.TMUX,\n identifier: 'main:0.1',\n tty: '/dev/ttys030',\n };\n\n it('sends message and Enter as separate tmux send-keys calls', async () => {\n mockExecFileSuccess();\n\n await TtyWriter.send(location, 'continue');\n\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'tmux',\n ['send-keys', '-t', 'main:0.1', '-l', 'continue'],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'tmux',\n ['send-keys', '-t', 'main:0.1', 'Enter'],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenCalledTimes(2);\n });\n\n it('throws on tmux failure', async () => {\n mockExecFileError('tmux not running');\n\n await expect(TtyWriter.send(location, 'hello'))\n .rejects.toThrow('tmux not running');\n });\n });\n\n describe('iTerm2', () => {\n const location: TerminalLocation = {\n type: TerminalType.ITERM2,\n identifier: '/dev/ttys030',\n tty: '/dev/ttys030',\n };\n\n it('sends message via osascript with execFile (no shell)', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'hello');\n\n // First call: send text without newline\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"hello\" newline no')],\n expect.any(Function),\n );\n // Second call: send Enter via separate write text with newline\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"\" newline yes')],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenCalledTimes(2);\n });\n\n it('escapes special characters in message', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'say \"hi\" \\\\ there');\n\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"say \\\\\"hi\\\\\" \\\\\\\\ there\" newline no')],\n expect.any(Function),\n );\n });\n\n it('escapes newlines in message', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'line1\\nline2');\n\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"line1\\\\nline2\" newline no')],\n expect.any(Function),\n );\n });\n\n it('throws when session not found', async () => {\n mockExecFileSuccess('not_found');\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('iTerm2 session not found');\n });\n\n it('throws when session disappears before Enter', async () => {\n // First call succeeds (text sent), second returns not_found\n let callCount = 0;\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n callCount++;\n cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');\n });\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('iTerm2 session disappeared before Enter');\n });\n });\n\n describe('Terminal.app', () => {\n const location: TerminalLocation = {\n type: TerminalType.TERMINAL_APP,\n identifier: '/dev/ttys030',\n tty: '/dev/ttys030',\n };\n\n it('sends message via do script (not System Events)', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'hello');\n\n // First call: send text via do script\n const firstCallArgs = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];\n const textScript = firstCallArgs[1];\n expect(textScript).toContain('do script \"hello\" in targetTab');\n expect(textScript).not.toContain('keystroke');\n expect(textScript).not.toContain('key code 36');\n\n // Second call: send Enter via separate do script\n const secondCallArgs = (mockedExecFile.mock.calls[1] as unknown[])[1] as string[];\n const enterScript = secondCallArgs[1];\n expect(enterScript).toContain('do script \"\" in targetTab');\n\n expect(mockedExecFile).toHaveBeenCalledTimes(2);\n });\n\n it('throws when tab not found', async () => {\n mockExecFileSuccess('not_found');\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Terminal.app tab not found');\n });\n\n it('throws when tab disappears before Enter', async () => {\n let callCount = 0;\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n callCount++;\n cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');\n });\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Terminal.app tab disappeared before Enter');\n });\n });\n\n describe('unsupported terminal', () => {\n it('throws for unknown terminal type', async () => {\n const location: TerminalLocation = {\n type: TerminalType.UNKNOWN,\n identifier: '',\n tty: '/dev/ttys030',\n };\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Cannot send input: unsupported terminal type');\n });\n });\n});\n"],"names":["TtyWriter","TerminalType","execFile","vi","mock","actual","importActual","fn","mockedExecFile","mockExecFileSuccess","stdout","mockImplementation","args","cb","length","mockExecFileError","message","Error","describe","beforeEach","clearAllMocks","location","type","TMUX","identifier","tty","it","send","expect","toHaveBeenCalledWith","any","Function","toHaveBeenCalledTimes","rejects","toThrow","ITERM2","stringContaining","callCount","TERMINAL_APP","firstCallArgs","calls","textScript","toContain","not","secondCallArgs","enterScript","UNKNOWN"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/__tests__/terminal/TtyWriter.test.ts"],"sourcesContent":["import type { Mock } from 'vitest';\n\nimport { TtyWriter } from '../../terminal/TtyWriter.js';\nimport { TerminalType } from '../../terminal/TerminalFocusManager.js';\nimport type { TerminalLocation } from '../../terminal/TerminalFocusManager.js';\nimport { execFile } from 'child_process';\n\nvi.mock('child_process', async () => {\n const actual = await vi.importActual<typeof import('child_process')>('child_process');\n return {\n ...actual,\n execFile: vi.fn(),\n };\n});\n\nconst mockedExecFile = execFile as unknown as Mock;\n\nfunction mockExecFileSuccess(stdout = '') {\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n cb(null, { stdout }, '');\n return { stdin: { end: vi.fn() } };\n });\n}\n\nfunction mockExecFileError(message: string) {\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: null, stderr: string) => void;\n cb(new Error(message), null, '');\n });\n}\n\ndescribe('TtyWriter', () => {\n beforeEach(() => {\n vi.clearAllMocks();\n });\n\n describe('tmux', () => {\n const location: TerminalLocation = {\n type: TerminalType.TMUX,\n identifier: 'main:0.1',\n tty: '/dev/ttys030',\n };\n\n it('pastes message in bracketed paste mode and sends Enter separately', async () => {\n mockExecFileSuccess();\n const message = 'line 1\\nline 2\\n';\n\n await TtyWriter.send(location, message);\n\n const loadArgs = mockedExecFile.mock.calls[0]?.[1] as string[];\n const bufferName = loadArgs[2];\n expect(bufferName).toMatch(/^ai-devkit-send-/);\n expect(mockedExecFile).toHaveBeenNthCalledWith(\n 1,\n 'tmux',\n ['load-buffer', '-b', bufferName, '-'],\n expect.any(Function),\n );\n expect(mockedExecFile.mock.results[0]?.value.stdin.end)\n .toHaveBeenCalledWith(message);\n expect(mockedExecFile).toHaveBeenNthCalledWith(\n 2,\n 'tmux',\n ['paste-buffer', '-t', 'main:0.1', '-b', bufferName, '-p', '-d'],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenNthCalledWith(\n 3,\n 'tmux',\n ['send-keys', '-t', 'main:0.1', 'Enter'],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenCalledTimes(3);\n });\n\n it('throws on tmux failure', async () => {\n mockExecFileError('tmux not running');\n\n await expect(TtyWriter.send(location, 'hello'))\n .rejects.toThrow('tmux not running');\n });\n });\n\n describe('iTerm2', () => {\n const location: TerminalLocation = {\n type: TerminalType.ITERM2,\n identifier: '/dev/ttys030',\n tty: '/dev/ttys030',\n };\n\n it('sends message via osascript with execFile (no shell)', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'hello');\n\n // First call: send text without newline\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"hello\" newline no')],\n expect.any(Function),\n );\n // Second call: send Enter via separate write text with newline\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"\" newline yes')],\n expect.any(Function),\n );\n expect(mockedExecFile).toHaveBeenCalledTimes(2);\n });\n\n it('escapes special characters in message', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'say \"hi\" \\\\ there');\n\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"say \\\\\"hi\\\\\" \\\\\\\\ there\" newline no')],\n expect.any(Function),\n );\n });\n\n it('escapes newlines in message', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'line1\\nline2');\n\n expect(mockedExecFile).toHaveBeenCalledWith(\n 'osascript',\n ['-e', expect.stringContaining('write text \"line1\\\\nline2\" newline no')],\n expect.any(Function),\n );\n });\n\n it('throws when session not found', async () => {\n mockExecFileSuccess('not_found');\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('iTerm2 session not found');\n });\n\n it('throws when session disappears before Enter', async () => {\n // First call succeeds (text sent), second returns not_found\n let callCount = 0;\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n callCount++;\n cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');\n });\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('iTerm2 session disappeared before Enter');\n });\n });\n\n describe('Terminal.app', () => {\n const location: TerminalLocation = {\n type: TerminalType.TERMINAL_APP,\n identifier: '/dev/ttys030',\n tty: '/dev/ttys030',\n };\n\n it('sends message via do script (not System Events)', async () => {\n mockExecFileSuccess('ok');\n\n await TtyWriter.send(location, 'hello');\n\n // First call: send text via do script\n const firstCallArgs = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];\n const textScript = firstCallArgs[1];\n expect(textScript).toContain('do script \"hello\" in targetTab');\n expect(textScript).not.toContain('keystroke');\n expect(textScript).not.toContain('key code 36');\n\n // Second call: send Enter via separate do script\n const secondCallArgs = (mockedExecFile.mock.calls[1] as unknown[])[1] as string[];\n const enterScript = secondCallArgs[1];\n expect(enterScript).toContain('do script \"\" in targetTab');\n\n expect(mockedExecFile).toHaveBeenCalledTimes(2);\n });\n\n it('throws when tab not found', async () => {\n mockExecFileSuccess('not_found');\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Terminal.app tab not found');\n });\n\n it('throws when tab disappears before Enter', async () => {\n let callCount = 0;\n mockedExecFile.mockImplementation((...args: unknown[]) => {\n const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;\n callCount++;\n cb(null, { stdout: callCount === 1 ? 'ok' : 'not_found' }, '');\n });\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Terminal.app tab disappeared before Enter');\n });\n });\n\n describe('unsupported terminal', () => {\n it('throws for unknown terminal type', async () => {\n const location: TerminalLocation = {\n type: TerminalType.UNKNOWN,\n identifier: '',\n tty: '/dev/ttys030',\n };\n\n await expect(TtyWriter.send(location, 'test'))\n .rejects.toThrow('Cannot send input: unsupported terminal type');\n });\n });\n});\n"],"names":["TtyWriter","TerminalType","execFile","vi","mock","actual","importActual","fn","mockedExecFile","mockExecFileSuccess","stdout","mockImplementation","args","cb","length","stdin","end","mockExecFileError","message","Error","describe","beforeEach","clearAllMocks","location","type","TMUX","identifier","tty","it","send","loadArgs","calls","bufferName","expect","toMatch","toHaveBeenNthCalledWith","any","Function","results","value","toHaveBeenCalledWith","toHaveBeenCalledTimes","rejects","toThrow","ITERM2","stringContaining","callCount","TERMINAL_APP","firstCallArgs","textScript","toContain","not","secondCallArgs","enterScript","UNKNOWN"],"mappings":"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"}
@@ -17,6 +17,7 @@ export declare class TtyWriter {
17
17
  */
18
18
  static send(location: TerminalLocation, message: string): Promise<void>;
19
19
  private static sendViaTmux;
20
+ private static execFileWithInput;
20
21
  /**
21
22
  * Build an AppleScript that finds an iTerm2 session by TTY and runs a
22
23
  * command against it. The `sessionCommand` is inserted inside a
@@ -1 +1 @@
1
- {"version":3,"file":"TtyWriter.d.ts","sourceRoot":"","sources":["../../src/terminal/TtyWriter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAMlE,qBAAa,SAAS;IAClB;;;;;;;;;;;;;;OAcG;WACU,IAAI,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;mBAgBxD,WAAW;IAWhC;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,mBAAmB;mBAsBb,aAAa;mBAuBb,kBAAkB;CA0D1C"}
1
+ {"version":3,"file":"TtyWriter.d.ts","sourceRoot":"","sources":["../../src/terminal/TtyWriter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAMlE,qBAAa,SAAS;IAClB;;;;;;;;;;;;;;OAcG;WACU,IAAI,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;mBAgBxD,WAAW;mBAWX,iBAAiB;IAiBtC;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,mBAAmB;mBAsBb,aAAa;mBAuBb,kBAAkB;CA0D1C"}
@@ -31,17 +31,24 @@ export class TtyWriter {
31
31
  }
32
32
  }
33
33
  static async sendViaTmux(identifier, message) {
34
- // Send text and Enter as two separate calls so that Enter arrives
35
- // outside of bracketed paste mode. When the inner application (e.g.
36
- // Claude Code) has bracketed paste enabled, tmux wraps the send-keys
37
- // payload in paste brackets — if Enter is included, it gets swallowed
38
- // as part of the paste instead of acting as a submit action.
34
+ // Paste the message body using tmux bracketed paste, then send Enter as
35
+ // a separate key so the inner TUI treats it as submission rather than
36
+ // pasted content.
37
+ const bufferName = `ai-devkit-send-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
38
+ await TtyWriter.execFileWithInput('tmux', [
39
+ 'load-buffer',
40
+ '-b',
41
+ bufferName,
42
+ '-'
43
+ ], message);
39
44
  await execFileAsync('tmux', [
40
- 'send-keys',
45
+ 'paste-buffer',
41
46
  '-t',
42
47
  identifier,
43
- '-l',
44
- message
48
+ '-b',
49
+ bufferName,
50
+ '-p',
51
+ '-d'
45
52
  ]);
46
53
  await new Promise((resolve)=>setTimeout(resolve, 150));
47
54
  await execFileAsync('tmux', [
@@ -51,6 +58,22 @@ export class TtyWriter {
51
58
  'Enter'
52
59
  ]);
53
60
  }
61
+ static async execFileWithInput(command, args, input) {
62
+ await new Promise((resolve, reject)=>{
63
+ const child = execFile(command, args, (error)=>{
64
+ if (error) {
65
+ reject(error);
66
+ return;
67
+ }
68
+ resolve();
69
+ });
70
+ if (!child.stdin) {
71
+ reject(new Error(`Cannot write stdin to ${command}`));
72
+ return;
73
+ }
74
+ child.stdin.end(input);
75
+ });
76
+ }
54
77
  /**
55
78
  * Build an AppleScript that finds an iTerm2 session by TTY and runs a
56
79
  * command against it. The `sessionCommand` is inserted inside a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/terminal/TtyWriter.ts"],"sourcesContent":["import { execFile } from 'child_process';\nimport { promisify } from 'util';\nimport type { TerminalLocation } from './TerminalFocusManager.js';\nimport { TerminalType } from './TerminalFocusManager.js';\nimport { escapeAppleScript } from '../utils/applescript.js';\n\nconst execFileAsync = promisify(execFile);\n\nexport class TtyWriter {\n /**\n * Send a message as keyboard input to a terminal session.\n *\n * Dispatches to the correct mechanism based on terminal type:\n * - tmux: `tmux send-keys`\n * - iTerm2: Two separate AppleScript `write text` calls (text then newline)\n * - Terminal.app: Two separate AppleScript `do script` calls (text then newline)\n *\n * All AppleScript is executed via `execFile('osascript', ['-e', script])`\n * to avoid shell interpolation and command injection.\n *\n * @param location Terminal location from TerminalFocusManager.findTerminal()\n * @param message Text to send\n * @throws Error if terminal type is unsupported or send fails\n */\n static async send(location: TerminalLocation, message: string): Promise<void> {\n switch (location.type) {\n case TerminalType.TMUX:\n return TtyWriter.sendViaTmux(location.identifier, message);\n case TerminalType.ITERM2:\n return TtyWriter.sendViaITerm2(location.tty, message);\n case TerminalType.TERMINAL_APP:\n return TtyWriter.sendViaTerminalApp(location.tty, message);\n default:\n throw new Error(\n `Cannot send input: unsupported terminal type \"${location.type}\". ` +\n 'Supported: tmux, iTerm2, Terminal.app.'\n );\n }\n }\n\n private static async sendViaTmux(identifier: string, message: string): Promise<void> {\n // Send text and Enter as two separate calls so that Enter arrives\n // outside of bracketed paste mode. When the inner application (e.g.\n // Claude Code) has bracketed paste enabled, tmux wraps the send-keys\n // payload in paste brackets — if Enter is included, it gets swallowed\n // as part of the paste instead of acting as a submit action.\n await execFileAsync('tmux', ['send-keys', '-t', identifier, '-l', message]);\n await new Promise((resolve) => setTimeout(resolve, 150));\n await execFileAsync('tmux', ['send-keys', '-t', identifier, 'Enter']);\n }\n\n /**\n * Build an AppleScript that finds an iTerm2 session by TTY and runs a\n * command against it. The `sessionCommand` is inserted inside a\n * `tell targetSession` block.\n */\n private static iterm2SessionScript(tty: string, sessionCommand: string): string {\n return `\ntell application \"iTerm\"\n set targetSession to missing value\n repeat with w in windows\n repeat with t in tabs of w\n repeat with s in sessions of t\n if tty of s is \"${tty}\" then\n set targetSession to s\n exit repeat\n end if\n end repeat\n if targetSession is not missing value then exit repeat\n end repeat\n if targetSession is not missing value then exit repeat\n end repeat\n if targetSession is missing value then return \"not_found\"\n tell targetSession to ${sessionCommand}\nend tell\nreturn \"ok\"`;\n }\n\n private static async sendViaITerm2(tty: string, message: string): Promise<void> {\n const escaped = escapeAppleScript(message);\n // Send text and Enter as two separate write text calls so the newline\n // is delivered outside the bracketed paste sequence of the message body.\n // iTerm2 appends the newline after the paste-end marker (\\e[201~), so\n // the inner TUI (Claude Code, Codex) sees it as a real submit action.\n const textScript = TtyWriter.iterm2SessionScript(tty, `write text \"${escaped}\" newline no`);\n\n const { stdout: textResult } = await execFileAsync('osascript', ['-e', textScript]);\n if (textResult.trim() !== 'ok') {\n throw new Error(`iTerm2 session not found for TTY ${tty}`);\n }\n\n // Wait for the paste to complete before sending Enter separately\n await new Promise((resolve) => setTimeout(resolve, 150));\n\n const enterScript = TtyWriter.iterm2SessionScript(tty, 'write text \"\" newline yes');\n const { stdout: enterResult } = await execFileAsync('osascript', ['-e', enterScript]);\n if (enterResult.trim() !== 'ok') {\n throw new Error(`iTerm2 session disappeared before Enter could be sent for TTY ${tty}`);\n }\n }\n\n private static async sendViaTerminalApp(tty: string, message: string): Promise<void> {\n const escaped = escapeAppleScript(message);\n // Use Terminal.app's `do script` to send text to the correct tab by TTY.\n // We avoid System Events `keystroke` + `key code 36` because it requires\n // accessibility permissions and unreliably delivers the Return key.\n //\n // `do script` with `in` targets a specific tab without opening a new one.\n // We send text and Enter as two separate calls so the newline arrives\n // outside of bracketed paste mode — same pattern as iTerm2 and tmux.\n const textScript = `\ntell application \"Terminal\"\n set targetTab to missing value\n repeat with w in windows\n repeat with i from 1 to count of tabs of w\n set t to tab i of w\n if tty of t is \"${tty}\" then\n set targetTab to t\n exit repeat\n end if\n end repeat\n if targetTab is not missing value then exit repeat\n end repeat\n if targetTab is missing value then return \"not_found\"\n do script \"${escaped}\" in targetTab\nend tell\nreturn \"ok\"`;\n\n const { stdout: textResult } = await execFileAsync('osascript', ['-e', textScript]);\n if (textResult.trim() !== 'ok') {\n throw new Error(`Terminal.app tab not found for TTY ${tty}`);\n }\n\n // Wait for the text to be delivered before sending Enter\n await new Promise((resolve) => setTimeout(resolve, 150));\n\n const enterScript = `\ntell application \"Terminal\"\n set targetTab to missing value\n repeat with w in windows\n repeat with i from 1 to count of tabs of w\n set t to tab i of w\n if tty of t is \"${tty}\" then\n set targetTab to t\n exit repeat\n end if\n end repeat\n if targetTab is not missing value then exit repeat\n end repeat\n if targetTab is missing value then return \"not_found\"\n do script \"\" in targetTab\nend tell\nreturn \"ok\"`;\n\n const { stdout: enterResult } = await execFileAsync('osascript', ['-e', enterScript]);\n if (enterResult.trim() !== 'ok') {\n throw new Error(`Terminal.app tab disappeared before Enter could be sent for TTY ${tty}`);\n }\n }\n}\n"],"names":["execFile","promisify","TerminalType","escapeAppleScript","execFileAsync","TtyWriter","send","location","message","type","TMUX","sendViaTmux","identifier","ITERM2","sendViaITerm2","tty","TERMINAL_APP","sendViaTerminalApp","Error","Promise","resolve","setTimeout","iterm2SessionScript","sessionCommand","escaped","textScript","stdout","textResult","trim","enterScript","enterResult"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/terminal/TtyWriter.ts"],"sourcesContent":["import { execFile } from 'child_process';\nimport { promisify } from 'util';\nimport type { TerminalLocation } from './TerminalFocusManager.js';\nimport { TerminalType } from './TerminalFocusManager.js';\nimport { escapeAppleScript } from '../utils/applescript.js';\n\nconst execFileAsync = promisify(execFile);\n\nexport class TtyWriter {\n /**\n * Send a message as keyboard input to a terminal session.\n *\n * Dispatches to the correct mechanism based on terminal type:\n * - tmux: `tmux send-keys`\n * - iTerm2: Two separate AppleScript `write text` calls (text then newline)\n * - Terminal.app: Two separate AppleScript `do script` calls (text then newline)\n *\n * All AppleScript is executed via `execFile('osascript', ['-e', script])`\n * to avoid shell interpolation and command injection.\n *\n * @param location Terminal location from TerminalFocusManager.findTerminal()\n * @param message Text to send\n * @throws Error if terminal type is unsupported or send fails\n */\n static async send(location: TerminalLocation, message: string): Promise<void> {\n switch (location.type) {\n case TerminalType.TMUX:\n return TtyWriter.sendViaTmux(location.identifier, message);\n case TerminalType.ITERM2:\n return TtyWriter.sendViaITerm2(location.tty, message);\n case TerminalType.TERMINAL_APP:\n return TtyWriter.sendViaTerminalApp(location.tty, message);\n default:\n throw new Error(\n `Cannot send input: unsupported terminal type \"${location.type}\". ` +\n 'Supported: tmux, iTerm2, Terminal.app.'\n );\n }\n }\n\n private static async sendViaTmux(identifier: string, message: string): Promise<void> {\n // Paste the message body using tmux bracketed paste, then send Enter as\n // a separate key so the inner TUI treats it as submission rather than\n // pasted content.\n const bufferName = `ai-devkit-send-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n await TtyWriter.execFileWithInput('tmux', ['load-buffer', '-b', bufferName, '-'], message);\n await execFileAsync('tmux', ['paste-buffer', '-t', identifier, '-b', bufferName, '-p', '-d']);\n await new Promise((resolve) => setTimeout(resolve, 150));\n await execFileAsync('tmux', ['send-keys', '-t', identifier, 'Enter']);\n }\n\n private static async execFileWithInput(command: string, args: string[], input: string): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n const child = execFile(command, args, (error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n if (!child.stdin) {\n reject(new Error(`Cannot write stdin to ${command}`));\n return;\n }\n child.stdin.end(input);\n });\n }\n\n /**\n * Build an AppleScript that finds an iTerm2 session by TTY and runs a\n * command against it. The `sessionCommand` is inserted inside a\n * `tell targetSession` block.\n */\n private static iterm2SessionScript(tty: string, sessionCommand: string): string {\n return `\ntell application \"iTerm\"\n set targetSession to missing value\n repeat with w in windows\n repeat with t in tabs of w\n repeat with s in sessions of t\n if tty of s is \"${tty}\" then\n set targetSession to s\n exit repeat\n end if\n end repeat\n if targetSession is not missing value then exit repeat\n end repeat\n if targetSession is not missing value then exit repeat\n end repeat\n if targetSession is missing value then return \"not_found\"\n tell targetSession to ${sessionCommand}\nend tell\nreturn \"ok\"`;\n }\n\n private static async sendViaITerm2(tty: string, message: string): Promise<void> {\n const escaped = escapeAppleScript(message);\n // Send text and Enter as two separate write text calls so the newline\n // is delivered outside the bracketed paste sequence of the message body.\n // iTerm2 appends the newline after the paste-end marker (\\e[201~), so\n // the inner TUI (Claude Code, Codex) sees it as a real submit action.\n const textScript = TtyWriter.iterm2SessionScript(tty, `write text \"${escaped}\" newline no`);\n\n const { stdout: textResult } = await execFileAsync('osascript', ['-e', textScript]);\n if (textResult.trim() !== 'ok') {\n throw new Error(`iTerm2 session not found for TTY ${tty}`);\n }\n\n // Wait for the paste to complete before sending Enter separately\n await new Promise((resolve) => setTimeout(resolve, 150));\n\n const enterScript = TtyWriter.iterm2SessionScript(tty, 'write text \"\" newline yes');\n const { stdout: enterResult } = await execFileAsync('osascript', ['-e', enterScript]);\n if (enterResult.trim() !== 'ok') {\n throw new Error(`iTerm2 session disappeared before Enter could be sent for TTY ${tty}`);\n }\n }\n\n private static async sendViaTerminalApp(tty: string, message: string): Promise<void> {\n const escaped = escapeAppleScript(message);\n // Use Terminal.app's `do script` to send text to the correct tab by TTY.\n // We avoid System Events `keystroke` + `key code 36` because it requires\n // accessibility permissions and unreliably delivers the Return key.\n //\n // `do script` with `in` targets a specific tab without opening a new one.\n // We send text and Enter as two separate calls so the newline arrives\n // outside of bracketed paste mode — same pattern as iTerm2 and tmux.\n const textScript = `\ntell application \"Terminal\"\n set targetTab to missing value\n repeat with w in windows\n repeat with i from 1 to count of tabs of w\n set t to tab i of w\n if tty of t is \"${tty}\" then\n set targetTab to t\n exit repeat\n end if\n end repeat\n if targetTab is not missing value then exit repeat\n end repeat\n if targetTab is missing value then return \"not_found\"\n do script \"${escaped}\" in targetTab\nend tell\nreturn \"ok\"`;\n\n const { stdout: textResult } = await execFileAsync('osascript', ['-e', textScript]);\n if (textResult.trim() !== 'ok') {\n throw new Error(`Terminal.app tab not found for TTY ${tty}`);\n }\n\n // Wait for the text to be delivered before sending Enter\n await new Promise((resolve) => setTimeout(resolve, 150));\n\n const enterScript = `\ntell application \"Terminal\"\n set targetTab to missing value\n repeat with w in windows\n repeat with i from 1 to count of tabs of w\n set t to tab i of w\n if tty of t is \"${tty}\" then\n set targetTab to t\n exit repeat\n end if\n end repeat\n if targetTab is not missing value then exit repeat\n end repeat\n if targetTab is missing value then return \"not_found\"\n do script \"\" in targetTab\nend tell\nreturn \"ok\"`;\n\n const { stdout: enterResult } = await execFileAsync('osascript', ['-e', enterScript]);\n if (enterResult.trim() !== 'ok') {\n throw new Error(`Terminal.app tab disappeared before Enter could be sent for TTY ${tty}`);\n }\n }\n}\n"],"names":["execFile","promisify","TerminalType","escapeAppleScript","execFileAsync","TtyWriter","send","location","message","type","TMUX","sendViaTmux","identifier","ITERM2","sendViaITerm2","tty","TERMINAL_APP","sendViaTerminalApp","Error","bufferName","process","pid","Date","now","Math","random","toString","slice","execFileWithInput","Promise","resolve","setTimeout","command","args","input","reject","child","error","stdin","end","iterm2SessionScript","sessionCommand","escaped","textScript","stdout","textResult","trim","enterScript","enterResult"],"mappings":"AAAA,SAASA,QAAQ,QAAQ,gBAAgB;AACzC,SAASC,SAAS,QAAQ,OAAO;AAEjC,SAASC,YAAY,QAAQ,4BAA4B;AACzD,SAASC,iBAAiB,QAAQ,0BAA0B;AAE5D,MAAMC,gBAAgBH,UAAUD;AAEhC,OAAO,MAAMK;IACT;;;;;;;;;;;;;;KAcC,GACD,aAAaC,KAAKC,QAA0B,EAAEC,OAAe,EAAiB;QAC1E,OAAQD,SAASE,IAAI;YACjB,KAAKP,aAAaQ,IAAI;gBAClB,OAAOL,UAAUM,WAAW,CAACJ,SAASK,UAAU,EAAEJ;YACtD,KAAKN,aAAaW,MAAM;gBACpB,OAAOR,UAAUS,aAAa,CAACP,SAASQ,GAAG,EAAEP;YACjD,KAAKN,aAAac,YAAY;gBAC1B,OAAOX,UAAUY,kBAAkB,CAACV,SAASQ,GAAG,EAAEP;YACtD;gBACI,MAAM,IAAIU,MACN,CAAC,8CAA8C,EAAEX,SAASE,IAAI,CAAC,GAAG,CAAC,GACnE;QAEZ;IACJ;IAEA,aAAqBE,YAAYC,UAAkB,EAAEJ,OAAe,EAAiB;QACjF,wEAAwE;QACxE,sEAAsE;QACtE,kBAAkB;QAClB,MAAMW,aAAa,CAAC,eAAe,EAAEC,QAAQC,GAAG,CAAC,CAAC,EAAEC,KAAKC,GAAG,GAAG,CAAC,EAAEC,KAAKC,MAAM,GAAGC,QAAQ,CAAC,IAAIC,KAAK,CAAC,IAAI;QACvG,MAAMtB,UAAUuB,iBAAiB,CAAC,QAAQ;YAAC;YAAe;YAAMT;YAAY;SAAI,EAAEX;QAClF,MAAMJ,cAAc,QAAQ;YAAC;YAAgB;YAAMQ;YAAY;YAAMO;YAAY;YAAM;SAAK;QAC5F,MAAM,IAAIU,QAAQ,CAACC,UAAYC,WAAWD,SAAS;QACnD,MAAM1B,cAAc,QAAQ;YAAC;YAAa;YAAMQ;YAAY;SAAQ;IACxE;IAEA,aAAqBgB,kBAAkBI,OAAe,EAAEC,IAAc,EAAEC,KAAa,EAAiB;QAClG,MAAM,IAAIL,QAAc,CAACC,SAASK;YAC9B,MAAMC,QAAQpC,SAASgC,SAASC,MAAM,CAACI;gBACnC,IAAIA,OAAO;oBACPF,OAAOE;oBACP;gBACJ;gBACAP;YACJ;YACA,IAAI,CAACM,MAAME,KAAK,EAAE;gBACdH,OAAO,IAAIjB,MAAM,CAAC,sBAAsB,EAAEc,SAAS;gBACnD;YACJ;YACAI,MAAME,KAAK,CAACC,GAAG,CAACL;QACpB;IACJ;IAEA;;;;KAIC,GACD,OAAeM,oBAAoBzB,GAAW,EAAE0B,cAAsB,EAAU;QAC5E,OAAO,CAAC;;;;;;wBAMQ,EAAE1B,IAAI;;;;;;;;;;wBAUN,EAAE0B,eAAe;;WAE9B,CAAC;IACR;IAEA,aAAqB3B,cAAcC,GAAW,EAAEP,OAAe,EAAiB;QAC5E,MAAMkC,UAAUvC,kBAAkBK;QAClC,sEAAsE;QACtE,yEAAyE;QACzE,sEAAsE;QACtE,sEAAsE;QACtE,MAAMmC,aAAatC,UAAUmC,mBAAmB,CAACzB,KAAK,CAAC,YAAY,EAAE2B,QAAQ,YAAY,CAAC;QAE1F,MAAM,EAAEE,QAAQC,UAAU,EAAE,GAAG,MAAMzC,cAAc,aAAa;YAAC;YAAMuC;SAAW;QAClF,IAAIE,WAAWC,IAAI,OAAO,MAAM;YAC5B,MAAM,IAAI5B,MAAM,CAAC,iCAAiC,EAAEH,KAAK;QAC7D;QAEA,iEAAiE;QACjE,MAAM,IAAIc,QAAQ,CAACC,UAAYC,WAAWD,SAAS;QAEnD,MAAMiB,cAAc1C,UAAUmC,mBAAmB,CAACzB,KAAK;QACvD,MAAM,EAAE6B,QAAQI,WAAW,EAAE,GAAG,MAAM5C,cAAc,aAAa;YAAC;YAAM2C;SAAY;QACpF,IAAIC,YAAYF,IAAI,OAAO,MAAM;YAC7B,MAAM,IAAI5B,MAAM,CAAC,8DAA8D,EAAEH,KAAK;QAC1F;IACJ;IAEA,aAAqBE,mBAAmBF,GAAW,EAAEP,OAAe,EAAiB;QACjF,MAAMkC,UAAUvC,kBAAkBK;QAClC,yEAAyE;QACzE,yEAAyE;QACzE,oEAAoE;QACpE,EAAE;QACF,0EAA0E;QAC1E,sEAAsE;QACtE,qEAAqE;QACrE,MAAMmC,aAAa,CAAC;;;;;;sBAMN,EAAE5B,IAAI;;;;;;;;aAQf,EAAE2B,QAAQ;;WAEZ,CAAC;QAEJ,MAAM,EAAEE,QAAQC,UAAU,EAAE,GAAG,MAAMzC,cAAc,aAAa;YAAC;YAAMuC;SAAW;QAClF,IAAIE,WAAWC,IAAI,OAAO,MAAM;YAC5B,MAAM,IAAI5B,MAAM,CAAC,mCAAmC,EAAEH,KAAK;QAC/D;QAEA,yDAAyD;QACzD,MAAM,IAAIc,QAAQ,CAACC,UAAYC,WAAWD,SAAS;QAEnD,MAAMiB,cAAc,CAAC;;;;;;sBAMP,EAAEhC,IAAI;;;;;;;;;;WAUjB,CAAC;QAEJ,MAAM,EAAE6B,QAAQI,WAAW,EAAE,GAAG,MAAM5C,cAAc,aAAa;YAAC;YAAM2C;SAAY;QACpF,IAAIC,YAAYF,IAAI,OAAO,MAAM;YAC7B,MAAM,IAAI5B,MAAM,CAAC,gEAAgE,EAAEH,KAAK;QAC5F;IACJ;AACJ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "description": "Standalone agent detection and management utilities for AI DevKit",
6
6
  "main": "dist/index.js",
@@ -19,6 +19,7 @@ function mockExecFileSuccess(stdout = '') {
19
19
  mockedExecFile.mockImplementation((...args: unknown[]) => {
20
20
  const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;
21
21
  cb(null, { stdout }, '');
22
+ return { stdin: { end: vi.fn() } };
22
23
  });
23
24
  }
24
25
 
@@ -41,22 +42,36 @@ describe('TtyWriter', () => {
41
42
  tty: '/dev/ttys030',
42
43
  };
43
44
 
44
- it('sends message and Enter as separate tmux send-keys calls', async () => {
45
+ it('pastes message in bracketed paste mode and sends Enter separately', async () => {
45
46
  mockExecFileSuccess();
47
+ const message = 'line 1\nline 2\n';
46
48
 
47
- await TtyWriter.send(location, 'continue');
49
+ await TtyWriter.send(location, message);
48
50
 
49
- expect(mockedExecFile).toHaveBeenCalledWith(
51
+ const loadArgs = mockedExecFile.mock.calls[0]?.[1] as string[];
52
+ const bufferName = loadArgs[2];
53
+ expect(bufferName).toMatch(/^ai-devkit-send-/);
54
+ expect(mockedExecFile).toHaveBeenNthCalledWith(
55
+ 1,
50
56
  'tmux',
51
- ['send-keys', '-t', 'main:0.1', '-l', 'continue'],
57
+ ['load-buffer', '-b', bufferName, '-'],
52
58
  expect.any(Function),
53
59
  );
54
- expect(mockedExecFile).toHaveBeenCalledWith(
60
+ expect(mockedExecFile.mock.results[0]?.value.stdin.end)
61
+ .toHaveBeenCalledWith(message);
62
+ expect(mockedExecFile).toHaveBeenNthCalledWith(
63
+ 2,
64
+ 'tmux',
65
+ ['paste-buffer', '-t', 'main:0.1', '-b', bufferName, '-p', '-d'],
66
+ expect.any(Function),
67
+ );
68
+ expect(mockedExecFile).toHaveBeenNthCalledWith(
69
+ 3,
55
70
  'tmux',
56
71
  ['send-keys', '-t', 'main:0.1', 'Enter'],
57
72
  expect.any(Function),
58
73
  );
59
- expect(mockedExecFile).toHaveBeenCalledTimes(2);
74
+ expect(mockedExecFile).toHaveBeenCalledTimes(3);
60
75
  });
61
76
 
62
77
  it('throws on tmux failure', async () => {
@@ -39,16 +39,33 @@ export class TtyWriter {
39
39
  }
40
40
 
41
41
  private static async sendViaTmux(identifier: string, message: string): Promise<void> {
42
- // Send text and Enter as two separate calls so that Enter arrives
43
- // outside of bracketed paste mode. When the inner application (e.g.
44
- // Claude Code) has bracketed paste enabled, tmux wraps the send-keys
45
- // payload in paste brackets — if Enter is included, it gets swallowed
46
- // as part of the paste instead of acting as a submit action.
47
- await execFileAsync('tmux', ['send-keys', '-t', identifier, '-l', message]);
42
+ // Paste the message body using tmux bracketed paste, then send Enter as
43
+ // a separate key so the inner TUI treats it as submission rather than
44
+ // pasted content.
45
+ const bufferName = `ai-devkit-send-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
46
+ await TtyWriter.execFileWithInput('tmux', ['load-buffer', '-b', bufferName, '-'], message);
47
+ await execFileAsync('tmux', ['paste-buffer', '-t', identifier, '-b', bufferName, '-p', '-d']);
48
48
  await new Promise((resolve) => setTimeout(resolve, 150));
49
49
  await execFileAsync('tmux', ['send-keys', '-t', identifier, 'Enter']);
50
50
  }
51
51
 
52
+ private static async execFileWithInput(command: string, args: string[], input: string): Promise<void> {
53
+ await new Promise<void>((resolve, reject) => {
54
+ const child = execFile(command, args, (error) => {
55
+ if (error) {
56
+ reject(error);
57
+ return;
58
+ }
59
+ resolve();
60
+ });
61
+ if (!child.stdin) {
62
+ reject(new Error(`Cannot write stdin to ${command}`));
63
+ return;
64
+ }
65
+ child.stdin.end(input);
66
+ });
67
+ }
68
+
52
69
  /**
53
70
  * Build an AppleScript that finds an iTerm2 session by TTY and runs a
54
71
  * command against it. The `sessionCommand` is inserted inside a