@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,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex Adapter
|
|
3
|
+
*
|
|
4
|
+
* Detects running Codex agents by combining:
|
|
5
|
+
* 1. Running `codex` processes
|
|
6
|
+
* 2. Session metadata under ~/.codex/sessions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
13
|
+
import { AgentStatus } from './AgentAdapter';
|
|
14
|
+
import { listProcesses } from '../utils/process';
|
|
15
|
+
import { readJsonLines } from '../utils/file';
|
|
16
|
+
|
|
17
|
+
interface CodexSessionMetaPayload {
|
|
18
|
+
id?: string;
|
|
19
|
+
timestamp?: string;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CodexSessionMetaEntry {
|
|
24
|
+
type?: string;
|
|
25
|
+
payload?: CodexSessionMetaPayload;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CodexEventEntry {
|
|
29
|
+
timestamp?: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
payload?: {
|
|
32
|
+
type?: string;
|
|
33
|
+
message?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CodexSession {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
projectPath: string;
|
|
40
|
+
summary: string;
|
|
41
|
+
sessionStart: Date;
|
|
42
|
+
lastActive: Date;
|
|
43
|
+
lastPayloadType?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SessionMatchMode = 'cwd' | 'missing-cwd' | 'any';
|
|
47
|
+
|
|
48
|
+
export class CodexAdapter implements AgentAdapter {
|
|
49
|
+
readonly type = 'codex' as const;
|
|
50
|
+
|
|
51
|
+
/** Keep status thresholds aligned across adapters. */
|
|
52
|
+
private static readonly IDLE_THRESHOLD_MINUTES = 5;
|
|
53
|
+
/** Limit session parsing per run to keep list latency bounded. */
|
|
54
|
+
private static readonly MIN_SESSION_SCAN = 12;
|
|
55
|
+
private static readonly MAX_SESSION_SCAN = 40;
|
|
56
|
+
private static readonly SESSION_SCAN_MULTIPLIER = 4;
|
|
57
|
+
/** Also include session files around process start day to recover long-lived processes. */
|
|
58
|
+
private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1;
|
|
59
|
+
/** Matching tolerance between process start time and session start time. */
|
|
60
|
+
private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
private codexSessionsDir: string;
|
|
63
|
+
|
|
64
|
+
constructor() {
|
|
65
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
66
|
+
this.codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
70
|
+
return this.isCodexExecutable(processInfo.command);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async detectAgents(): Promise<AgentInfo[]> {
|
|
74
|
+
const codexProcesses = this.listCodexProcesses();
|
|
75
|
+
|
|
76
|
+
if (codexProcesses.length === 0) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const processStartByPid = this.getProcessStartTimes(codexProcesses.map((processInfo) => processInfo.pid));
|
|
81
|
+
|
|
82
|
+
const sessionScanLimit = this.calculateSessionScanLimit(codexProcesses.length);
|
|
83
|
+
const sessions = this.readSessions(sessionScanLimit, processStartByPid);
|
|
84
|
+
if (sessions.length === 0) {
|
|
85
|
+
return codexProcesses.map((processInfo) =>
|
|
86
|
+
this.mapProcessOnlyAgent(processInfo, [], 'No Codex session metadata found'),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sortedSessions = [...sessions].sort(
|
|
91
|
+
(a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
|
|
92
|
+
);
|
|
93
|
+
const usedSessionIds = new Set<string>();
|
|
94
|
+
const assignedPids = new Set<number>();
|
|
95
|
+
const agents: AgentInfo[] = [];
|
|
96
|
+
|
|
97
|
+
// Match exact cwd first, then missing-cwd sessions, then any available session.
|
|
98
|
+
this.assignSessionsForMode(
|
|
99
|
+
'cwd',
|
|
100
|
+
codexProcesses,
|
|
101
|
+
sortedSessions,
|
|
102
|
+
usedSessionIds,
|
|
103
|
+
assignedPids,
|
|
104
|
+
processStartByPid,
|
|
105
|
+
agents,
|
|
106
|
+
);
|
|
107
|
+
this.assignSessionsForMode(
|
|
108
|
+
'missing-cwd',
|
|
109
|
+
codexProcesses,
|
|
110
|
+
sortedSessions,
|
|
111
|
+
usedSessionIds,
|
|
112
|
+
assignedPids,
|
|
113
|
+
processStartByPid,
|
|
114
|
+
agents,
|
|
115
|
+
);
|
|
116
|
+
this.assignSessionsForMode(
|
|
117
|
+
'any',
|
|
118
|
+
codexProcesses,
|
|
119
|
+
sortedSessions,
|
|
120
|
+
usedSessionIds,
|
|
121
|
+
assignedPids,
|
|
122
|
+
processStartByPid,
|
|
123
|
+
agents,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Every running codex process should still be listed.
|
|
127
|
+
for (const processInfo of codexProcesses) {
|
|
128
|
+
if (assignedPids.has(processInfo.pid)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.addProcessOnlyAgent(processInfo, assignedPids, agents);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return agents;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private listCodexProcesses(): ProcessInfo[] {
|
|
139
|
+
return listProcesses({ namePattern: 'codex' }).filter((processInfo) =>
|
|
140
|
+
this.canHandle(processInfo),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private calculateSessionScanLimit(processCount: number): number {
|
|
145
|
+
return Math.min(
|
|
146
|
+
Math.max(
|
|
147
|
+
processCount * CodexAdapter.SESSION_SCAN_MULTIPLIER,
|
|
148
|
+
CodexAdapter.MIN_SESSION_SCAN,
|
|
149
|
+
),
|
|
150
|
+
CodexAdapter.MAX_SESSION_SCAN,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private assignSessionsForMode(
|
|
155
|
+
mode: SessionMatchMode,
|
|
156
|
+
codexProcesses: ProcessInfo[],
|
|
157
|
+
sessions: CodexSession[],
|
|
158
|
+
usedSessionIds: Set<string>,
|
|
159
|
+
assignedPids: Set<number>,
|
|
160
|
+
processStartByPid: Map<number, Date>,
|
|
161
|
+
agents: AgentInfo[],
|
|
162
|
+
): void {
|
|
163
|
+
for (const processInfo of codexProcesses) {
|
|
164
|
+
if (assignedPids.has(processInfo.pid)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const session = this.selectBestSession(
|
|
169
|
+
processInfo,
|
|
170
|
+
sessions,
|
|
171
|
+
usedSessionIds,
|
|
172
|
+
processStartByPid,
|
|
173
|
+
mode,
|
|
174
|
+
);
|
|
175
|
+
if (!session) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.addMappedSessionAgent(session, processInfo, usedSessionIds, assignedPids, agents);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private addMappedSessionAgent(
|
|
184
|
+
session: CodexSession,
|
|
185
|
+
processInfo: ProcessInfo,
|
|
186
|
+
usedSessionIds: Set<string>,
|
|
187
|
+
assignedPids: Set<number>,
|
|
188
|
+
agents: AgentInfo[],
|
|
189
|
+
): void {
|
|
190
|
+
usedSessionIds.add(session.sessionId);
|
|
191
|
+
assignedPids.add(processInfo.pid);
|
|
192
|
+
agents.push(this.mapSessionToAgent(session, processInfo, agents));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private addProcessOnlyAgent(
|
|
196
|
+
processInfo: ProcessInfo,
|
|
197
|
+
assignedPids: Set<number>,
|
|
198
|
+
agents: AgentInfo[],
|
|
199
|
+
): void {
|
|
200
|
+
assignedPids.add(processInfo.pid);
|
|
201
|
+
agents.push(this.mapProcessOnlyAgent(processInfo, agents));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private mapSessionToAgent(
|
|
205
|
+
session: CodexSession,
|
|
206
|
+
processInfo: ProcessInfo,
|
|
207
|
+
existingAgents: AgentInfo[],
|
|
208
|
+
): AgentInfo {
|
|
209
|
+
return {
|
|
210
|
+
name: this.generateAgentName(session, existingAgents),
|
|
211
|
+
type: this.type,
|
|
212
|
+
status: this.determineStatus(session),
|
|
213
|
+
summary: session.summary || 'Codex session active',
|
|
214
|
+
pid: processInfo.pid,
|
|
215
|
+
projectPath: session.projectPath || processInfo.cwd || '',
|
|
216
|
+
sessionId: session.sessionId,
|
|
217
|
+
lastActive: session.lastActive,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private mapProcessOnlyAgent(
|
|
222
|
+
processInfo: ProcessInfo,
|
|
223
|
+
existingAgents: AgentInfo[],
|
|
224
|
+
summary: string = 'Codex process running',
|
|
225
|
+
): AgentInfo {
|
|
226
|
+
const syntheticSession: CodexSession = {
|
|
227
|
+
sessionId: `pid-${processInfo.pid}`,
|
|
228
|
+
projectPath: processInfo.cwd || '',
|
|
229
|
+
summary,
|
|
230
|
+
sessionStart: new Date(),
|
|
231
|
+
lastActive: new Date(),
|
|
232
|
+
lastPayloadType: 'process_only',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
name: this.generateAgentName(syntheticSession, existingAgents),
|
|
237
|
+
type: this.type,
|
|
238
|
+
status: AgentStatus.RUNNING,
|
|
239
|
+
summary,
|
|
240
|
+
pid: processInfo.pid,
|
|
241
|
+
projectPath: processInfo.cwd || '',
|
|
242
|
+
sessionId: syntheticSession.sessionId,
|
|
243
|
+
lastActive: syntheticSession.lastActive,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private readSessions(limit: number, processStartByPid: Map<number, Date>): CodexSession[] {
|
|
248
|
+
const sessionFiles = this.findSessionFiles(limit, processStartByPid);
|
|
249
|
+
const sessions: CodexSession[] = [];
|
|
250
|
+
|
|
251
|
+
for (const sessionFile of sessionFiles) {
|
|
252
|
+
try {
|
|
253
|
+
const session = this.readSession(sessionFile);
|
|
254
|
+
if (session) {
|
|
255
|
+
sessions.push(session);
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error(`Failed to parse Codex session ${sessionFile}:`, error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return sessions;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private findSessionFiles(limit: number, processStartByPid: Map<number, Date>): string[] {
|
|
266
|
+
if (!fs.existsSync(this.codexSessionsDir)) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const files: Array<{ path: string; mtimeMs: number }> = [];
|
|
271
|
+
const stack: string[] = [this.codexSessionsDir];
|
|
272
|
+
|
|
273
|
+
while (stack.length > 0) {
|
|
274
|
+
const currentDir = stack.pop();
|
|
275
|
+
if (!currentDir || !fs.existsSync(currentDir)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
280
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
281
|
+
if (entry.isDirectory()) {
|
|
282
|
+
stack.push(fullPath);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
287
|
+
try {
|
|
288
|
+
files.push({
|
|
289
|
+
path: fullPath,
|
|
290
|
+
mtimeMs: fs.statSync(fullPath).mtimeMs,
|
|
291
|
+
});
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const recentFiles = files
|
|
300
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
301
|
+
.slice(0, limit)
|
|
302
|
+
.map((file) => file.path);
|
|
303
|
+
const processDayFiles = this.findProcessDaySessionFiles(processStartByPid);
|
|
304
|
+
|
|
305
|
+
const selectedPaths = new Set(recentFiles);
|
|
306
|
+
for (const processDayFile of processDayFiles) {
|
|
307
|
+
selectedPaths.add(processDayFile);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Array.from(selectedPaths);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private findProcessDaySessionFiles(processStartByPid: Map<number, Date>): string[] {
|
|
314
|
+
const files: string[] = [];
|
|
315
|
+
const dayKeys = new Set<string>();
|
|
316
|
+
const dayWindow = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS;
|
|
317
|
+
|
|
318
|
+
for (const processStart of processStartByPid.values()) {
|
|
319
|
+
for (let offset = -dayWindow; offset <= dayWindow; offset++) {
|
|
320
|
+
const day = new Date(processStart.getTime());
|
|
321
|
+
day.setDate(day.getDate() + offset);
|
|
322
|
+
dayKeys.add(this.toSessionDayKey(day));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const dayKey of dayKeys) {
|
|
327
|
+
const dayDir = path.join(this.codexSessionsDir, dayKey);
|
|
328
|
+
if (!fs.existsSync(dayDir)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const entry of fs.readdirSync(dayDir, { withFileTypes: true })) {
|
|
333
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
334
|
+
files.push(path.join(dayDir, entry.name));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return files;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private toSessionDayKey(date: Date): string {
|
|
343
|
+
const yyyy = String(date.getFullYear()).padStart(4, '0');
|
|
344
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
345
|
+
const dd = String(date.getDate()).padStart(2, '0');
|
|
346
|
+
return path.join(yyyy, mm, dd);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private readSession(filePath: string): CodexSession | null {
|
|
350
|
+
const firstLine = this.readFirstLine(filePath);
|
|
351
|
+
if (!firstLine) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const metaEntry = this.parseSessionMeta(firstLine);
|
|
356
|
+
if (!metaEntry?.payload?.id) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const entries = readJsonLines<CodexEventEntry>(filePath, 300);
|
|
361
|
+
const lastEntry = this.findLastEventEntry(entries);
|
|
362
|
+
const lastPayloadType = lastEntry?.payload?.type;
|
|
363
|
+
|
|
364
|
+
const lastActive =
|
|
365
|
+
this.parseTimestamp(lastEntry?.timestamp) ||
|
|
366
|
+
this.parseTimestamp(metaEntry.payload.timestamp) ||
|
|
367
|
+
fs.statSync(filePath).mtime;
|
|
368
|
+
const sessionStart =
|
|
369
|
+
this.parseTimestamp(metaEntry.payload.timestamp) ||
|
|
370
|
+
lastActive;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
sessionId: metaEntry.payload.id,
|
|
374
|
+
projectPath: metaEntry.payload.cwd || '',
|
|
375
|
+
summary: this.extractSummary(entries),
|
|
376
|
+
sessionStart,
|
|
377
|
+
lastActive,
|
|
378
|
+
lastPayloadType,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private readFirstLine(filePath: string): string {
|
|
383
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
384
|
+
return content.split('\n')[0]?.trim() || '';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private parseSessionMeta(line: string): CodexSessionMetaEntry | null {
|
|
388
|
+
try {
|
|
389
|
+
const parsed = JSON.parse(line) as CodexSessionMetaEntry;
|
|
390
|
+
if (parsed.type !== 'session_meta') {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return parsed;
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private findLastEventEntry(entries: CodexEventEntry[]): CodexEventEntry | undefined {
|
|
400
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
401
|
+
const entry = entries[i];
|
|
402
|
+
if (entry && typeof entry.type === 'string') {
|
|
403
|
+
return entry;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private parseTimestamp(value?: string): Date | null {
|
|
410
|
+
if (!value) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const timestamp = new Date(value);
|
|
415
|
+
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private selectBestSession(
|
|
419
|
+
processInfo: ProcessInfo,
|
|
420
|
+
sessions: CodexSession[],
|
|
421
|
+
usedSessionIds: Set<string>,
|
|
422
|
+
processStartByPid: Map<number, Date>,
|
|
423
|
+
mode: SessionMatchMode,
|
|
424
|
+
): CodexSession | undefined {
|
|
425
|
+
const candidates = this.filterCandidateSessions(processInfo, sessions, usedSessionIds, mode);
|
|
426
|
+
|
|
427
|
+
if (candidates.length === 0) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const processStart = processStartByPid.get(processInfo.pid);
|
|
432
|
+
if (!processStart) {
|
|
433
|
+
return candidates.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime())[0];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return this.rankCandidatesByStartTime(candidates, processStart)[0];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private filterCandidateSessions(
|
|
440
|
+
processInfo: ProcessInfo,
|
|
441
|
+
sessions: CodexSession[],
|
|
442
|
+
usedSessionIds: Set<string>,
|
|
443
|
+
mode: SessionMatchMode,
|
|
444
|
+
): CodexSession[] {
|
|
445
|
+
return sessions.filter((session) => {
|
|
446
|
+
if (usedSessionIds.has(session.sessionId)) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (mode === 'cwd') {
|
|
451
|
+
return session.projectPath === processInfo.cwd;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (mode === 'missing-cwd') {
|
|
455
|
+
return !session.projectPath;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return true;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private rankCandidatesByStartTime(candidates: CodexSession[], processStart: Date): CodexSession[] {
|
|
463
|
+
const toleranceMs = CodexAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS;
|
|
464
|
+
|
|
465
|
+
return candidates
|
|
466
|
+
.map((session) => {
|
|
467
|
+
const diffMs = Math.abs(session.sessionStart.getTime() - processStart.getTime());
|
|
468
|
+
const outsideTolerance = diffMs > toleranceMs ? 1 : 0;
|
|
469
|
+
return {
|
|
470
|
+
session,
|
|
471
|
+
rank: outsideTolerance,
|
|
472
|
+
diffMs,
|
|
473
|
+
recency: session.lastActive.getTime(),
|
|
474
|
+
};
|
|
475
|
+
})
|
|
476
|
+
.sort((a, b) => {
|
|
477
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
478
|
+
if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs;
|
|
479
|
+
return b.recency - a.recency;
|
|
480
|
+
})
|
|
481
|
+
.map((ranked) => ranked.session);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private getProcessStartTimes(pids: number[]): Map<number, Date> {
|
|
485
|
+
if (pids.length === 0 || process.env.JEST_WORKER_ID) {
|
|
486
|
+
return new Map();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const output = execSync(`ps -o pid=,etime= -p ${pids.join(',')}`, {
|
|
491
|
+
encoding: 'utf-8',
|
|
492
|
+
});
|
|
493
|
+
const nowMs = Date.now();
|
|
494
|
+
const startTimes = new Map<number, Date>();
|
|
495
|
+
|
|
496
|
+
for (const rawLine of output.split('\n')) {
|
|
497
|
+
const line = rawLine.trim();
|
|
498
|
+
if (!line) continue;
|
|
499
|
+
|
|
500
|
+
const parts = line.split(/\s+/);
|
|
501
|
+
if (parts.length < 2) continue;
|
|
502
|
+
|
|
503
|
+
const pid = Number.parseInt(parts[0], 10);
|
|
504
|
+
const elapsedSeconds = this.parseElapsedSeconds(parts[1]);
|
|
505
|
+
if (!Number.isFinite(pid) || elapsedSeconds === null) continue;
|
|
506
|
+
|
|
507
|
+
startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return startTimes;
|
|
511
|
+
} catch {
|
|
512
|
+
return new Map();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private parseElapsedSeconds(etime: string): number | null {
|
|
517
|
+
const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
|
|
518
|
+
if (!match) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const days = Number.parseInt(match[1] || '0', 10);
|
|
523
|
+
const hours = Number.parseInt(match[2] || '0', 10);
|
|
524
|
+
const minutes = Number.parseInt(match[3] || '0', 10);
|
|
525
|
+
const seconds = Number.parseInt(match[4] || '0', 10);
|
|
526
|
+
|
|
527
|
+
return (((days * 24 + hours) * 60 + minutes) * 60) + seconds;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private extractSummary(entries: CodexEventEntry[]): string {
|
|
531
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
532
|
+
const message = entries[i]?.payload?.message;
|
|
533
|
+
if (typeof message === 'string' && message.trim().length > 0) {
|
|
534
|
+
return this.truncate(message.trim(), 120);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return 'Codex session active';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private truncate(value: string, maxLength: number): string {
|
|
542
|
+
if (value.length <= maxLength) {
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private isCodexExecutable(command: string): boolean {
|
|
549
|
+
const executable = command.trim().split(/\s+/)[0] || '';
|
|
550
|
+
const base = path.basename(executable).toLowerCase();
|
|
551
|
+
return base === 'codex' || base === 'codex.exe';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private determineStatus(session: CodexSession): AgentStatus {
|
|
555
|
+
const diffMs = Date.now() - session.lastActive.getTime();
|
|
556
|
+
const diffMinutes = diffMs / 60000;
|
|
557
|
+
|
|
558
|
+
if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) {
|
|
559
|
+
return AgentStatus.IDLE;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (
|
|
563
|
+
session.lastPayloadType === 'agent_message' ||
|
|
564
|
+
session.lastPayloadType === 'task_complete' ||
|
|
565
|
+
session.lastPayloadType === 'turn_aborted'
|
|
566
|
+
) {
|
|
567
|
+
return AgentStatus.WAITING;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return AgentStatus.RUNNING;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private generateAgentName(session: CodexSession, existingAgents: AgentInfo[]): string {
|
|
574
|
+
const fallback = `codex-${session.sessionId.slice(0, 8)}`;
|
|
575
|
+
const baseName = session.projectPath ? path.basename(path.normalize(session.projectPath)) : fallback;
|
|
576
|
+
|
|
577
|
+
const conflict = existingAgents.some((agent) => agent.name === baseName);
|
|
578
|
+
if (!conflict) {
|
|
579
|
+
return baseName || fallback;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return `${baseName || fallback} (${session.sessionId.slice(0, 8)})`;
|
|
583
|
+
}
|
|
584
|
+
}
|
package/src/adapters/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { AgentManager } from './AgentManager';
|
|
2
2
|
|
|
3
3
|
export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter';
|
|
4
|
+
export { CodexAdapter } from './adapters/CodexAdapter';
|
|
4
5
|
export { AgentStatus } from './adapters/AgentAdapter';
|
|
5
6
|
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter';
|
|
6
7
|
|
|
7
|
-
export { TerminalFocusManager } from './terminal/TerminalFocusManager';
|
|
8
|
+
export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager';
|
|
8
9
|
export type { TerminalLocation } from './terminal/TerminalFocusManager';
|
|
10
|
+
export { TtyWriter } from './terminal/TtyWriter';
|
|
9
11
|
|
|
10
12
|
export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process';
|
|
11
13
|
export type { ListProcessesOptions } from './utils/process';
|
|
@@ -5,8 +5,15 @@ import { getProcessTty } from '../utils/process';
|
|
|
5
5
|
const execAsync = promisify(exec);
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
7
|
|
|
8
|
+
export enum TerminalType {
|
|
9
|
+
TMUX = 'tmux',
|
|
10
|
+
ITERM2 = 'iterm2',
|
|
11
|
+
TERMINAL_APP = 'terminal-app',
|
|
12
|
+
UNKNOWN = 'unknown',
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export interface TerminalLocation {
|
|
9
|
-
type:
|
|
16
|
+
type: TerminalType;
|
|
10
17
|
identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others
|
|
11
18
|
tty: string; // e.g., "/dev/ttys030"
|
|
12
19
|
}
|
|
@@ -39,7 +46,7 @@ export class TerminalFocusManager {
|
|
|
39
46
|
|
|
40
47
|
// 4. Fallback: we know the TTY but not the emulator wrapper
|
|
41
48
|
return {
|
|
42
|
-
type:
|
|
49
|
+
type: TerminalType.UNKNOWN,
|
|
43
50
|
identifier: '',
|
|
44
51
|
tty: fullTty
|
|
45
52
|
};
|
|
@@ -51,11 +58,11 @@ export class TerminalFocusManager {
|
|
|
51
58
|
async focusTerminal(location: TerminalLocation): Promise<boolean> {
|
|
52
59
|
try {
|
|
53
60
|
switch (location.type) {
|
|
54
|
-
case
|
|
61
|
+
case TerminalType.TMUX:
|
|
55
62
|
return await this.focusTmuxPane(location.identifier);
|
|
56
|
-
case
|
|
63
|
+
case TerminalType.ITERM2:
|
|
57
64
|
return await this.focusITerm2Session(location.tty);
|
|
58
|
-
case
|
|
65
|
+
case TerminalType.TERMINAL_APP:
|
|
59
66
|
return await this.focusTerminalAppWindow(location.tty);
|
|
60
67
|
default:
|
|
61
68
|
return false;
|
|
@@ -78,7 +85,7 @@ export class TerminalFocusManager {
|
|
|
78
85
|
const [paneTty, identifier] = line.split('|');
|
|
79
86
|
if (paneTty === tty && identifier) {
|
|
80
87
|
return {
|
|
81
|
-
type:
|
|
88
|
+
type: TerminalType.TMUX,
|
|
82
89
|
identifier,
|
|
83
90
|
tty
|
|
84
91
|
};
|
|
@@ -113,7 +120,7 @@ export class TerminalFocusManager {
|
|
|
113
120
|
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
114
121
|
if (stdout.trim() === "found") {
|
|
115
122
|
return {
|
|
116
|
-
type:
|
|
123
|
+
type: TerminalType.ITERM2,
|
|
117
124
|
identifier: tty,
|
|
118
125
|
tty
|
|
119
126
|
};
|
|
@@ -145,7 +152,7 @@ export class TerminalFocusManager {
|
|
|
145
152
|
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
146
153
|
if (stdout.trim() === "found") {
|
|
147
154
|
return {
|
|
148
|
-
type:
|
|
155
|
+
type: TerminalType.TERMINAL_APP,
|
|
149
156
|
identifier: tty,
|
|
150
157
|
tty
|
|
151
158
|
};
|