@ai-devkit/agent-manager 0.17.0 → 0.19.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/__tests__/adapters/CodexAdapter.test.js +80 -15
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
- package/dist/__tests__/adapters/PiAdapter.test.js +590 -0
- package/dist/__tests__/adapters/PiAdapter.test.js.map +1 -0
- package/dist/__tests__/terminal/TerminalFocusManager.test.js +73 -0
- package/dist/__tests__/terminal/TerminalFocusManager.test.js.map +1 -0
- package/dist/__tests__/utils/agents.test.js +17 -0
- package/dist/__tests__/utils/agents.test.js.map +1 -0
- package/dist/adapters/AgentAdapter.d.ts +1 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +4 -0
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +38 -1
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/PiAdapter.d.ts +62 -0
- package/dist/adapters/PiAdapter.d.ts.map +1 -0
- package/dist/adapters/PiAdapter.js +450 -0
- package/dist/adapters/PiAdapter.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 +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/index.d.ts +1 -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 +1 -0
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +10 -9
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/utils/agents.d.ts +1 -1
- package/dist/utils/agents.d.ts.map +1 -1
- package/dist/utils/agents.js +29 -3
- package/dist/utils/agents.js.map +1 -1
- package/package.json +6 -1
- package/src/__tests__/adapters/CodexAdapter.test.ts +64 -13
- package/src/__tests__/adapters/PiAdapter.test.ts +435 -0
- package/src/__tests__/terminal/TerminalFocusManager.test.ts +92 -0
- package/src/__tests__/utils/agents.test.ts +17 -0
- package/src/adapters/AgentAdapter.ts +1 -1
- package/src/adapters/CodexAdapter.ts +51 -1
- package/src/adapters/PiAdapter.ts +597 -0
- package/src/adapters/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/terminal/TerminalFocusManager.ts +11 -3
- package/src/utils/agents.ts +22 -2
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Adapter
|
|
3
|
+
*
|
|
4
|
+
* Detects running Pi agents by:
|
|
5
|
+
* 1. Finding running Pi processes
|
|
6
|
+
* 2. Matching exact PID-to-session metadata from ~/.pi/agent/sessions.json
|
|
7
|
+
* 3. Falling back to shared process/session matching over Pi JSONL session files
|
|
8
|
+
* 4. Parsing Pi JSONL entries defensively for summary and conversation output
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import type {
|
|
14
|
+
AgentAdapter,
|
|
15
|
+
AgentInfo,
|
|
16
|
+
ProcessInfo,
|
|
17
|
+
ConversationMessage,
|
|
18
|
+
SessionSummary,
|
|
19
|
+
ListSessionsOptions,
|
|
20
|
+
} from './AgentAdapter.js';
|
|
21
|
+
import { AgentStatus } from './AgentAdapter.js';
|
|
22
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
|
|
23
|
+
import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
|
|
24
|
+
import type { SessionFile } from '../utils/session.js';
|
|
25
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
|
|
26
|
+
import { AgentRegistry } from '../utils/AgentRegistry.js';
|
|
27
|
+
|
|
28
|
+
interface PiSession {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
projectPath: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
sessionStart: Date;
|
|
33
|
+
lastActive: Date;
|
|
34
|
+
lastRole?: ConversationMessage['role'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PiLine {
|
|
38
|
+
timestamp?: string;
|
|
39
|
+
role?: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
content?: unknown;
|
|
42
|
+
text?: unknown;
|
|
43
|
+
message?: unknown;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
session_id?: string;
|
|
46
|
+
id?: string;
|
|
47
|
+
cwd?: string;
|
|
48
|
+
projectPath?: string;
|
|
49
|
+
project_path?: string;
|
|
50
|
+
payload?: Record<string, unknown>;
|
|
51
|
+
data?: Record<string, unknown>;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type PiRecord = Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
interface TrackerMatch {
|
|
58
|
+
process: ProcessInfo;
|
|
59
|
+
filePath: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TrackerAgentResult {
|
|
63
|
+
agents: AgentInfo[];
|
|
64
|
+
fallback: ProcessInfo[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class PiAdapter implements AgentAdapter {
|
|
68
|
+
readonly type = 'pi' as const;
|
|
69
|
+
|
|
70
|
+
private static readonly IDLE_THRESHOLD_MINUTES = 5;
|
|
71
|
+
|
|
72
|
+
private piAgentDir: string;
|
|
73
|
+
private piSessionsDir: string;
|
|
74
|
+
private trackerPath: string;
|
|
75
|
+
private registry: AgentRegistry;
|
|
76
|
+
|
|
77
|
+
constructor(registry: AgentRegistry = AgentRegistry.default()) {
|
|
78
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
79
|
+
this.piAgentDir = path.join(homeDir, '.pi', 'agent');
|
|
80
|
+
this.piSessionsDir = path.join(this.piAgentDir, 'sessions');
|
|
81
|
+
this.trackerPath = path.join(this.piAgentDir, 'sessions.json');
|
|
82
|
+
this.registry = registry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
86
|
+
return this.isPiExecutable(processInfo.command);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async detectAgents(): Promise<AgentInfo[]> {
|
|
90
|
+
const processes = enrichProcesses(this.listPiProcesses());
|
|
91
|
+
if (processes.length === 0) return [];
|
|
92
|
+
|
|
93
|
+
const { cachedAgents, remaining } = this.tryRegistryCache(processes);
|
|
94
|
+
if (remaining.length === 0) return cachedAgents;
|
|
95
|
+
|
|
96
|
+
const trackerResult = this.mapTrackerMatches(remaining);
|
|
97
|
+
const fallbackAgents = this.mapFallbackMatches(trackerResult.fallback);
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
...cachedAgents,
|
|
101
|
+
...trackerResult.agents,
|
|
102
|
+
...fallbackAgents,
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private mapTrackerMatches(processes: ProcessInfo[]): TrackerAgentResult {
|
|
107
|
+
const { matches: trackerMatches, fallback } = this.matchFromTracker(processes);
|
|
108
|
+
const agents: AgentInfo[] = [];
|
|
109
|
+
|
|
110
|
+
for (const match of trackerMatches) {
|
|
111
|
+
const session = this.parseSession(match.filePath, match.process.cwd);
|
|
112
|
+
if (session) {
|
|
113
|
+
agents.push(this.mapSessionToAgent(session, match.process, match.filePath));
|
|
114
|
+
} else {
|
|
115
|
+
fallback.push(match.process);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { agents, fallback };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private mapFallbackMatches(processes: ProcessInfo[]): AgentInfo[] {
|
|
123
|
+
if (processes.length === 0) return [];
|
|
124
|
+
|
|
125
|
+
const sessions = this.discoverSessions(processes);
|
|
126
|
+
if (sessions.length === 0) {
|
|
127
|
+
return processes.map((p) => this.mapProcessOnlyAgent(p));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const matches = matchProcessesToSessions(processes, sessions);
|
|
131
|
+
const matchedPids = new Set(matches.map((m) => m.process.pid));
|
|
132
|
+
const agents: AgentInfo[] = [];
|
|
133
|
+
|
|
134
|
+
for (const match of matches) {
|
|
135
|
+
const session = this.parseSession(match.session.filePath, match.process.cwd);
|
|
136
|
+
if (session) {
|
|
137
|
+
agents.push(this.mapSessionToAgent(session, match.process, match.session.filePath));
|
|
138
|
+
} else {
|
|
139
|
+
matchedPids.delete(match.process.pid);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const proc of processes) {
|
|
144
|
+
if (!matchedPids.has(proc.pid)) {
|
|
145
|
+
agents.push(this.mapProcessOnlyAgent(proc));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return agents;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private listPiProcesses(): ProcessInfo[] {
|
|
153
|
+
const byPid = new Map<number, ProcessInfo>();
|
|
154
|
+
for (const proc of listAgentProcesses('pi')) {
|
|
155
|
+
if (this.canHandle(proc)) byPid.set(proc.pid, proc);
|
|
156
|
+
}
|
|
157
|
+
for (const proc of listAgentProcesses('node')) {
|
|
158
|
+
if (this.canHandle(proc)) byPid.set(proc.pid, proc);
|
|
159
|
+
}
|
|
160
|
+
return Array.from(byPid.values());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private tryRegistryCache(processes: ProcessInfo[]): {
|
|
164
|
+
cachedAgents: AgentInfo[];
|
|
165
|
+
remaining: ProcessInfo[];
|
|
166
|
+
} {
|
|
167
|
+
const cachedAgents: AgentInfo[] = [];
|
|
168
|
+
const remaining: ProcessInfo[] = [];
|
|
169
|
+
const byPid = new Map(this.registry.list().map((e) => [e.pid, e]));
|
|
170
|
+
|
|
171
|
+
for (const proc of processes) {
|
|
172
|
+
const entry = byPid.get(proc.pid);
|
|
173
|
+
if (
|
|
174
|
+
!entry ||
|
|
175
|
+
entry.type !== this.type ||
|
|
176
|
+
!entry.sessionFilePath ||
|
|
177
|
+
!fs.existsSync(entry.sessionFilePath)
|
|
178
|
+
) {
|
|
179
|
+
remaining.push(proc);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const session = this.parseSession(entry.sessionFilePath, proc.cwd);
|
|
184
|
+
if (!session) {
|
|
185
|
+
remaining.push(proc);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
cachedAgents.push(this.mapSessionToAgent(session, proc, entry.sessionFilePath));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { cachedAgents, remaining };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private matchFromTracker(processes: ProcessInfo[]): {
|
|
196
|
+
matches: TrackerMatch[];
|
|
197
|
+
fallback: ProcessInfo[];
|
|
198
|
+
} {
|
|
199
|
+
const tracker = this.readTracker();
|
|
200
|
+
if (tracker.size === 0) return { matches: [], fallback: processes };
|
|
201
|
+
|
|
202
|
+
const matches: TrackerMatch[] = [];
|
|
203
|
+
const fallback: ProcessInfo[] = [];
|
|
204
|
+
|
|
205
|
+
for (const proc of processes) {
|
|
206
|
+
const filePath = tracker.get(proc.pid);
|
|
207
|
+
if (!filePath || !this.isTrustedSessionPath(filePath) || !fs.existsSync(filePath)) {
|
|
208
|
+
fallback.push(proc);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
matches.push({ process: proc, filePath });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { matches, fallback };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private readTracker(): Map<number, string> {
|
|
218
|
+
const content = safeReadFile(this.trackerPath);
|
|
219
|
+
if (content === undefined) return new Map();
|
|
220
|
+
|
|
221
|
+
let parsed: unknown;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(content);
|
|
224
|
+
} catch {
|
|
225
|
+
return new Map();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map();
|
|
229
|
+
|
|
230
|
+
const map = new Map<number, string>();
|
|
231
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
232
|
+
const keyPid = this.toPid(key);
|
|
233
|
+
if (keyPid !== null && typeof value === 'string' && value) {
|
|
234
|
+
map.set(keyPid, value);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return map;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private toPid(value: unknown): number | null {
|
|
241
|
+
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
|
242
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
|
243
|
+
const parsed = Number(value);
|
|
244
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private isTrustedSessionPath(filePath: string): boolean {
|
|
248
|
+
const resolvedRoot = path.resolve(this.piSessionsDir);
|
|
249
|
+
const resolvedPath = path.resolve(filePath);
|
|
250
|
+
return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private discoverSessions(processes: ProcessInfo[] = []): SessionFile[] {
|
|
254
|
+
if (!isDirectory(this.piSessionsDir)) return [];
|
|
255
|
+
|
|
256
|
+
const cwdByProjectDir = this.buildProjectDirCwdMap(processes);
|
|
257
|
+
const sessions: SessionFile[] = [];
|
|
258
|
+
for (const filePath of this.collectJsonlFiles(this.piSessionsDir)) {
|
|
259
|
+
const stat = safeStat(filePath);
|
|
260
|
+
if (!stat) continue;
|
|
261
|
+
|
|
262
|
+
const session = this.parseSession(filePath);
|
|
263
|
+
const sessionId = session?.sessionId || this.sessionIdFromFile(filePath);
|
|
264
|
+
const projectDir = path.dirname(filePath);
|
|
265
|
+
sessions.push({
|
|
266
|
+
sessionId,
|
|
267
|
+
filePath,
|
|
268
|
+
projectDir,
|
|
269
|
+
birthtimeMs: stat.birthtimeMs || stat.mtimeMs,
|
|
270
|
+
resolvedCwd: session?.projectPath || cwdByProjectDir.get(path.basename(projectDir)) || '',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return sessions;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private buildProjectDirCwdMap(processes: ProcessInfo[]): Map<string, string> {
|
|
278
|
+
const map = new Map<string, string>();
|
|
279
|
+
for (const proc of processes) {
|
|
280
|
+
if (!proc.cwd) continue;
|
|
281
|
+
map.set(this.encodeProjectDir(proc.cwd), proc.cwd);
|
|
282
|
+
}
|
|
283
|
+
return map;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private collectJsonlFiles(dir: string): string[] {
|
|
287
|
+
const files: string[] = [];
|
|
288
|
+
for (const entry of safeReaddir(dir)) {
|
|
289
|
+
const fullPath = path.join(dir, entry);
|
|
290
|
+
const stat = safeStat(fullPath);
|
|
291
|
+
if (!stat) continue;
|
|
292
|
+
if (stat.isDirectory()) {
|
|
293
|
+
files.push(...this.collectJsonlFiles(fullPath));
|
|
294
|
+
} else if (stat.isFile() && entry.endsWith('.jsonl')) {
|
|
295
|
+
files.push(fullPath);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return files;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private parseSession(filePath: string, fallbackCwd = ''): PiSession | null {
|
|
302
|
+
const entries = this.readJsonl(filePath);
|
|
303
|
+
if (entries.length === 0) return null;
|
|
304
|
+
return this.sessionFromEntries(entries, filePath, fallbackCwd);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private sessionFromEntries(entries: PiLine[], filePath: string, fallbackCwd = ''): PiSession {
|
|
308
|
+
const stat = safeStat(filePath);
|
|
309
|
+
const timestamps = entries
|
|
310
|
+
.map((entry) => this.parseTimestamp(this.entryTimestamp(entry)))
|
|
311
|
+
.filter((value): value is Date => value !== null);
|
|
312
|
+
|
|
313
|
+
const sessionStart = timestamps[0] ?? stat?.birthtime ?? stat?.mtime ?? new Date();
|
|
314
|
+
const lastActive = timestamps[timestamps.length - 1] ?? stat?.mtime ?? sessionStart;
|
|
315
|
+
const messages = this.entriesToMessages(entries, true);
|
|
316
|
+
const lastUser = [...messages].reverse().find((msg) => msg.role === 'user');
|
|
317
|
+
const lastMessage = messages[messages.length - 1];
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
sessionId: this.sessionIdFromEntries(entries) || this.sessionIdFromFile(filePath),
|
|
321
|
+
projectPath: this.cwdFromEntries(entries) || fallbackCwd,
|
|
322
|
+
summary: lastUser?.content ? this.truncate(lastUser.content, 120) : 'Pi session active',
|
|
323
|
+
sessionStart,
|
|
324
|
+
lastActive,
|
|
325
|
+
lastRole: lastMessage?.role,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private readJsonl(filePath: string): PiLine[] {
|
|
330
|
+
const content = safeReadFile(filePath);
|
|
331
|
+
if (content === undefined) return [];
|
|
332
|
+
|
|
333
|
+
const entries: PiLine[] = [];
|
|
334
|
+
for (const line of content.split(/\r?\n/)) {
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
if (!trimmed) continue;
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(trimmed);
|
|
339
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
340
|
+
entries.push(parsed as PiLine);
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return entries;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private entryToMessage(entry: PiLine, includeSystem: boolean): ConversationMessage | null {
|
|
350
|
+
const role = this.entryRole(entry);
|
|
351
|
+
if (!role) return null;
|
|
352
|
+
if (role === 'system' && !includeSystem) return null;
|
|
353
|
+
|
|
354
|
+
const content = this.entryContent(entry).trim();
|
|
355
|
+
if (!content) return null;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
role,
|
|
359
|
+
content,
|
|
360
|
+
timestamp: this.entryTimestamp(entry),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private entryRole(entry: PiLine): ConversationMessage['role'] | null {
|
|
365
|
+
const message = this.messageRecord(entry);
|
|
366
|
+
const raw = this.firstString(
|
|
367
|
+
entry.role,
|
|
368
|
+
message?.role,
|
|
369
|
+
this.roleLikeType(entry.type),
|
|
370
|
+
entry.payload?.role,
|
|
371
|
+
entry.payload?.type,
|
|
372
|
+
entry.data?.role,
|
|
373
|
+
entry.data?.type,
|
|
374
|
+
);
|
|
375
|
+
if (!raw) return null;
|
|
376
|
+
const normalized = raw.toLowerCase();
|
|
377
|
+
if (normalized === 'user' || normalized === 'human') return 'user';
|
|
378
|
+
if (normalized === 'assistant' || normalized === 'ai' || normalized === 'pi') return 'assistant';
|
|
379
|
+
if (normalized === 'system') return 'system';
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private entryContent(entry: PiLine): string {
|
|
384
|
+
const message = this.messageRecord(entry);
|
|
385
|
+
const candidates = [
|
|
386
|
+
entry.content,
|
|
387
|
+
entry.text,
|
|
388
|
+
message?.content,
|
|
389
|
+
message?.text,
|
|
390
|
+
message?.message,
|
|
391
|
+
entry.message,
|
|
392
|
+
entry.payload?.content,
|
|
393
|
+
entry.payload?.text,
|
|
394
|
+
entry.payload?.message,
|
|
395
|
+
entry.data?.content,
|
|
396
|
+
entry.data?.text,
|
|
397
|
+
entry.data?.message,
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
for (const candidate of candidates) {
|
|
401
|
+
const text = this.contentToString(candidate);
|
|
402
|
+
if (text) return text;
|
|
403
|
+
}
|
|
404
|
+
return '';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private contentToString(value: unknown): string {
|
|
408
|
+
if (typeof value === 'string') return value;
|
|
409
|
+
if (Array.isArray(value)) {
|
|
410
|
+
return value.map((item) => this.contentToString(item)).filter(Boolean).join('');
|
|
411
|
+
}
|
|
412
|
+
if (!value || typeof value !== 'object') return '';
|
|
413
|
+
|
|
414
|
+
const record = value as Record<string, unknown>;
|
|
415
|
+
return this.contentToString(record.content ?? record.text ?? record.value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private asRecord(value: unknown): PiRecord | null {
|
|
419
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
420
|
+
return value as PiRecord;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private messageRecord(entry: PiLine): PiRecord | null {
|
|
424
|
+
return this.asRecord(entry.message);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private roleLikeType(value: unknown): string | undefined {
|
|
428
|
+
if (typeof value !== 'string') return undefined;
|
|
429
|
+
const normalized = value.toLowerCase();
|
|
430
|
+
if (['user', 'human', 'assistant', 'ai', 'pi', 'system'].includes(normalized)) {
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private entryTimestamp(entry: PiLine): string | undefined {
|
|
437
|
+
return this.firstString(
|
|
438
|
+
entry.timestamp,
|
|
439
|
+
entry.payload?.timestamp,
|
|
440
|
+
entry.data?.timestamp,
|
|
441
|
+
entry.createdAt,
|
|
442
|
+
entry.created_at,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private sessionIdFromEntries(entries: PiLine[]): string | null {
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
const sessionId = this.firstString(
|
|
449
|
+
entry.sessionId,
|
|
450
|
+
entry.session_id,
|
|
451
|
+
entry.id,
|
|
452
|
+
entry.payload?.sessionId,
|
|
453
|
+
entry.payload?.session_id,
|
|
454
|
+
entry.payload?.id,
|
|
455
|
+
entry.data?.sessionId,
|
|
456
|
+
entry.data?.session_id,
|
|
457
|
+
entry.data?.id,
|
|
458
|
+
);
|
|
459
|
+
if (sessionId) return sessionId;
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private cwdFromEntries(entries: PiLine[]): string {
|
|
465
|
+
for (const entry of entries) {
|
|
466
|
+
const cwd = this.firstString(
|
|
467
|
+
entry.cwd,
|
|
468
|
+
entry.projectPath,
|
|
469
|
+
entry.project_path,
|
|
470
|
+
entry.payload?.cwd,
|
|
471
|
+
entry.payload?.projectPath,
|
|
472
|
+
entry.payload?.project_path,
|
|
473
|
+
entry.data?.cwd,
|
|
474
|
+
entry.data?.projectPath,
|
|
475
|
+
entry.data?.project_path,
|
|
476
|
+
);
|
|
477
|
+
if (cwd) return cwd;
|
|
478
|
+
}
|
|
479
|
+
return '';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private sessionIdFromFile(filePath: string): string {
|
|
483
|
+
const base = path.basename(filePath, '.jsonl');
|
|
484
|
+
const underscore = base.lastIndexOf('_');
|
|
485
|
+
return underscore >= 0 ? base.slice(underscore + 1) : base;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private encodeProjectDir(cwd: string): string {
|
|
489
|
+
const normalized = path.resolve(cwd);
|
|
490
|
+
return `--${normalized.replace(/^\//, '').replace(/\//g, '-')}--`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private firstString(...values: unknown[]): string | undefined {
|
|
494
|
+
for (const value of values) {
|
|
495
|
+
if (typeof value === 'string' && value) return value;
|
|
496
|
+
}
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private parseTimestamp(value?: string): Date | null {
|
|
501
|
+
if (!value) return null;
|
|
502
|
+
const timestamp = new Date(value);
|
|
503
|
+
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private mapSessionToAgent(session: PiSession, processInfo: ProcessInfo, filePath: string): AgentInfo {
|
|
507
|
+
const projectPath = session.projectPath || processInfo.cwd || '';
|
|
508
|
+
return {
|
|
509
|
+
name: generateAgentName(projectPath, processInfo.pid),
|
|
510
|
+
type: this.type,
|
|
511
|
+
status: this.determineStatus(session),
|
|
512
|
+
summary: session.summary || 'Pi session active',
|
|
513
|
+
pid: processInfo.pid,
|
|
514
|
+
projectPath,
|
|
515
|
+
sessionId: session.sessionId,
|
|
516
|
+
lastActive: session.lastActive,
|
|
517
|
+
sessionFilePath: filePath,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
|
|
522
|
+
return {
|
|
523
|
+
name: generateAgentName(processInfo.cwd || '', processInfo.pid),
|
|
524
|
+
type: this.type,
|
|
525
|
+
status: AgentStatus.RUNNING,
|
|
526
|
+
summary: 'Pi process running',
|
|
527
|
+
pid: processInfo.pid,
|
|
528
|
+
projectPath: processInfo.cwd || '',
|
|
529
|
+
sessionId: `pid-${processInfo.pid}`,
|
|
530
|
+
lastActive: new Date(),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private determineStatus(session: PiSession): AgentStatus {
|
|
535
|
+
const diffMs = Date.now() - session.lastActive.getTime();
|
|
536
|
+
const diffMinutes = diffMs / 60000;
|
|
537
|
+
|
|
538
|
+
if (diffMinutes > PiAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE;
|
|
539
|
+
if (session.lastRole === 'assistant') return AgentStatus.WAITING;
|
|
540
|
+
return AgentStatus.RUNNING;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private truncate(value: string, maxLength: number): string {
|
|
544
|
+
if (value.length <= maxLength) return value;
|
|
545
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private isPiExecutable(command: string): boolean {
|
|
549
|
+
for (const token of command.trim().split(/\s+/)) {
|
|
550
|
+
const base = path.basename(token).toLowerCase();
|
|
551
|
+
if (base === 'pi' || base === 'pi.exe' || base === 'pi.js') return true;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
557
|
+
const includeSystem = options?.verbose ?? false;
|
|
558
|
+
return this.entriesToMessages(this.readJsonl(sessionFilePath), includeSystem);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private entriesToMessages(entries: PiLine[], includeSystem: boolean): ConversationMessage[] {
|
|
562
|
+
return entries
|
|
563
|
+
.map((entry) => this.entryToMessage(entry, includeSystem))
|
|
564
|
+
.filter((msg): msg is ConversationMessage => msg !== null);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
568
|
+
if (!isDirectory(this.piSessionsDir)) return [];
|
|
569
|
+
|
|
570
|
+
const summaries: SessionSummary[] = [];
|
|
571
|
+
for (const filePath of this.collectJsonlFiles(this.piSessionsDir)) {
|
|
572
|
+
const summary = this.fileToSessionSummary(filePath);
|
|
573
|
+
if (!summary) continue;
|
|
574
|
+
if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue;
|
|
575
|
+
summaries.push(summary);
|
|
576
|
+
}
|
|
577
|
+
return summaries;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private fileToSessionSummary(filePath: string): SessionSummary | null {
|
|
581
|
+
const entries = this.readJsonl(filePath);
|
|
582
|
+
if (entries.length === 0) return null;
|
|
583
|
+
|
|
584
|
+
const session = this.sessionFromEntries(entries, filePath);
|
|
585
|
+
const firstUserMessage = this.entriesToMessages(entries, false)
|
|
586
|
+
.find((msg) => msg.role === 'user')?.content ?? '';
|
|
587
|
+
return {
|
|
588
|
+
type: this.type,
|
|
589
|
+
sessionId: session.sessionId,
|
|
590
|
+
cwd: session.projectPath,
|
|
591
|
+
firstUserMessage,
|
|
592
|
+
lastActive: session.lastActive,
|
|
593
|
+
startedAt: session.sessionStart,
|
|
594
|
+
sessionFilePath: filePath,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
package/src/adapters/index.ts
CHANGED
|
@@ -3,5 +3,6 @@ export { CodexAdapter } from './CodexAdapter.js';
|
|
|
3
3
|
export { CopilotAdapter } from './CopilotAdapter.js';
|
|
4
4
|
export { GeminiCliAdapter } from './GeminiCliAdapter.js';
|
|
5
5
|
export { OpenCodeAdapter } from './OpenCodeAdapter.js';
|
|
6
|
+
export { PiAdapter } from './PiAdapter.js';
|
|
6
7
|
export { AgentStatus } from './AgentAdapter.js';
|
|
7
8
|
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js';
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { CodexAdapter } from './adapters/CodexAdapter.js';
|
|
|
5
5
|
export { CopilotAdapter } from './adapters/CopilotAdapter.js';
|
|
6
6
|
export { GeminiCliAdapter } from './adapters/GeminiCliAdapter.js';
|
|
7
7
|
export { OpenCodeAdapter } from './adapters/OpenCodeAdapter.js';
|
|
8
|
+
export { PiAdapter } from './adapters/PiAdapter.js';
|
|
8
9
|
export { AgentStatus } from './adapters/AgentAdapter.js';
|
|
9
10
|
export type {
|
|
10
11
|
AgentAdapter,
|
|
@@ -98,8 +98,8 @@ export class TerminalFocusManager {
|
|
|
98
98
|
|
|
99
99
|
private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
|
|
100
100
|
try {
|
|
101
|
-
// Check if iTerm2 is running first to avoid launching it
|
|
102
|
-
await
|
|
101
|
+
// Check if iTerm2 is running first to avoid launching it.
|
|
102
|
+
if (!await this.isProcessRunning('iTerm2')) return null;
|
|
103
103
|
} catch {
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
@@ -137,7 +137,7 @@ export class TerminalFocusManager {
|
|
|
137
137
|
private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
|
|
138
138
|
try {
|
|
139
139
|
// Check if Terminal.app is running
|
|
140
|
-
await
|
|
140
|
+
if (!await this.isProcessRunning('Terminal')) return null;
|
|
141
141
|
} catch {
|
|
142
142
|
return null;
|
|
143
143
|
}
|
|
@@ -170,6 +170,14 @@ export class TerminalFocusManager {
|
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
private async isProcessRunning(name: string): Promise<boolean> {
|
|
174
|
+
const { stdout } = await execFileAsync('ps', ['-Axo', 'comm']);
|
|
175
|
+
return stdout
|
|
176
|
+
.split('\n')
|
|
177
|
+
.map((line) => line.trim())
|
|
178
|
+
.some((command) => command === name || command.endsWith(`/${name}`));
|
|
179
|
+
}
|
|
180
|
+
|
|
173
181
|
private async focusTmuxPane(identifier: string): Promise<boolean> {
|
|
174
182
|
try {
|
|
175
183
|
await execFileAsync('tmux', ['switch-client', '-t', identifier]);
|
package/src/utils/agents.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import type { AgentType } from '../adapters/AgentAdapter.js';
|
|
3
3
|
|
|
4
|
-
export type StartableAgentType = Extract<AgentType, 'claude' | 'codex' | 'gemini_cli' | 'opencode'>;
|
|
4
|
+
export type StartableAgentType = Extract<AgentType, 'claude' | 'codex' | 'copilot' | 'gemini_cli' | 'opencode' | 'pi'>;
|
|
5
5
|
|
|
6
6
|
export interface AgentConfig {
|
|
7
7
|
/** Shell command to launch the agent (sent to tmux via `send-keys`). */
|
|
@@ -18,8 +18,10 @@ export interface AgentConfig {
|
|
|
18
18
|
export const AGENTS: Record<StartableAgentType, AgentConfig> = {
|
|
19
19
|
claude: { command: 'claude', matches: matchArgv0('claude') },
|
|
20
20
|
codex: { command: 'codex', matches: matchArgv0('codex') },
|
|
21
|
-
|
|
21
|
+
copilot: { command: 'copilot', matches: matchArgv0Name('copilot-cli') },
|
|
22
22
|
gemini_cli: { command: 'gemini', matches: matchAnyToken('gemini') },
|
|
23
|
+
opencode: { command: 'opencode', matches: matchArgv0('opencode') },
|
|
24
|
+
pi: { command: 'pi', matches: matchAnyBasename(['pi']) },
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
function matchArgv0(name: string): (psCommand: string) => boolean {
|
|
@@ -30,6 +32,14 @@ function matchArgv0(name: string): (psCommand: string) => boolean {
|
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function matchArgv0Name(name: string): (psCommand: string) => boolean {
|
|
36
|
+
const lower = name.toLowerCase();
|
|
37
|
+
return (psCommand) => {
|
|
38
|
+
const token = psCommand.trim().split(/\s+/)[0];
|
|
39
|
+
return token ? token.toLowerCase().includes(lower) : false;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
function matchAnyToken(name: string): (psCommand: string) => boolean {
|
|
34
44
|
const lower = name.toLowerCase();
|
|
35
45
|
return (psCommand) => {
|
|
@@ -39,3 +49,13 @@ function matchAnyToken(name: string): (psCommand: string) => boolean {
|
|
|
39
49
|
return false;
|
|
40
50
|
};
|
|
41
51
|
}
|
|
52
|
+
|
|
53
|
+
function matchAnyBasename(names: string[]): (psCommand: string) => boolean {
|
|
54
|
+
const lowers = new Set(names.map((name) => name.toLowerCase()));
|
|
55
|
+
return (psCommand) => {
|
|
56
|
+
for (const token of psCommand.trim().split(/\s+/)) {
|
|
57
|
+
if (lowers.has(path.basename(token).toLowerCase())) return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
}
|