@ai-devkit/agent-manager 0.22.1 → 0.24.0

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.
Files changed (34) hide show
  1. package/README.md +3 -0
  2. package/dist/__tests__/AgentManager.test.js +9 -1
  3. package/dist/__tests__/AgentManager.test.js.map +1 -1
  4. package/dist/__tests__/terminal/TerminalFocusManager.test.js +180 -0
  5. package/dist/__tests__/terminal/TerminalFocusManager.test.js.map +1 -1
  6. package/dist/__tests__/terminal/TtyWriter.test.js +206 -0
  7. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -1
  8. package/dist/__tests__/utils/agent-requests.test.js +90 -0
  9. package/dist/__tests__/utils/agent-requests.test.js.map +1 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/terminal/TerminalFocusManager.d.ts +11 -0
  15. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  16. package/dist/terminal/TerminalFocusManager.js +84 -10
  17. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  18. package/dist/terminal/TtyWriter.d.ts +19 -0
  19. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  20. package/dist/terminal/TtyWriter.js +167 -1
  21. package/dist/terminal/TtyWriter.js.map +1 -1
  22. package/dist/utils/agent-requests.d.ts +10 -0
  23. package/dist/utils/agent-requests.d.ts.map +1 -0
  24. package/dist/utils/agent-requests.js +22 -0
  25. package/dist/utils/agent-requests.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/__tests__/AgentManager.test.ts +7 -1
  28. package/src/__tests__/terminal/TerminalFocusManager.test.ts +187 -0
  29. package/src/__tests__/terminal/TtyWriter.test.ts +234 -0
  30. package/src/__tests__/utils/agent-requests.test.ts +74 -0
  31. package/src/index.ts +3 -0
  32. package/src/terminal/TerminalFocusManager.ts +103 -11
  33. package/src/terminal/TtyWriter.ts +161 -1
  34. package/src/utils/agent-requests.ts +28 -0
@@ -7,6 +7,7 @@ const execFileAsync = promisify(execFile);
7
7
 
8
8
  export enum TerminalType {
9
9
  TMUX = 'tmux',
10
+ WEZTERM = 'wezterm',
10
11
  ITERM2 = 'iterm2',
11
12
  TERMINAL_APP = 'terminal-app',
12
13
  UNKNOWN = 'unknown',
@@ -14,11 +15,31 @@ export enum TerminalType {
14
15
 
15
16
  export interface TerminalLocation {
16
17
  type: TerminalType;
17
- identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others
18
+ identifier: string; // e.g., "session:window.pane" for tmux, WezTerm pane id, or TTY for others
18
19
  tty: string; // e.g., "/dev/ttys030"
19
20
  }
20
21
 
22
+ /**
23
+ * Subset of a `wezterm cli list --format json` entry. Only `pane_id` and
24
+ * `tty_name` are read; extra fields are ignored so schema additions across
25
+ * WezTerm versions don't break parsing. (The TTY is exposed as `tty_name` in
26
+ * the JSON, not `tty`.)
27
+ */
28
+ interface WeztermPaneEntry {
29
+ pane_id?: number;
30
+ tty_name?: string | null;
31
+ }
32
+
33
+ /**
34
+ * Optional trace sink. When provided to {@link TerminalFocusManager}, each
35
+ * discovery/focus step reports a human-readable line so callers (e.g. the
36
+ * `agent open --debug` command) can inspect the matching/focus decision path.
37
+ */
38
+ export type TerminalDebugLogger = (message: string) => void;
39
+
21
40
  export class TerminalFocusManager {
41
+ constructor(private readonly debug?: TerminalDebugLogger) {}
42
+
22
43
  /**
23
44
  * Find the terminal location (emulator info) for a given process ID
24
45
  */
@@ -27,24 +48,47 @@ export class TerminalFocusManager {
27
48
 
28
49
  // If no TTY or invalid, we can't find the terminal
29
50
  if (!ttyShort || ttyShort === '?') {
51
+ this.debug?.(`findTerminal(pid=${pid}): no usable TTY, cannot resolve terminal`);
30
52
  return null;
31
53
  }
32
54
 
33
55
  const fullTty = `/dev/${ttyShort}`;
56
+ this.debug?.(`findTerminal(pid=${pid}): resolving terminal for ${fullTty}`);
34
57
 
35
58
  // 1. Check tmux (most specific if running inside it)
36
59
  const tmuxLocation = await this.findTmuxPane(fullTty);
37
- if (tmuxLocation) return tmuxLocation;
60
+ if (tmuxLocation) {
61
+ this.debug?.(`findTerminal: matched tmux (identifier=${tmuxLocation.identifier})`);
62
+ return tmuxLocation;
63
+ }
64
+ this.debug?.('findTerminal: tmux no match');
65
+
66
+ // 2. Check WezTerm (cross-platform, via its CLI — no AppleScript)
67
+ const weztermLocation = await this.findWeztermPane(fullTty);
68
+ if (weztermLocation) {
69
+ this.debug?.(`findTerminal: matched wezterm (pane_id=${weztermLocation.identifier})`);
70
+ return weztermLocation;
71
+ }
72
+ this.debug?.('findTerminal: wezterm no match');
38
73
 
39
- // 2. Check iTerm2
74
+ // 3. Check iTerm2
40
75
  const itermLocation = await this.findITerm2Session(fullTty);
41
- if (itermLocation) return itermLocation;
76
+ if (itermLocation) {
77
+ this.debug?.(`findTerminal: matched iTerm2 (tty=${itermLocation.tty})`);
78
+ return itermLocation;
79
+ }
80
+ this.debug?.('findTerminal: iTerm2 no match');
42
81
 
43
- // 3. Check Terminal.app
82
+ // 4. Check Terminal.app
44
83
  const terminalAppLocation = await this.findTerminalAppWindow(fullTty);
45
- if (terminalAppLocation) return terminalAppLocation;
84
+ if (terminalAppLocation) {
85
+ this.debug?.(`findTerminal: matched Terminal.app (tty=${terminalAppLocation.tty})`);
86
+ return terminalAppLocation;
87
+ }
88
+ this.debug?.('findTerminal: Terminal.app no match');
46
89
 
47
- // 4. Fallback: we know the TTY but not the emulator wrapper
90
+ // 5. Fallback: we know the TTY but not the emulator wrapper
91
+ this.debug?.('findTerminal: no emulator matched; returning UNKNOWN');
48
92
  return {
49
93
  type: TerminalType.UNKNOWN,
50
94
  identifier: '',
@@ -56,17 +100,65 @@ export class TerminalFocusManager {
56
100
  * Focus the terminal identified by the location
57
101
  */
58
102
  async focusTerminal(location: TerminalLocation): Promise<boolean> {
103
+ this.debug?.(`focusTerminal: focusing ${location.type} (identifier=${location.identifier}, tty=${location.tty})`);
104
+ let success = false;
59
105
  try {
60
106
  switch (location.type) {
61
107
  case TerminalType.TMUX:
62
- return await this.focusTmuxPane(location.identifier);
108
+ success = await this.focusTmuxPane(location.identifier);
109
+ break;
110
+ case TerminalType.WEZTERM:
111
+ success = await this.focusWeztermPane(location.identifier);
112
+ break;
63
113
  case TerminalType.ITERM2:
64
- return await this.focusITerm2Session(location.tty);
114
+ success = await this.focusITerm2Session(location.tty);
115
+ break;
65
116
  case TerminalType.TERMINAL_APP:
66
- return await this.focusTerminalAppWindow(location.tty);
117
+ success = await this.focusTerminalAppWindow(location.tty);
118
+ break;
67
119
  default:
68
- return false;
120
+ success = false;
121
+ }
122
+ } catch {
123
+ success = false;
124
+ }
125
+ this.debug?.(`focusTerminal: ${success ? 'succeeded' : 'failed'} for ${location.type}`);
126
+ return success;
127
+ }
128
+
129
+ private async findWeztermPane(tty: string): Promise<TerminalLocation | null> {
130
+ try {
131
+ const { stdout } = await execFileAsync('wezterm', [
132
+ 'cli', 'list', '--format', 'json',
133
+ ]);
134
+
135
+ const panes = JSON.parse(stdout) as WeztermPaneEntry[];
136
+ if (!Array.isArray(panes)) return null;
137
+
138
+ for (const pane of panes) {
139
+ if (
140
+ pane &&
141
+ typeof pane.tty_name === 'string' &&
142
+ pane.tty_name === tty &&
143
+ pane.pane_id != null
144
+ ) {
145
+ return {
146
+ type: TerminalType.WEZTERM,
147
+ identifier: String(pane.pane_id),
148
+ tty,
149
+ };
150
+ }
69
151
  }
152
+ } catch {
153
+ // wezterm not installed, not running, or returned invalid JSON
154
+ }
155
+ return null;
156
+ }
157
+
158
+ private async focusWeztermPane(paneId: string): Promise<boolean> {
159
+ try {
160
+ await execFileAsync('wezterm', ['cli', 'activate-pane', '--pane-id', paneId]);
161
+ return true;
70
162
  } catch {
71
163
  return false;
72
164
  }
@@ -6,6 +6,19 @@ import { escapeAppleScript } from '../utils/applescript.js';
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
 
9
+ /**
10
+ * Carriage return byte (0x0d). Sent as a fixed discrete argv element with
11
+ * `--no-paste` to deliver Enter literally (shell equivalent: $'\x0d').
12
+ */
13
+ const CARRIAGE_RETURN = '\x0d';
14
+
15
+ /**
16
+ * Escape byte (0x1b). Recognized by `sendKey` and translated to the
17
+ * backend-native representation (`Escape` for tmux, `key code 53` for
18
+ * AppleScript, the literal byte for WezTerm).
19
+ */
20
+ const ESCAPE_BYTE = '\x1b';
21
+
9
22
  export class TtyWriter {
10
23
  /**
11
24
  * Send a message as keyboard input to a terminal session.
@@ -26,6 +39,8 @@ export class TtyWriter {
26
39
  switch (location.type) {
27
40
  case TerminalType.TMUX:
28
41
  return TtyWriter.sendViaTmux(location.identifier, message);
42
+ case TerminalType.WEZTERM:
43
+ return TtyWriter.sendViaWezterm(location.identifier, message);
29
44
  case TerminalType.ITERM2:
30
45
  return TtyWriter.sendViaITerm2(location.tty, message);
31
46
  case TerminalType.TERMINAL_APP:
@@ -33,11 +48,146 @@ export class TtyWriter {
33
48
  default:
34
49
  throw new Error(
35
50
  `Cannot send input: unsupported terminal type "${location.type}". ` +
36
- 'Supported: tmux, iTerm2, Terminal.app.'
51
+ 'Supported: tmux, WezTerm, iTerm2, Terminal.app.'
37
52
  );
38
53
  }
39
54
  }
40
55
 
56
+ /**
57
+ * Send a single raw key (e.g. "1", "Enter", "Up") to the terminal as a
58
+ * keystroke — bypassing bracketed paste and without auto-appending Enter.
59
+ *
60
+ * Use this when the target TUI distinguishes between typed text and raw
61
+ * keypresses (e.g. an `AskUserQuestion` picker that selects on digit-key
62
+ * press, not on a pasted digit followed by Enter).
63
+ *
64
+ * - tmux: `tmux send-keys -t <id> <key>` — direct keystroke, no paste buffer.
65
+ * - WezTerm: `wezterm cli send-text --pane-id <id> --no-paste <key>`.
66
+ * - iTerm2 / Terminal.app: AppleScript via System Events. Requires
67
+ * Accessibility permissions.
68
+ */
69
+ static async sendKey(location: TerminalLocation, key: string): Promise<void> {
70
+ switch (location.type) {
71
+ case TerminalType.TMUX:
72
+ return TtyWriter.sendKeyViaTmux(location.identifier, key);
73
+ case TerminalType.WEZTERM:
74
+ return TtyWriter.sendKeyViaWezterm(location.identifier, key);
75
+ case TerminalType.ITERM2:
76
+ return TtyWriter.sendKeyViaITerm2(location.tty, key);
77
+ case TerminalType.TERMINAL_APP:
78
+ return TtyWriter.sendKeyViaTerminalApp(location.tty, key);
79
+ default:
80
+ throw new Error(
81
+ `Cannot send key: unsupported terminal type "${location.type}". ` +
82
+ 'Supported: tmux, WezTerm, iTerm2, Terminal.app.'
83
+ );
84
+ }
85
+ }
86
+
87
+ private static async sendKeyViaTmux(identifier: string, key: string): Promise<void> {
88
+ // tmux send-keys interprets named keys (Enter, Up, Escape, ...) and
89
+ // passes literals through. No bracketed paste, no auto-Enter.
90
+ const arg = key === ESCAPE_BYTE ? 'Escape' : key;
91
+ await execFileAsync('tmux', ['send-keys', '-t', identifier, arg]);
92
+ }
93
+
94
+ private static async sendKeyViaWezterm(paneId: string, key: string): Promise<void> {
95
+ // --no-paste delivers the key bytes literally outside bracketed-paste
96
+ // markers; the TUI sees a raw keystroke. For Esc (`\x1b`), wezterm
97
+ // accepts the byte directly.
98
+ await execFileAsync('wezterm', [
99
+ 'cli', 'send-text', '--pane-id', paneId, '--no-paste', key,
100
+ ]);
101
+ }
102
+
103
+ private static async sendKeyViaITerm2(tty: string, key: string): Promise<void> {
104
+ // Focus the target session, then press the key via System Events so the
105
+ // inner TUI sees a raw keystroke (not a bracketed-paste text run).
106
+ const action = appleScriptKeyAction(key);
107
+ const script = `
108
+ tell application "iTerm"
109
+ set targetSession to missing value
110
+ repeat with w in windows
111
+ repeat with t in tabs of w
112
+ repeat with s in sessions of t
113
+ if tty of s is "${tty}" then
114
+ set targetSession to s
115
+ set frontmost of w to true
116
+ tell t to select
117
+ tell s to select
118
+ exit repeat
119
+ end if
120
+ end repeat
121
+ if targetSession is not missing value then exit repeat
122
+ end repeat
123
+ if targetSession is not missing value then exit repeat
124
+ end repeat
125
+ if targetSession is missing value then return "not_found"
126
+ activate
127
+ end tell
128
+ tell application "System Events" to ${action}
129
+ return "ok"`;
130
+
131
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
132
+ if (stdout.trim() !== 'ok') {
133
+ throw new Error(`iTerm2 session not found for TTY ${tty}`);
134
+ }
135
+ }
136
+
137
+ private static async sendKeyViaTerminalApp(tty: string, key: string): Promise<void> {
138
+ const action = appleScriptKeyAction(key);
139
+ const script = `
140
+ tell application "Terminal"
141
+ set targetTab to missing value
142
+ set targetWindow to missing value
143
+ repeat with w in windows
144
+ repeat with i from 1 to count of tabs of w
145
+ set t to tab i of w
146
+ if tty of t is "${tty}" then
147
+ set targetTab to t
148
+ set targetWindow to w
149
+ exit repeat
150
+ end if
151
+ end repeat
152
+ if targetTab is not missing value then exit repeat
153
+ end repeat
154
+ if targetTab is missing value then return "not_found"
155
+ set selected of targetTab to true
156
+ set frontmost of targetWindow to true
157
+ activate
158
+ end tell
159
+ tell application "System Events" to ${action}
160
+ return "ok"`;
161
+
162
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
163
+ if (stdout.trim() !== 'ok') {
164
+ throw new Error(`Terminal.app tab not found for TTY ${tty}`);
165
+ }
166
+ }
167
+
168
+ private static async sendViaWezterm(paneId: string, message: string): Promise<void> {
169
+ // Two explicit CLI calls, mirroring the text-then-Enter convention used
170
+ // by tmux / iTerm2 / Terminal.app so a bracketed-paste-aware TUI still
171
+ // sees Enter as a submit.
172
+ //
173
+ // Step 1 (text): write the message to stdin so prompt contents are not
174
+ // exposed through process arguments. execFile still spawns wezterm
175
+ // directly (no shell), so shell metacharacters remain inert.
176
+ // Step 2 (Enter): pass a fixed carriage return (0x0d) as a discrete
177
+ // argv element (the JS char '\x0d') with --no-paste, so the CR is
178
+ // delivered literally rather than wrapped in paste brackets. The
179
+ // equivalent shell command is:
180
+ // wezterm cli send-text --pane-id <id> --no-paste $'\x0d'
181
+ // (ANSI-C quoting, note the leading $).
182
+ await TtyWriter.execFileWithInput('wezterm', [
183
+ 'cli', 'send-text', '--pane-id', paneId,
184
+ ], message);
185
+ await new Promise((resolve) => setTimeout(resolve, 150));
186
+ await execFileAsync('wezterm', [
187
+ 'cli', 'send-text', '--pane-id', paneId, '--no-paste', CARRIAGE_RETURN,
188
+ ]);
189
+ }
190
+
41
191
  private static async sendViaTmux(identifier: string, message: string): Promise<void> {
42
192
  // Paste the message body using tmux bracketed paste, then send Enter as
43
193
  // a separate key so the inner TUI treats it as submission rather than
@@ -175,3 +325,13 @@ return "ok"`;
175
325
  }
176
326
  }
177
327
  }
328
+
329
+ /**
330
+ * AppleScript `keystroke` only delivers typeable characters; non-typeable
331
+ * keys (Esc, arrows, F-keys, …) must be sent via `key code <N>`. Add more
332
+ * mappings here as new special keys are needed.
333
+ */
334
+ function appleScriptKeyAction(key: string): string {
335
+ if (key === ESCAPE_BYTE) return 'key code 53';
336
+ return `keystroke "${escapeAppleScript(key)}"`;
337
+ }
@@ -0,0 +1,28 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ export interface AgentRequest {
5
+ sessionId: string;
6
+ toolName: string;
7
+ toolInput: Record<string, unknown>;
8
+ timestamp: string;
9
+ }
10
+
11
+ export function getAgentRequestPath(homeDir: string, sessionId: string): string {
12
+ return path.join(homeDir, '.ai-devkit', 'agent-requests', `${sessionId}.json`);
13
+ }
14
+
15
+ export function readLatestAgentRequest(homeDir: string, sessionId: string): AgentRequest | null {
16
+ try {
17
+ const raw = fs.readFileSync(getAgentRequestPath(homeDir, sessionId), 'utf-8');
18
+ return JSON.parse(raw) as AgentRequest;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function writeAgentRequest(homeDir: string, entry: AgentRequest): void {
25
+ const filePath = getAgentRequestPath(homeDir, entry.sessionId);
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), 'utf-8');
28
+ }