@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.
- package/README.md +3 -0
- package/dist/__tests__/AgentManager.test.js +9 -1
- package/dist/__tests__/AgentManager.test.js.map +1 -1
- package/dist/__tests__/terminal/TerminalFocusManager.test.js +180 -0
- package/dist/__tests__/terminal/TerminalFocusManager.test.js.map +1 -1
- package/dist/__tests__/terminal/TtyWriter.test.js +206 -0
- package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -1
- package/dist/__tests__/utils/agent-requests.test.js +90 -0
- package/dist/__tests__/utils/agent-requests.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts +11 -0
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +84 -10
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts +19 -0
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +167 -1
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/utils/agent-requests.d.ts +10 -0
- package/dist/utils/agent-requests.d.ts.map +1 -0
- package/dist/utils/agent-requests.js +22 -0
- package/dist/utils/agent-requests.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/AgentManager.test.ts +7 -1
- package/src/__tests__/terminal/TerminalFocusManager.test.ts +187 -0
- package/src/__tests__/terminal/TtyWriter.test.ts +234 -0
- package/src/__tests__/utils/agent-requests.test.ts +74 -0
- package/src/index.ts +3 -0
- package/src/terminal/TerminalFocusManager.ts +103 -11
- package/src/terminal/TtyWriter.ts +161 -1
- 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)
|
|
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
|
-
//
|
|
74
|
+
// 3. Check iTerm2
|
|
40
75
|
const itermLocation = await this.findITerm2Session(fullTty);
|
|
41
|
-
if (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
|
-
//
|
|
82
|
+
// 4. Check Terminal.app
|
|
44
83
|
const terminalAppLocation = await this.findTerminalAppWindow(fullTty);
|
|
45
|
-
if (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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
success = await this.focusITerm2Session(location.tty);
|
|
115
|
+
break;
|
|
65
116
|
case TerminalType.TERMINAL_APP:
|
|
66
|
-
|
|
117
|
+
success = await this.focusTerminalAppWindow(location.tty);
|
|
118
|
+
break;
|
|
67
119
|
default:
|
|
68
|
-
|
|
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
|
+
}
|