@ai-devkit/agent-manager 0.1.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/.eslintrc.json +31 -0
- package/dist/AgentManager.d.ts +104 -0
- package/dist/AgentManager.d.ts.map +1 -0
- package/dist/AgentManager.js +185 -0
- package/dist/AgentManager.js.map +1 -0
- package/dist/adapters/AgentAdapter.d.ts +76 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -0
- package/dist/adapters/AgentAdapter.js +20 -0
- package/dist/adapters/AgentAdapter.js.map +1 -0
- package/dist/adapters/ClaudeCodeAdapter.d.ts +58 -0
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -0
- package/dist/adapters/ClaudeCodeAdapter.js +274 -0
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/terminal/TerminalFocusManager.d.ts +22 -0
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -0
- package/dist/terminal/TerminalFocusManager.js +196 -0
- package/dist/terminal/TerminalFocusManager.js.map +1 -0
- package/dist/terminal/index.d.ts +3 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +6 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/utils/file.d.ts +52 -0
- package/dist/utils/file.d.ts.map +1 -0
- package/dist/utils/file.js +135 -0
- package/dist/utils/file.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +15 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/process.d.ts +61 -0
- package/dist/utils/process.d.ts.map +1 -0
- package/dist/utils/process.js +166 -0
- package/dist/utils/process.js.map +1 -0
- package/jest.config.js +21 -0
- package/package.json +42 -0
- package/project.json +29 -0
- package/src/AgentManager.ts +198 -0
- package/src/__tests__/AgentManager.test.ts +308 -0
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +286 -0
- package/src/adapters/AgentAdapter.ts +94 -0
- package/src/adapters/ClaudeCodeAdapter.ts +344 -0
- package/src/adapters/index.ts +3 -0
- package/src/index.ts +12 -0
- package/src/terminal/TerminalFocusManager.ts +206 -0
- package/src/terminal/index.ts +2 -0
- package/src/utils/file.ts +100 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/process.ts +184 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Adapter
|
|
3
|
+
*
|
|
4
|
+
* Detects running Claude Code agents by reading session files
|
|
5
|
+
* from ~/.claude/ directory and correlating with running processes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
11
|
+
import { AgentStatus } from './AgentAdapter';
|
|
12
|
+
import { listProcesses } from '../utils/process';
|
|
13
|
+
import { readLastLines, readJsonLines, readJson } from '../utils/file';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Structure of ~/.claude/projects/{path}/sessions-index.json
|
|
17
|
+
*/
|
|
18
|
+
interface SessionsIndex {
|
|
19
|
+
originalPath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Entry in session JSONL file
|
|
24
|
+
*/
|
|
25
|
+
interface SessionEntry {
|
|
26
|
+
type?: 'assistant' | 'user' | 'progress' | 'thinking' | 'system' | 'message' | 'text';
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
slug?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
message?: {
|
|
32
|
+
content?: Array<{
|
|
33
|
+
type?: string;
|
|
34
|
+
text?: string;
|
|
35
|
+
content?: string;
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Entry in ~/.claude/history.jsonl
|
|
43
|
+
*/
|
|
44
|
+
interface HistoryEntry {
|
|
45
|
+
display: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
project: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Claude Code session information
|
|
53
|
+
*/
|
|
54
|
+
interface ClaudeSession {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
projectPath: string;
|
|
57
|
+
slug?: string;
|
|
58
|
+
sessionLogPath: string;
|
|
59
|
+
lastEntry?: SessionEntry;
|
|
60
|
+
lastActive?: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Claude Code Adapter
|
|
65
|
+
*
|
|
66
|
+
* Detects Claude Code agents by:
|
|
67
|
+
* 1. Finding running claude processes
|
|
68
|
+
* 2. Reading session files from ~/.claude/projects/
|
|
69
|
+
* 3. Matching sessions to processes via CWD
|
|
70
|
+
* 4. Extracting status from session JSONL
|
|
71
|
+
* 5. Extracting summary from history.jsonl
|
|
72
|
+
*/
|
|
73
|
+
export class ClaudeCodeAdapter implements AgentAdapter {
|
|
74
|
+
readonly type = 'claude' as const;
|
|
75
|
+
|
|
76
|
+
/** Threshold in minutes before considering a session idle */
|
|
77
|
+
private static readonly IDLE_THRESHOLD_MINUTES = 5;
|
|
78
|
+
|
|
79
|
+
private claudeDir: string;
|
|
80
|
+
private projectsDir: string;
|
|
81
|
+
private historyPath: string;
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
85
|
+
this.claudeDir = path.join(homeDir, '.claude');
|
|
86
|
+
this.projectsDir = path.join(this.claudeDir, 'projects');
|
|
87
|
+
this.historyPath = path.join(this.claudeDir, 'history.jsonl');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if this adapter can handle a given process
|
|
92
|
+
*/
|
|
93
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
94
|
+
return processInfo.command.toLowerCase().includes('claude');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect running Claude Code agents
|
|
99
|
+
*/
|
|
100
|
+
async detectAgents(): Promise<AgentInfo[]> {
|
|
101
|
+
// 1. Find running claude processes
|
|
102
|
+
const claudeProcesses = listProcesses({ namePattern: 'claude' });
|
|
103
|
+
|
|
104
|
+
if (claudeProcesses.length === 0) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Read all sessions
|
|
109
|
+
const sessions = this.readSessions();
|
|
110
|
+
|
|
111
|
+
// 3. Read history for summaries
|
|
112
|
+
const history = this.readHistory();
|
|
113
|
+
|
|
114
|
+
// 4. Group processes by CWD
|
|
115
|
+
const processesByCwd = new Map<string, ProcessInfo[]>();
|
|
116
|
+
for (const p of claudeProcesses) {
|
|
117
|
+
const list = processesByCwd.get(p.cwd) || [];
|
|
118
|
+
list.push(p);
|
|
119
|
+
processesByCwd.set(p.cwd, list);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Match sessions to processes
|
|
123
|
+
const agents: AgentInfo[] = [];
|
|
124
|
+
|
|
125
|
+
for (const [cwd, processes] of processesByCwd) {
|
|
126
|
+
// Find sessions for this project path
|
|
127
|
+
const projectSessions = sessions.filter(s => s.projectPath === cwd);
|
|
128
|
+
|
|
129
|
+
if (projectSessions.length === 0) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sort sessions by last active time (newest first)
|
|
134
|
+
projectSessions.sort((a, b) => {
|
|
135
|
+
const timeA = a.lastActive?.getTime() || 0;
|
|
136
|
+
const timeB = b.lastActive?.getTime() || 0;
|
|
137
|
+
return timeB - timeA;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Map processes to the most recent sessions
|
|
141
|
+
// If there are 2 processes, we take the 2 most recent sessions
|
|
142
|
+
const activeSessions = projectSessions.slice(0, processes.length);
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < activeSessions.length; i++) {
|
|
145
|
+
const session = activeSessions[i];
|
|
146
|
+
const process = processes[i]; // Assign process to session (arbitrary 1-to-1 mapping)
|
|
147
|
+
|
|
148
|
+
const historyEntry = [...history].reverse().find(
|
|
149
|
+
h => h.sessionId === session.sessionId
|
|
150
|
+
);
|
|
151
|
+
const summary = historyEntry?.display || 'Session started';
|
|
152
|
+
const status = this.determineStatus(session);
|
|
153
|
+
const agentName = this.generateAgentName(session, agents); // Pass currently built agents for collision checks
|
|
154
|
+
|
|
155
|
+
agents.push({
|
|
156
|
+
name: agentName,
|
|
157
|
+
type: this.type,
|
|
158
|
+
status,
|
|
159
|
+
summary,
|
|
160
|
+
pid: process.pid,
|
|
161
|
+
projectPath: session.projectPath,
|
|
162
|
+
sessionId: session.sessionId,
|
|
163
|
+
slug: session.slug,
|
|
164
|
+
lastActive: session.lastActive || new Date(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return agents;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read all Claude Code sessions
|
|
174
|
+
*/
|
|
175
|
+
private readSessions(): ClaudeSession[] {
|
|
176
|
+
if (!fs.existsSync(this.projectsDir)) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sessions: ClaudeSession[] = [];
|
|
181
|
+
const projectDirs = fs.readdirSync(this.projectsDir);
|
|
182
|
+
|
|
183
|
+
for (const dirName of projectDirs) {
|
|
184
|
+
if (dirName.startsWith('.')) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const projectDir = path.join(this.projectsDir, dirName);
|
|
189
|
+
if (!fs.statSync(projectDir).isDirectory()) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Read sessions-index.json to get original project path
|
|
194
|
+
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
195
|
+
if (!fs.existsSync(indexPath)) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sessionsIndex = readJson<SessionsIndex>(indexPath);
|
|
200
|
+
if (!sessionsIndex) {
|
|
201
|
+
console.error(`Failed to parse ${indexPath}`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
206
|
+
|
|
207
|
+
for (const sessionFile of sessionFiles) {
|
|
208
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
209
|
+
const sessionLogPath = path.join(projectDir, sessionFile);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const sessionData = this.readSessionLog(sessionLogPath);
|
|
213
|
+
|
|
214
|
+
sessions.push({
|
|
215
|
+
sessionId,
|
|
216
|
+
projectPath: sessionsIndex.originalPath,
|
|
217
|
+
slug: sessionData.slug,
|
|
218
|
+
sessionLogPath,
|
|
219
|
+
lastEntry: sessionData.lastEntry,
|
|
220
|
+
lastActive: sessionData.lastActive,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`Failed to read session ${sessionId}:`, error);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return sessions;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read a session JSONL file
|
|
234
|
+
* Only reads last 100 lines for performance with large files
|
|
235
|
+
*/
|
|
236
|
+
private readSessionLog(logPath: string): {
|
|
237
|
+
slug?: string;
|
|
238
|
+
lastEntry?: SessionEntry;
|
|
239
|
+
lastActive?: Date;
|
|
240
|
+
} {
|
|
241
|
+
const lines = readLastLines(logPath, 100);
|
|
242
|
+
|
|
243
|
+
let slug: string | undefined;
|
|
244
|
+
let lastEntry: SessionEntry | undefined;
|
|
245
|
+
let lastActive: Date | undefined;
|
|
246
|
+
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
try {
|
|
249
|
+
const entry: SessionEntry = JSON.parse(line);
|
|
250
|
+
|
|
251
|
+
if (entry.slug && !slug) {
|
|
252
|
+
slug = entry.slug;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
lastEntry = entry;
|
|
256
|
+
|
|
257
|
+
if (entry.timestamp) {
|
|
258
|
+
lastActive = new Date(entry.timestamp);
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { slug, lastEntry, lastActive };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read history.jsonl for user prompts
|
|
270
|
+
* Only reads last 100 lines for performance
|
|
271
|
+
*/
|
|
272
|
+
private readHistory(): HistoryEntry[] {
|
|
273
|
+
return readJsonLines<HistoryEntry>(this.historyPath, 100);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Determine agent status from session entry
|
|
278
|
+
*/
|
|
279
|
+
private determineStatus(session: ClaudeSession): AgentStatus {
|
|
280
|
+
if (!session.lastEntry) {
|
|
281
|
+
return AgentStatus.UNKNOWN;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const entryType = session.lastEntry.type;
|
|
285
|
+
const lastActive = session.lastActive || new Date(0);
|
|
286
|
+
const ageMinutes = (Date.now() - lastActive.getTime()) / 1000 / 60;
|
|
287
|
+
|
|
288
|
+
if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) {
|
|
289
|
+
return AgentStatus.IDLE;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (entryType === 'user') {
|
|
293
|
+
// Check if user interrupted manually - this puts agent back in waiting state
|
|
294
|
+
const content = session.lastEntry.message?.content;
|
|
295
|
+
if (Array.isArray(content)) {
|
|
296
|
+
const isInterrupted = content.some(c =>
|
|
297
|
+
(c.type === 'text' && c.text?.includes('[Request interrupted')) ||
|
|
298
|
+
(c.type === 'tool_result' && c.content?.includes('[Request interrupted'))
|
|
299
|
+
);
|
|
300
|
+
if (isInterrupted) return AgentStatus.WAITING;
|
|
301
|
+
}
|
|
302
|
+
return AgentStatus.RUNNING;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (entryType === 'progress' || entryType === 'thinking') {
|
|
306
|
+
return AgentStatus.RUNNING;
|
|
307
|
+
} else if (entryType === 'assistant') {
|
|
308
|
+
return AgentStatus.WAITING;
|
|
309
|
+
} else if (entryType === 'system') {
|
|
310
|
+
return AgentStatus.IDLE;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return AgentStatus.UNKNOWN;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Generate unique agent name
|
|
318
|
+
* Uses project basename, appends slug if multiple sessions for same project
|
|
319
|
+
*/
|
|
320
|
+
private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string {
|
|
321
|
+
const projectName = path.basename(session.projectPath);
|
|
322
|
+
|
|
323
|
+
const sameProjectAgents = existingAgents.filter(
|
|
324
|
+
a => a.projectPath === session.projectPath
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (sameProjectAgents.length === 0) {
|
|
328
|
+
return projectName;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Multiple sessions for same project, append slug
|
|
332
|
+
if (session.slug) {
|
|
333
|
+
// Use first word of slug for brevity (with safety check for format)
|
|
334
|
+
const slugPart = session.slug.includes('-')
|
|
335
|
+
? session.slug.split('-')[0]
|
|
336
|
+
: session.slug.slice(0, 8);
|
|
337
|
+
return `${projectName} (${slugPart})`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// No slug available, use session ID prefix
|
|
341
|
+
return `${projectName} (${session.sessionId.slice(0, 8)})`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { AgentManager } from './AgentManager';
|
|
2
|
+
|
|
3
|
+
export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter';
|
|
4
|
+
export { AgentStatus } from './adapters/AgentAdapter';
|
|
5
|
+
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter';
|
|
6
|
+
|
|
7
|
+
export { TerminalFocusManager } from './terminal/TerminalFocusManager';
|
|
8
|
+
export type { TerminalLocation } from './terminal/TerminalFocusManager';
|
|
9
|
+
|
|
10
|
+
export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process';
|
|
11
|
+
export type { ListProcessesOptions } from './utils/process';
|
|
12
|
+
export { readLastLines, readJsonLines, fileExists, readJson } from './utils/file';
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { getProcessTty } from '../utils/process';
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export interface TerminalLocation {
|
|
9
|
+
type: 'tmux' | 'iterm2' | 'terminal-app' | 'unknown';
|
|
10
|
+
identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others
|
|
11
|
+
tty: string; // e.g., "/dev/ttys030"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class TerminalFocusManager {
|
|
15
|
+
/**
|
|
16
|
+
* Find the terminal location (emulator info) for a given process ID
|
|
17
|
+
*/
|
|
18
|
+
async findTerminal(pid: number): Promise<TerminalLocation | null> {
|
|
19
|
+
const ttyShort = getProcessTty(pid);
|
|
20
|
+
|
|
21
|
+
// If no TTY or invalid, we can't find the terminal
|
|
22
|
+
if (!ttyShort || ttyShort === '?') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fullTty = `/dev/${ttyShort}`;
|
|
27
|
+
|
|
28
|
+
// 1. Check tmux (most specific if running inside it)
|
|
29
|
+
const tmuxLocation = await this.findTmuxPane(fullTty);
|
|
30
|
+
if (tmuxLocation) return tmuxLocation;
|
|
31
|
+
|
|
32
|
+
// 2. Check iTerm2
|
|
33
|
+
const itermLocation = await this.findITerm2Session(fullTty);
|
|
34
|
+
if (itermLocation) return itermLocation;
|
|
35
|
+
|
|
36
|
+
// 3. Check Terminal.app
|
|
37
|
+
const terminalAppLocation = await this.findTerminalAppWindow(fullTty);
|
|
38
|
+
if (terminalAppLocation) return terminalAppLocation;
|
|
39
|
+
|
|
40
|
+
// 4. Fallback: we know the TTY but not the emulator wrapper
|
|
41
|
+
return {
|
|
42
|
+
type: 'unknown',
|
|
43
|
+
identifier: '',
|
|
44
|
+
tty: fullTty
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Focus the terminal identified by the location
|
|
50
|
+
*/
|
|
51
|
+
async focusTerminal(location: TerminalLocation): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
switch (location.type) {
|
|
54
|
+
case 'tmux':
|
|
55
|
+
return await this.focusTmuxPane(location.identifier);
|
|
56
|
+
case 'iterm2':
|
|
57
|
+
return await this.focusITerm2Session(location.tty);
|
|
58
|
+
case 'terminal-app':
|
|
59
|
+
return await this.focusTerminalAppWindow(location.tty);
|
|
60
|
+
default:
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async findTmuxPane(tty: string): Promise<TerminalLocation | null> {
|
|
69
|
+
try {
|
|
70
|
+
// List all panes with their TTYs and identifiers
|
|
71
|
+
// Format: /dev/ttys001|my-session:1.1
|
|
72
|
+
// using | as separator to handle spaces in session names
|
|
73
|
+
const { stdout } = await execAsync("tmux list-panes -a -F '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'");
|
|
74
|
+
|
|
75
|
+
const lines = stdout.trim().split('\n');
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
const [paneTty, identifier] = line.split('|');
|
|
79
|
+
if (paneTty === tty && identifier) {
|
|
80
|
+
return {
|
|
81
|
+
type: 'tmux',
|
|
82
|
+
identifier,
|
|
83
|
+
tty
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// tmux might not be installed or running
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
|
|
94
|
+
try {
|
|
95
|
+
// Check if iTerm2 is running first to avoid launching it
|
|
96
|
+
const { stdout: isRunning } = await execAsync('pgrep -x iTerm2 || echo "no"');
|
|
97
|
+
if (isRunning.trim() === "no") return null;
|
|
98
|
+
|
|
99
|
+
const script = `
|
|
100
|
+
tell application "iTerm"
|
|
101
|
+
repeat with w in windows
|
|
102
|
+
repeat with t in tabs of w
|
|
103
|
+
repeat with s in sessions of t
|
|
104
|
+
if tty of s is "${tty}" then
|
|
105
|
+
return "found"
|
|
106
|
+
end if
|
|
107
|
+
end repeat
|
|
108
|
+
end repeat
|
|
109
|
+
end repeat
|
|
110
|
+
end tell
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
114
|
+
if (stdout.trim() === "found") {
|
|
115
|
+
return {
|
|
116
|
+
type: 'iterm2',
|
|
117
|
+
identifier: tty,
|
|
118
|
+
tty
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// iTerm2 not found or script failed
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
|
|
128
|
+
try {
|
|
129
|
+
// Check if Terminal is running
|
|
130
|
+
const { stdout: isRunning } = await execAsync('pgrep -x Terminal || echo "no"');
|
|
131
|
+
if (isRunning.trim() === "no") return null;
|
|
132
|
+
|
|
133
|
+
const script = `
|
|
134
|
+
tell application "Terminal"
|
|
135
|
+
repeat with w in windows
|
|
136
|
+
repeat with t in tabs of w
|
|
137
|
+
if tty of t is "${tty}" then
|
|
138
|
+
return "found"
|
|
139
|
+
end if
|
|
140
|
+
end repeat
|
|
141
|
+
end repeat
|
|
142
|
+
end tell
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
146
|
+
if (stdout.trim() === "found") {
|
|
147
|
+
return {
|
|
148
|
+
type: 'terminal-app',
|
|
149
|
+
identifier: tty,
|
|
150
|
+
tty
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Terminal not found or script failed
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async focusTmuxPane(identifier: string): Promise<boolean> {
|
|
160
|
+
try {
|
|
161
|
+
await execFileAsync('tmux', ['switch-client', '-t', identifier]);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async focusITerm2Session(tty: string): Promise<boolean> {
|
|
169
|
+
const script = `
|
|
170
|
+
tell application "iTerm"
|
|
171
|
+
activate
|
|
172
|
+
repeat with w in windows
|
|
173
|
+
repeat with t in tabs of w
|
|
174
|
+
repeat with s in sessions of t
|
|
175
|
+
if tty of s is "${tty}" then
|
|
176
|
+
select s
|
|
177
|
+
return "true"
|
|
178
|
+
end if
|
|
179
|
+
end repeat
|
|
180
|
+
end repeat
|
|
181
|
+
end repeat
|
|
182
|
+
end tell
|
|
183
|
+
`;
|
|
184
|
+
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
185
|
+
return stdout.trim() === "true";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async focusTerminalAppWindow(tty: string): Promise<boolean> {
|
|
189
|
+
const script = `
|
|
190
|
+
tell application "Terminal"
|
|
191
|
+
activate
|
|
192
|
+
repeat with w in windows
|
|
193
|
+
repeat with t in tabs of w
|
|
194
|
+
if tty of t is "${tty}" then
|
|
195
|
+
set index of w to 1
|
|
196
|
+
set selected tab of w to t
|
|
197
|
+
return "true"
|
|
198
|
+
end if
|
|
199
|
+
end repeat
|
|
200
|
+
end repeat
|
|
201
|
+
end tell
|
|
202
|
+
`;
|
|
203
|
+
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
|
204
|
+
return stdout.trim() === "true";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for reading files efficiently
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read last N lines from a file efficiently
|
|
11
|
+
*
|
|
12
|
+
* @param filePath Path to the file
|
|
13
|
+
* @param lineCount Number of lines to read from the end (default: 100)
|
|
14
|
+
* @returns Array of lines
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const lastLines = readLastLines('/path/to/log.txt', 50);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function readLastLines(filePath: string, lineCount: number = 100): string[] {
|
|
22
|
+
if (!fs.existsSync(filePath)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
28
|
+
const allLines = content.trim().split('\n');
|
|
29
|
+
|
|
30
|
+
// Return last N lines (or all if file has fewer lines)
|
|
31
|
+
return allLines.slice(-lineCount);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`Failed to read ${filePath}:`, error);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a JSONL (JSON Lines) file and parse each line
|
|
40
|
+
*
|
|
41
|
+
* @param filePath Path to the JSONL file
|
|
42
|
+
* @param maxLines Maximum number of lines to read from end (default: 1000)
|
|
43
|
+
* @returns Array of parsed objects
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const entries = readJsonLines<MyType>('/path/to/data.jsonl');
|
|
48
|
+
* const recent = readJsonLines<MyType>('/path/to/data.jsonl', 100);
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function readJsonLines<T = unknown>(filePath: string, maxLines: number = 1000): T[] {
|
|
52
|
+
const lines = readLastLines(filePath, maxLines);
|
|
53
|
+
|
|
54
|
+
return lines.map(line => {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(line) as T;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}).filter((entry): entry is T => entry !== null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a file exists
|
|
65
|
+
*
|
|
66
|
+
* @param filePath Path to check
|
|
67
|
+
* @returns True if file exists
|
|
68
|
+
*/
|
|
69
|
+
export function fileExists(filePath: string): boolean {
|
|
70
|
+
try {
|
|
71
|
+
return fs.existsSync(filePath);
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read a JSON file safely
|
|
79
|
+
*
|
|
80
|
+
* @param filePath Path to JSON file
|
|
81
|
+
* @returns Parsed JSON object or null if error
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* const config = readJson<ConfigType>('/path/to/config.json');
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function readJson<T = unknown>(filePath: string): T | null {
|
|
89
|
+
if (!fs.existsSync(filePath)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
95
|
+
return JSON.parse(content) as T;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`Failed to parse JSON from ${filePath}:`, error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|