@ai-devkit/agent-manager 0.1.0 → 0.3.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/dist/adapters/ClaudeCodeAdapter.d.ts +12 -0
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +208 -50
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +52 -0
- package/dist/adapters/CodexAdapter.d.ts.map +1 -0
- package/dist/adapters/CodexAdapter.js +432 -0
- package/dist/adapters/CodexAdapter.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +3 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts +7 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +15 -8
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts +23 -0
- package/dist/terminal/TtyWriter.d.ts.map +1 -0
- package/dist/terminal/TtyWriter.js +106 -0
- package/dist/terminal/TtyWriter.js.map +1 -0
- package/dist/terminal/index.d.ts +2 -0
- package/dist/terminal/index.d.ts.map +1 -1
- package/dist/terminal/index.js +5 -1
- package/dist/terminal/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +120 -2
- package/src/__tests__/adapters/CodexAdapter.test.ts +319 -0
- package/src/__tests__/terminal/TtyWriter.test.ts +154 -0
- package/src/adapters/ClaudeCodeAdapter.ts +309 -56
- package/src/adapters/CodexAdapter.ts +584 -0
- package/src/adapters/index.ts +1 -0
- package/src/index.ts +3 -1
- package/src/terminal/TerminalFocusManager.ts +15 -8
- package/src/terminal/TtyWriter.ts +112 -0
- package/src/terminal/index.ts +2 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TerminalLocation } from './TerminalFocusManager';
|
|
2
|
+
export declare class TtyWriter {
|
|
3
|
+
/**
|
|
4
|
+
* Send a message as keyboard input to a terminal session.
|
|
5
|
+
*
|
|
6
|
+
* Dispatches to the correct mechanism based on terminal type:
|
|
7
|
+
* - tmux: `tmux send-keys`
|
|
8
|
+
* - iTerm2: AppleScript `write text`
|
|
9
|
+
* - Terminal.app: System Events `keystroke` + `key code 36` (Return)
|
|
10
|
+
*
|
|
11
|
+
* All AppleScript is executed via `execFile('osascript', ['-e', script])`
|
|
12
|
+
* to avoid shell interpolation and command injection.
|
|
13
|
+
*
|
|
14
|
+
* @param location Terminal location from TerminalFocusManager.findTerminal()
|
|
15
|
+
* @param message Text to send
|
|
16
|
+
* @throws Error if terminal type is unsupported or send fails
|
|
17
|
+
*/
|
|
18
|
+
static send(location: TerminalLocation, message: string): Promise<void>;
|
|
19
|
+
private static sendViaTmux;
|
|
20
|
+
private static sendViaITerm2;
|
|
21
|
+
private static sendViaTerminalApp;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=TtyWriter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TtyWriter.d.ts","sourceRoot":"","sources":["../../src/terminal/TtyWriter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAa/D,qBAAa,SAAS;IAClB;;;;;;;;;;;;;;OAcG;WACU,IAAI,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;mBAgBxD,WAAW;mBAIX,aAAa;mBAuBb,kBAAkB;CAqC1C"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TtyWriter = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const util_1 = require("util");
|
|
6
|
+
const TerminalFocusManager_1 = require("./TerminalFocusManager");
|
|
7
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Escape a string for safe use inside an AppleScript double-quoted string.
|
|
10
|
+
* Backslashes and double quotes must be escaped.
|
|
11
|
+
*/
|
|
12
|
+
function escapeAppleScript(text) {
|
|
13
|
+
return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
14
|
+
}
|
|
15
|
+
class TtyWriter {
|
|
16
|
+
/**
|
|
17
|
+
* Send a message as keyboard input to a terminal session.
|
|
18
|
+
*
|
|
19
|
+
* Dispatches to the correct mechanism based on terminal type:
|
|
20
|
+
* - tmux: `tmux send-keys`
|
|
21
|
+
* - iTerm2: AppleScript `write text`
|
|
22
|
+
* - Terminal.app: System Events `keystroke` + `key code 36` (Return)
|
|
23
|
+
*
|
|
24
|
+
* All AppleScript is executed via `execFile('osascript', ['-e', script])`
|
|
25
|
+
* to avoid shell interpolation and command injection.
|
|
26
|
+
*
|
|
27
|
+
* @param location Terminal location from TerminalFocusManager.findTerminal()
|
|
28
|
+
* @param message Text to send
|
|
29
|
+
* @throws Error if terminal type is unsupported or send fails
|
|
30
|
+
*/
|
|
31
|
+
static async send(location, message) {
|
|
32
|
+
switch (location.type) {
|
|
33
|
+
case TerminalFocusManager_1.TerminalType.TMUX:
|
|
34
|
+
return TtyWriter.sendViaTmux(location.identifier, message);
|
|
35
|
+
case TerminalFocusManager_1.TerminalType.ITERM2:
|
|
36
|
+
return TtyWriter.sendViaITerm2(location.tty, message);
|
|
37
|
+
case TerminalFocusManager_1.TerminalType.TERMINAL_APP:
|
|
38
|
+
return TtyWriter.sendViaTerminalApp(location.tty, message);
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`Cannot send input: unsupported terminal type "${location.type}". ` +
|
|
41
|
+
'Supported: tmux, iTerm2, Terminal.app.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
static async sendViaTmux(identifier, message) {
|
|
45
|
+
await execFileAsync('tmux', ['send-keys', '-t', identifier, message, 'Enter']);
|
|
46
|
+
}
|
|
47
|
+
static async sendViaITerm2(tty, message) {
|
|
48
|
+
const escaped = escapeAppleScript(message);
|
|
49
|
+
const script = `
|
|
50
|
+
tell application "iTerm"
|
|
51
|
+
repeat with w in windows
|
|
52
|
+
repeat with t in tabs of w
|
|
53
|
+
repeat with s in sessions of t
|
|
54
|
+
if tty of s is "${tty}" then
|
|
55
|
+
tell s to write text "${escaped}"
|
|
56
|
+
return "ok"
|
|
57
|
+
end if
|
|
58
|
+
end repeat
|
|
59
|
+
end repeat
|
|
60
|
+
end repeat
|
|
61
|
+
end tell
|
|
62
|
+
return "not_found"`;
|
|
63
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script]);
|
|
64
|
+
if (stdout.trim() !== 'ok') {
|
|
65
|
+
throw new Error(`iTerm2 session not found for TTY ${tty}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
static async sendViaTerminalApp(tty, message) {
|
|
69
|
+
const escaped = escapeAppleScript(message);
|
|
70
|
+
// Use System Events keystroke to type into the foreground process,
|
|
71
|
+
// NOT Terminal.app's "do script" which runs a new shell command.
|
|
72
|
+
// First activate Terminal and select the correct tab, then type via System Events.
|
|
73
|
+
const script = `
|
|
74
|
+
tell application "Terminal"
|
|
75
|
+
set targetFound to false
|
|
76
|
+
repeat with w in windows
|
|
77
|
+
repeat with i from 1 to count of tabs of w
|
|
78
|
+
set t to tab i of w
|
|
79
|
+
if tty of t is "${tty}" then
|
|
80
|
+
set selected tab of w to t
|
|
81
|
+
set index of w to 1
|
|
82
|
+
activate
|
|
83
|
+
set targetFound to true
|
|
84
|
+
exit repeat
|
|
85
|
+
end if
|
|
86
|
+
end repeat
|
|
87
|
+
if targetFound then exit repeat
|
|
88
|
+
end repeat
|
|
89
|
+
if not targetFound then return "not_found"
|
|
90
|
+
end tell
|
|
91
|
+
delay 0.1
|
|
92
|
+
tell application "System Events"
|
|
93
|
+
tell process "Terminal"
|
|
94
|
+
keystroke "${escaped}"
|
|
95
|
+
key code 36
|
|
96
|
+
end tell
|
|
97
|
+
end tell
|
|
98
|
+
return "ok"`;
|
|
99
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script]);
|
|
100
|
+
if (stdout.trim() !== 'ok') {
|
|
101
|
+
throw new Error(`Terminal.app tab not found for TTY ${tty}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.TtyWriter = TtyWriter;
|
|
106
|
+
//# sourceMappingURL=TtyWriter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TtyWriter.js","sourceRoot":"","sources":["../../src/terminal/TtyWriter.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AACzC,+BAAiC;AAEjC,iEAAsD;AAEtD,MAAM,aAAa,GAAG,IAAA,gBAAS,EAAC,wBAAQ,CAAC,CAAC;AAE1C;;;GAGG;AACH,SAAS,iBAAiB,CAAC,IAAY;IACnC,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC5D,CAAC;AAED,MAAa,SAAS;IAClB;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,QAA0B,EAAE,OAAe;QACzD,QAAQ,QAAQ,CAAC,IAAI,EAAE,CAAC;YACpB,KAAK,mCAAY,CAAC,IAAI;gBAClB,OAAO,SAAS,CAAC,WAAW,CAAC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAC/D,KAAK,mCAAY,CAAC,MAAM;gBACpB,OAAO,SAAS,CAAC,aAAa,CAAC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC1D,KAAK,mCAAY,CAAC,YAAY;gBAC1B,OAAO,SAAS,CAAC,kBAAkB,CAAC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC/D;gBACI,MAAM,IAAI,KAAK,CACX,iDAAiD,QAAQ,CAAC,IAAI,KAAK;oBACnE,wCAAwC,CAC3C,CAAC;QACV,CAAC;IACL,CAAC;IAEO,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,UAAkB,EAAE,OAAe;QAChE,MAAM,aAAa,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IACnF,CAAC;IAEO,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,GAAW,EAAE,OAAe;QAC3D,MAAM,OAAO,GAAG,iBAAiB,CAAC,OAAO,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG;;;;;0BAKG,GAAG;kCACK,OAAO;;;;;;;mBAOtB,CAAC;QAEZ,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,aAAa,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QACpE,IAAI,MAAM,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,oCAAoC,GAAG,EAAE,CAAC,CAAC;QAC/D,CAAC;IACL,CAAC;IAEO,MAAM,CAAC,KAAK,CAAC,kBAAkB,CAAC,GAAW,EAAE,OAAe;QAChE,MAAM,OAAO,GAAG,iBAAiB,CAAC,OAAO,CAAC,CAAC;QAC3C,mEAAmE;QACnE,iEAAiE;QACjE,mFAAmF;QACnF,MAAM,MAAM,GAAG;;;;;;wBAMC,GAAG;;;;;;;;;;;;;;;iBAeV,OAAO;;;;YAIZ,CAAC;QAEL,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,aAAa,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QACpE,IAAI,MAAM,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,sCAAsC,GAAG,EAAE,CAAC,CAAC;QACjE,CAAC;IACL,CAAC;CACJ;AAhGD,8BAgGC"}
|
package/dist/terminal/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/terminal/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/terminal/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACtD,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/terminal/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.TerminalFocusManager = void 0;
|
|
3
|
+
exports.TtyWriter = exports.TerminalType = exports.TerminalFocusManager = void 0;
|
|
4
4
|
var TerminalFocusManager_1 = require("./TerminalFocusManager");
|
|
5
5
|
Object.defineProperty(exports, "TerminalFocusManager", { enumerable: true, get: function () { return TerminalFocusManager_1.TerminalFocusManager; } });
|
|
6
|
+
var TerminalFocusManager_2 = require("./TerminalFocusManager");
|
|
7
|
+
Object.defineProperty(exports, "TerminalType", { enumerable: true, get: function () { return TerminalFocusManager_2.TerminalType; } });
|
|
8
|
+
var TtyWriter_1 = require("./TtyWriter");
|
|
9
|
+
Object.defineProperty(exports, "TtyWriter", { enumerable: true, get: function () { return TtyWriter_1.TtyWriter; } });
|
|
6
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/terminal/index.ts"],"names":[],"mappings":";;;AAAA,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/terminal/index.ts"],"names":[],"mappings":";;;AAAA,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,+DAAsD;AAA7C,oHAAA,YAAY,OAAA;AAErB,yCAAwC;AAA/B,sGAAA,SAAS,OAAA"}
|
package/package.json
CHANGED
|
@@ -127,7 +127,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
127
127
|
expect(agents[0].summary).toContain('Investigate failing tests in package');
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
it('should
|
|
130
|
+
it('should include process-only entry when process cwd has no matching session', async () => {
|
|
131
131
|
mockedListProcesses.mockReturnValue([
|
|
132
132
|
{
|
|
133
133
|
pid: 777,
|
|
@@ -148,7 +148,125 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
148
148
|
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]);
|
|
149
149
|
|
|
150
150
|
const agents = await adapter.detectAgents();
|
|
151
|
-
expect(agents).
|
|
151
|
+
expect(agents).toHaveLength(1);
|
|
152
|
+
expect(agents[0]).toMatchObject({
|
|
153
|
+
type: 'claude',
|
|
154
|
+
status: AgentStatus.RUNNING,
|
|
155
|
+
pid: 777,
|
|
156
|
+
projectPath: '/project/without-session',
|
|
157
|
+
sessionId: 'pid-777',
|
|
158
|
+
summary: 'Claude process running',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should match process in subdirectory to project-root session', async () => {
|
|
163
|
+
mockedListProcesses.mockReturnValue([
|
|
164
|
+
{
|
|
165
|
+
pid: 888,
|
|
166
|
+
command: 'claude',
|
|
167
|
+
cwd: '/Users/test/my-project/packages/cli',
|
|
168
|
+
tty: 'ttys009',
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
172
|
+
{
|
|
173
|
+
sessionId: 'session-3',
|
|
174
|
+
projectPath: '/Users/test/my-project',
|
|
175
|
+
sessionLogPath: '/mock/path/session-3.jsonl',
|
|
176
|
+
slug: 'gentle-otter',
|
|
177
|
+
lastEntry: { type: 'assistant' },
|
|
178
|
+
lastActive: new Date(),
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
|
|
182
|
+
{
|
|
183
|
+
display: 'Refactor CLI command flow',
|
|
184
|
+
timestamp: Date.now(),
|
|
185
|
+
project: '/Users/test/my-project',
|
|
186
|
+
sessionId: 'session-3',
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const agents = await adapter.detectAgents();
|
|
191
|
+
expect(agents).toHaveLength(1);
|
|
192
|
+
expect(agents[0]).toMatchObject({
|
|
193
|
+
type: 'claude',
|
|
194
|
+
pid: 888,
|
|
195
|
+
sessionId: 'session-3',
|
|
196
|
+
projectPath: '/Users/test/my-project',
|
|
197
|
+
summary: 'Refactor CLI command flow',
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should use latest history entry for process-only fallback session id', async () => {
|
|
202
|
+
mockedListProcesses.mockReturnValue([
|
|
203
|
+
{
|
|
204
|
+
pid: 97529,
|
|
205
|
+
command: 'claude',
|
|
206
|
+
cwd: '/Users/test/my-project/packages/cli',
|
|
207
|
+
tty: 'ttys021',
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
211
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
|
|
212
|
+
{
|
|
213
|
+
display: '/status',
|
|
214
|
+
timestamp: 1772122701536,
|
|
215
|
+
project: '/Users/test/my-project/packages/cli',
|
|
216
|
+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const agents = await adapter.detectAgents();
|
|
221
|
+
expect(agents).toHaveLength(1);
|
|
222
|
+
expect(agents[0]).toMatchObject({
|
|
223
|
+
type: 'claude',
|
|
224
|
+
pid: 97529,
|
|
225
|
+
projectPath: '/Users/test/my-project/packages/cli',
|
|
226
|
+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
|
|
227
|
+
summary: '/status',
|
|
228
|
+
status: AgentStatus.RUNNING,
|
|
229
|
+
});
|
|
230
|
+
expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should prefer exact-cwd history session over parent-project session match', async () => {
|
|
234
|
+
mockedListProcesses.mockReturnValue([
|
|
235
|
+
{
|
|
236
|
+
pid: 97529,
|
|
237
|
+
command: 'claude',
|
|
238
|
+
cwd: '/Users/test/my-project/packages/cli',
|
|
239
|
+
tty: 'ttys021',
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
243
|
+
{
|
|
244
|
+
sessionId: 'old-parent-session',
|
|
245
|
+
projectPath: '/Users/test/my-project',
|
|
246
|
+
sessionLogPath: '/mock/path/old-parent-session.jsonl',
|
|
247
|
+
slug: 'fluffy-brewing-kazoo',
|
|
248
|
+
lastEntry: { type: 'assistant' },
|
|
249
|
+
lastActive: new Date('2026-02-23T17:24:50.996Z'),
|
|
250
|
+
},
|
|
251
|
+
]);
|
|
252
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
|
|
253
|
+
{
|
|
254
|
+
display: '/status',
|
|
255
|
+
timestamp: 1772122701536,
|
|
256
|
+
project: '/Users/test/my-project/packages/cli',
|
|
257
|
+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
const agents = await adapter.detectAgents();
|
|
262
|
+
expect(agents).toHaveLength(1);
|
|
263
|
+
expect(agents[0]).toMatchObject({
|
|
264
|
+
type: 'claude',
|
|
265
|
+
pid: 97529,
|
|
266
|
+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
|
|
267
|
+
projectPath: '/Users/test/my-project/packages/cli',
|
|
268
|
+
summary: '/status',
|
|
269
|
+
});
|
|
152
270
|
});
|
|
153
271
|
});
|
|
154
272
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import { CodexAdapter } from '../../adapters/CodexAdapter';
|
|
3
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
4
|
+
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
5
|
+
import { listProcesses } from '../../utils/process';
|
|
6
|
+
|
|
7
|
+
jest.mock('../../utils/process', () => ({
|
|
8
|
+
listProcesses: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const mockedListProcesses = listProcesses as jest.MockedFunction<typeof listProcesses>;
|
|
12
|
+
|
|
13
|
+
interface MockSession {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
projectPath: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
sessionStart?: Date;
|
|
18
|
+
lastActive: Date;
|
|
19
|
+
lastPayloadType?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('CodexAdapter', () => {
|
|
23
|
+
let adapter: CodexAdapter;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
adapter = new CodexAdapter();
|
|
27
|
+
mockedListProcesses.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should expose codex type', () => {
|
|
31
|
+
expect(adapter.type).toBe('codex');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should match codex commands in canHandle', () => {
|
|
35
|
+
expect(
|
|
36
|
+
adapter.canHandle({
|
|
37
|
+
pid: 1,
|
|
38
|
+
command: 'codex',
|
|
39
|
+
cwd: '/repo',
|
|
40
|
+
tty: 'ttys001',
|
|
41
|
+
}),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
adapter.canHandle({
|
|
46
|
+
pid: 2,
|
|
47
|
+
command: '/usr/local/bin/CODEX --sandbox workspace-write',
|
|
48
|
+
cwd: '/repo',
|
|
49
|
+
tty: 'ttys002',
|
|
50
|
+
}),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
|
|
53
|
+
expect(
|
|
54
|
+
adapter.canHandle({
|
|
55
|
+
pid: 4,
|
|
56
|
+
command: 'node /worktrees/feature-codex-adapter-agent-manager-package/node_modules/nx/src/daemon/server/start.js',
|
|
57
|
+
cwd: '/repo',
|
|
58
|
+
tty: 'ttys004',
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
adapter.canHandle({
|
|
64
|
+
pid: 3,
|
|
65
|
+
command: 'node app.js',
|
|
66
|
+
cwd: '/repo',
|
|
67
|
+
tty: 'ttys003',
|
|
68
|
+
}),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return empty list when no codex process is running', async () => {
|
|
73
|
+
mockedListProcesses.mockReturnValue([]);
|
|
74
|
+
|
|
75
|
+
const agents = await adapter.detectAgents();
|
|
76
|
+
expect(agents).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should map active codex sessions to matching processes by cwd', async () => {
|
|
80
|
+
mockedListProcesses.mockReturnValue([
|
|
81
|
+
{ pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001' },
|
|
82
|
+
] as ProcessInfo[]);
|
|
83
|
+
|
|
84
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
85
|
+
{
|
|
86
|
+
sessionId: 'abc12345-session',
|
|
87
|
+
projectPath: '/repo-a',
|
|
88
|
+
summary: 'Implement adapter flow',
|
|
89
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
90
|
+
lastActive: new Date(),
|
|
91
|
+
lastPayloadType: 'token_count',
|
|
92
|
+
} as MockSession,
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const agents = await adapter.detectAgents();
|
|
96
|
+
expect(agents).toHaveLength(1);
|
|
97
|
+
expect(agents[0]).toMatchObject({
|
|
98
|
+
name: 'repo-a',
|
|
99
|
+
type: 'codex',
|
|
100
|
+
status: AgentStatus.RUNNING,
|
|
101
|
+
summary: 'Implement adapter flow',
|
|
102
|
+
pid: 100,
|
|
103
|
+
projectPath: '/repo-a',
|
|
104
|
+
sessionId: 'abc12345-session',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should still map sessions with task_complete as waiting when process is running', async () => {
|
|
109
|
+
mockedListProcesses.mockReturnValue([
|
|
110
|
+
{ pid: 101, command: 'codex', cwd: '/repo-b', tty: 'ttys001' },
|
|
111
|
+
] as ProcessInfo[]);
|
|
112
|
+
|
|
113
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
114
|
+
{
|
|
115
|
+
sessionId: 'ended-1111',
|
|
116
|
+
projectPath: '/repo-b',
|
|
117
|
+
summary: 'Ended turn but process still alive',
|
|
118
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
119
|
+
lastActive: new Date(),
|
|
120
|
+
lastPayloadType: 'task_complete',
|
|
121
|
+
} as MockSession,
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const agents = await adapter.detectAgents();
|
|
125
|
+
expect(agents).toHaveLength(1);
|
|
126
|
+
expect(agents[0].sessionId).toBe('ended-1111');
|
|
127
|
+
expect(agents[0].status).toBe(AgentStatus.WAITING);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should use codex-session-id-prefix fallback name when cwd is missing', async () => {
|
|
131
|
+
mockedListProcesses.mockReturnValue([
|
|
132
|
+
{ pid: 102, command: 'codex', cwd: '', tty: 'ttys009' },
|
|
133
|
+
] as ProcessInfo[]);
|
|
134
|
+
|
|
135
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
136
|
+
{
|
|
137
|
+
sessionId: 'abcdef123456',
|
|
138
|
+
projectPath: '',
|
|
139
|
+
summary: 'No cwd available',
|
|
140
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
141
|
+
lastActive: new Date(),
|
|
142
|
+
lastPayloadType: 'agent_reasoning',
|
|
143
|
+
} as MockSession,
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const agents = await adapter.detectAgents();
|
|
147
|
+
expect(agents).toHaveLength(1);
|
|
148
|
+
expect(agents[0].name).toBe('codex-abcdef12');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should report waiting status for recent agent_message events', async () => {
|
|
152
|
+
mockedListProcesses.mockReturnValue([
|
|
153
|
+
{ pid: 103, command: 'codex', cwd: '/repo-c', tty: 'ttys010' },
|
|
154
|
+
] as ProcessInfo[]);
|
|
155
|
+
|
|
156
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
157
|
+
{
|
|
158
|
+
sessionId: 'waiting-1234',
|
|
159
|
+
projectPath: '/repo-c',
|
|
160
|
+
summary: 'Waiting',
|
|
161
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
162
|
+
lastActive: new Date(),
|
|
163
|
+
lastPayloadType: 'agent_message',
|
|
164
|
+
} as MockSession,
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const agents = await adapter.detectAgents();
|
|
168
|
+
expect(agents).toHaveLength(1);
|
|
169
|
+
expect(agents[0].status).toBe(AgentStatus.WAITING);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should report idle status when session exceeds shared threshold', async () => {
|
|
173
|
+
mockedListProcesses.mockReturnValue([
|
|
174
|
+
{ pid: 104, command: 'codex', cwd: '/repo-d', tty: 'ttys011' },
|
|
175
|
+
] as ProcessInfo[]);
|
|
176
|
+
|
|
177
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
178
|
+
{
|
|
179
|
+
sessionId: 'idle-5678',
|
|
180
|
+
projectPath: '/repo-d',
|
|
181
|
+
summary: 'Idle',
|
|
182
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
183
|
+
lastActive: new Date(Date.now() - 10 * 60 * 1000),
|
|
184
|
+
lastPayloadType: 'token_count',
|
|
185
|
+
} as MockSession,
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
const agents = await adapter.detectAgents();
|
|
189
|
+
expect(agents).toHaveLength(1);
|
|
190
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should list unmatched running codex process even when no session matches', async () => {
|
|
194
|
+
mockedListProcesses.mockReturnValue([
|
|
195
|
+
{ pid: 105, command: 'codex', cwd: '/repo-x', tty: 'ttys012' },
|
|
196
|
+
] as ProcessInfo[]);
|
|
197
|
+
|
|
198
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
199
|
+
{
|
|
200
|
+
sessionId: 'other-session',
|
|
201
|
+
projectPath: '/repo-y',
|
|
202
|
+
summary: 'Other repo',
|
|
203
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
204
|
+
lastActive: new Date(),
|
|
205
|
+
lastPayloadType: 'agent_message',
|
|
206
|
+
} as MockSession,
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const agents = await adapter.detectAgents();
|
|
210
|
+
expect(agents).toHaveLength(1);
|
|
211
|
+
expect(agents[0].pid).toBe(105);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should list process when session metadata is unavailable', async () => {
|
|
215
|
+
mockedListProcesses.mockReturnValue([
|
|
216
|
+
{ pid: 106, command: 'codex', cwd: '/repo-z', tty: 'ttys013' },
|
|
217
|
+
] as ProcessInfo[]);
|
|
218
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([]);
|
|
219
|
+
|
|
220
|
+
const agents = await adapter.detectAgents();
|
|
221
|
+
expect(agents).toHaveLength(1);
|
|
222
|
+
expect(agents[0].pid).toBe(106);
|
|
223
|
+
expect(agents[0].summary).toContain('No Codex session metadata');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should choose same-cwd session closest to process start time', async () => {
|
|
227
|
+
mockedListProcesses.mockReturnValue([
|
|
228
|
+
{ pid: 107, command: 'codex', cwd: '/repo-time', tty: 'ttys014' },
|
|
229
|
+
] as ProcessInfo[]);
|
|
230
|
+
|
|
231
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
232
|
+
{
|
|
233
|
+
sessionId: 'far-session',
|
|
234
|
+
projectPath: '/repo-time',
|
|
235
|
+
summary: 'Far start time',
|
|
236
|
+
sessionStart: new Date('2026-02-26T14:00:00.000Z'),
|
|
237
|
+
lastActive: new Date('2026-02-26T15:10:00.000Z'),
|
|
238
|
+
lastPayloadType: 'agent_message',
|
|
239
|
+
} as MockSession,
|
|
240
|
+
{
|
|
241
|
+
sessionId: 'near-session',
|
|
242
|
+
projectPath: '/repo-time',
|
|
243
|
+
summary: 'Near start time',
|
|
244
|
+
sessionStart: new Date('2026-02-26T15:00:20.000Z'),
|
|
245
|
+
lastActive: new Date('2026-02-26T15:11:00.000Z'),
|
|
246
|
+
lastPayloadType: 'agent_message',
|
|
247
|
+
} as MockSession,
|
|
248
|
+
]);
|
|
249
|
+
jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue(
|
|
250
|
+
new Map([[107, new Date('2026-02-26T15:00:00.000Z')]]),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const agents = await adapter.detectAgents();
|
|
254
|
+
expect(agents).toHaveLength(1);
|
|
255
|
+
expect(agents[0].sessionId).toBe('near-session');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should prefer missing-cwd session before any-session fallback for unmatched process', async () => {
|
|
259
|
+
mockedListProcesses.mockReturnValue([
|
|
260
|
+
{ pid: 108, command: 'codex', cwd: '/repo-missing-cwd', tty: 'ttys015' },
|
|
261
|
+
] as ProcessInfo[]);
|
|
262
|
+
|
|
263
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
264
|
+
{
|
|
265
|
+
sessionId: 'any-session',
|
|
266
|
+
projectPath: '/another-repo',
|
|
267
|
+
summary: 'Any session fallback',
|
|
268
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
269
|
+
lastActive: new Date('2026-02-26T15:12:00.000Z'),
|
|
270
|
+
lastPayloadType: 'agent_message',
|
|
271
|
+
} as MockSession,
|
|
272
|
+
{
|
|
273
|
+
sessionId: 'missing-cwd-session',
|
|
274
|
+
projectPath: '',
|
|
275
|
+
summary: 'Missing cwd session',
|
|
276
|
+
sessionStart: new Date('2026-02-26T15:00:10.000Z'),
|
|
277
|
+
lastActive: new Date('2026-02-26T15:11:00.000Z'),
|
|
278
|
+
lastPayloadType: 'agent_message',
|
|
279
|
+
} as MockSession,
|
|
280
|
+
]);
|
|
281
|
+
jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue(
|
|
282
|
+
new Map([[108, new Date('2026-02-26T15:00:00.000Z')]]),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const agents = await adapter.detectAgents();
|
|
286
|
+
expect(agents).toHaveLength(1);
|
|
287
|
+
expect(agents[0].sessionId).toBe('missing-cwd-session');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should not reuse the same session for multiple running processes', async () => {
|
|
291
|
+
mockedListProcesses.mockReturnValue([
|
|
292
|
+
{ pid: 109, command: 'codex', cwd: '/repo-shared', tty: 'ttys016' },
|
|
293
|
+
{ pid: 110, command: 'codex', cwd: '/repo-shared', tty: 'ttys017' },
|
|
294
|
+
] as ProcessInfo[]);
|
|
295
|
+
|
|
296
|
+
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
297
|
+
{
|
|
298
|
+
sessionId: 'shared-session',
|
|
299
|
+
projectPath: '/repo-shared',
|
|
300
|
+
summary: 'Only one session exists',
|
|
301
|
+
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
302
|
+
lastActive: new Date('2026-02-26T15:11:00.000Z'),
|
|
303
|
+
lastPayloadType: 'agent_message',
|
|
304
|
+
} as MockSession,
|
|
305
|
+
]);
|
|
306
|
+
jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue(
|
|
307
|
+
new Map([
|
|
308
|
+
[109, new Date('2026-02-26T15:00:00.000Z')],
|
|
309
|
+
[110, new Date('2026-02-26T15:00:30.000Z')],
|
|
310
|
+
]),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const agents = await adapter.detectAgents();
|
|
314
|
+
expect(agents).toHaveLength(2);
|
|
315
|
+
const mappedAgents = agents.filter((agent) => agent.sessionId === 'shared-session');
|
|
316
|
+
expect(mappedAgents).toHaveLength(1);
|
|
317
|
+
expect(agents.some((agent) => agent.sessionId.startsWith('pid-'))).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
});
|