@ai-devkit/agent-manager 0.3.0 → 0.5.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/AgentAdapter.d.ts +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +49 -38
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +286 -293
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +32 -30
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +148 -284
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +6 -3
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +12 -11
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +39 -0
- package/dist/utils/matching.d.ts.map +1 -0
- package/dist/utils/matching.js +103 -0
- package/dist/utils/matching.js.map +1 -0
- package/dist/utils/process.d.ts +25 -40
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +151 -105
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +30 -0
- package/dist/utils/session.d.ts.map +1 -0
- package/dist/utils/session.js +101 -0
- package/dist/utils/session.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/AgentManager.test.ts +0 -25
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
- package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
- package/src/__tests__/utils/matching.test.ts +191 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/ClaudeCodeAdapter.ts +341 -418
- package/src/adapters/CodexAdapter.ts +155 -420
- package/src/index.ts +1 -3
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +92 -0
- package/src/utils/process.ts +133 -119
- package/src/utils/session.ts +92 -0
- package/dist/utils/file.d.ts +0 -52
- package/dist/utils/file.d.ts.map +0 -1
- package/dist/utils/file.js +0 -135
- package/dist/utils/file.js.map +0 -1
- package/src/utils/file.ts +0 -100
|
@@ -1,61 +1,46 @@
|
|
|
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
1
|
import * as fs from 'fs';
|
|
9
2
|
import * as path from 'path';
|
|
10
3
|
import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
11
4
|
import { AgentStatus } from './AgentAdapter';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* Structure of ~/.claude/projects/{path}/sessions-index.json
|
|
17
|
-
*/
|
|
18
|
-
interface SessionsIndex {
|
|
19
|
-
originalPath: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
enum SessionEntryType {
|
|
23
|
-
ASSISTANT = 'assistant',
|
|
24
|
-
USER = 'user',
|
|
25
|
-
PROGRESS = 'progress',
|
|
26
|
-
THINKING = 'thinking',
|
|
27
|
-
SYSTEM = 'system',
|
|
28
|
-
MESSAGE = 'message',
|
|
29
|
-
TEXT = 'text',
|
|
30
|
-
}
|
|
31
|
-
|
|
5
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
6
|
+
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
7
|
+
import type { SessionFile } from '../utils/session';
|
|
8
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
32
9
|
/**
|
|
33
10
|
* Entry in session JSONL file
|
|
34
11
|
*/
|
|
35
12
|
interface SessionEntry {
|
|
36
|
-
type?:
|
|
13
|
+
type?: string;
|
|
37
14
|
timestamp?: string;
|
|
38
15
|
slug?: string;
|
|
39
16
|
cwd?: string;
|
|
40
|
-
sessionId?: string;
|
|
41
17
|
message?: {
|
|
42
|
-
content?: Array<{
|
|
18
|
+
content?: string | Array<{
|
|
43
19
|
type?: string;
|
|
44
20
|
text?: string;
|
|
45
21
|
content?: string;
|
|
46
22
|
}>;
|
|
47
23
|
};
|
|
48
|
-
[key: string]: unknown;
|
|
49
24
|
}
|
|
50
25
|
|
|
51
26
|
/**
|
|
52
|
-
* Entry in ~/.claude/
|
|
27
|
+
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code
|
|
53
28
|
*/
|
|
54
|
-
interface
|
|
55
|
-
|
|
56
|
-
timestamp: number;
|
|
57
|
-
project: string;
|
|
29
|
+
interface PidFileEntry {
|
|
30
|
+
pid: number;
|
|
58
31
|
sessionId: string;
|
|
32
|
+
cwd: string;
|
|
33
|
+
startedAt: number; // epoch milliseconds
|
|
34
|
+
kind: string;
|
|
35
|
+
entrypoint: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A process directly matched to a session via PID file (authoritative path)
|
|
40
|
+
*/
|
|
41
|
+
interface DirectMatch {
|
|
42
|
+
process: ProcessInfo;
|
|
43
|
+
sessionFile: SessionFile;
|
|
59
44
|
}
|
|
60
45
|
|
|
61
46
|
/**
|
|
@@ -66,466 +51,386 @@ interface ClaudeSession {
|
|
|
66
51
|
projectPath: string;
|
|
67
52
|
lastCwd?: string;
|
|
68
53
|
slug?: string;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
sessionStart: Date;
|
|
55
|
+
lastActive: Date;
|
|
56
|
+
lastEntryType?: string;
|
|
57
|
+
isInterrupted: boolean;
|
|
58
|
+
lastUserMessage?: string;
|
|
72
59
|
}
|
|
73
60
|
|
|
74
|
-
type SessionMatchMode = 'cwd' | 'project-parent';
|
|
75
|
-
|
|
76
61
|
/**
|
|
77
62
|
* Claude Code Adapter
|
|
78
|
-
*
|
|
63
|
+
*
|
|
79
64
|
* Detects Claude Code agents by:
|
|
80
|
-
* 1. Finding running claude processes
|
|
81
|
-
* 2.
|
|
82
|
-
* 3.
|
|
83
|
-
* 4.
|
|
84
|
-
* 5. Extracting summary from
|
|
65
|
+
* 1. Finding running claude processes via shared listAgentProcesses()
|
|
66
|
+
* 2. Enriching with CWD and start times via shared enrichProcesses()
|
|
67
|
+
* 3. Attempting authoritative PID-file matching via ~/.claude/sessions/<pid>.json
|
|
68
|
+
* 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file
|
|
69
|
+
* 5. Extracting summary from last user message in session JSONL
|
|
85
70
|
*/
|
|
86
71
|
export class ClaudeCodeAdapter implements AgentAdapter {
|
|
87
72
|
readonly type = 'claude' as const;
|
|
88
73
|
|
|
89
|
-
/** Threshold in minutes before considering a session idle */
|
|
90
|
-
private static readonly IDLE_THRESHOLD_MINUTES = 5;
|
|
91
|
-
|
|
92
|
-
private claudeDir: string;
|
|
93
74
|
private projectsDir: string;
|
|
94
|
-
private
|
|
75
|
+
private sessionsDir: string;
|
|
95
76
|
|
|
96
77
|
constructor() {
|
|
97
78
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
-
this.historyPath = path.join(this.claudeDir, 'history.jsonl');
|
|
79
|
+
this.projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
80
|
+
this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
|
101
81
|
}
|
|
102
82
|
|
|
103
83
|
/**
|
|
104
84
|
* Check if this adapter can handle a given process
|
|
105
85
|
*/
|
|
106
86
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
107
|
-
return processInfo.command
|
|
87
|
+
return this.isClaudeExecutable(processInfo.command);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isClaudeExecutable(command: string): boolean {
|
|
91
|
+
const executable = command.trim().split(/\s+/)[0] || '';
|
|
92
|
+
const base = path.basename(executable).toLowerCase();
|
|
93
|
+
return base === 'claude' || base === 'claude.exe';
|
|
108
94
|
}
|
|
109
95
|
|
|
110
96
|
/**
|
|
111
97
|
* Detect running Claude Code agents
|
|
112
98
|
*/
|
|
113
99
|
async detectAgents(): Promise<AgentInfo[]> {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
if (claudeProcesses.length === 0) {
|
|
100
|
+
const processes = enrichProcesses(listAgentProcesses('claude'));
|
|
101
|
+
if (processes.length === 0) {
|
|
119
102
|
return [];
|
|
120
103
|
}
|
|
121
104
|
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
105
|
+
// Step 1: try authoritative PID-file matching for every process
|
|
106
|
+
const { direct, fallback } = this.tryPidFileMatching(processes);
|
|
107
|
+
|
|
108
|
+
// Step 2: run legacy CWD+birthtime matching only for processes without a PID file
|
|
109
|
+
const legacySessions = this.discoverSessions(fallback);
|
|
110
|
+
const legacyMatches =
|
|
111
|
+
fallback.length > 0 && legacySessions.length > 0
|
|
112
|
+
? matchProcessesToSessions(fallback, legacySessions)
|
|
113
|
+
: [];
|
|
129
114
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
115
|
+
const matchedPids = new Set([
|
|
116
|
+
...direct.map((d) => d.process.pid),
|
|
117
|
+
...legacyMatches.map((m) => m.process.pid),
|
|
118
|
+
]);
|
|
135
119
|
|
|
136
|
-
const usedSessionIds = new Set<string>();
|
|
137
|
-
const assignedPids = new Set<number>();
|
|
138
120
|
const agents: AgentInfo[] = [];
|
|
139
121
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
usedSessionIds,
|
|
161
|
-
assignedPids,
|
|
162
|
-
historyBySessionId,
|
|
163
|
-
agents,
|
|
164
|
-
);
|
|
165
|
-
for (const processInfo of claudeProcesses) {
|
|
166
|
-
if (assignedPids.has(processInfo.pid)) {
|
|
167
|
-
continue;
|
|
122
|
+
// Build agents from direct (PID-file) matches
|
|
123
|
+
for (const { process: proc, sessionFile } of direct) {
|
|
124
|
+
const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
125
|
+
if (sessionData) {
|
|
126
|
+
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
|
|
127
|
+
} else {
|
|
128
|
+
matchedPids.delete(proc.pid);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build agents from legacy matches
|
|
133
|
+
for (const match of legacyMatches) {
|
|
134
|
+
const sessionData = this.readSession(
|
|
135
|
+
match.session.filePath,
|
|
136
|
+
match.session.resolvedCwd,
|
|
137
|
+
);
|
|
138
|
+
if (sessionData) {
|
|
139
|
+
agents.push(this.mapSessionToAgent(sessionData, match.process, match.session));
|
|
140
|
+
} else {
|
|
141
|
+
matchedPids.delete(match.process.pid);
|
|
168
142
|
}
|
|
143
|
+
}
|
|
169
144
|
|
|
170
|
-
|
|
171
|
-
|
|
145
|
+
// Any process with no match (direct or legacy) appears as IDLE
|
|
146
|
+
for (const proc of processes) {
|
|
147
|
+
if (!matchedPids.has(proc.pid)) {
|
|
148
|
+
agents.push(this.mapProcessOnlyAgent(proc));
|
|
149
|
+
}
|
|
172
150
|
}
|
|
173
151
|
|
|
174
152
|
return agents;
|
|
175
153
|
}
|
|
176
154
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Discover session files for the given processes.
|
|
157
|
+
*
|
|
158
|
+
* For each unique process CWD, encodes it to derive the expected
|
|
159
|
+
* ~/.claude/projects/<encoded>/ directory, then gets session file birthtimes
|
|
160
|
+
* via a single batched stat call across all directories.
|
|
161
|
+
*/
|
|
162
|
+
private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
|
|
163
|
+
// Collect valid project dirs and map them back to their CWD
|
|
164
|
+
const dirToCwd = new Map<string, string>();
|
|
165
|
+
|
|
166
|
+
for (const proc of processes) {
|
|
167
|
+
if (!proc.cwd) continue;
|
|
168
|
+
|
|
169
|
+
const projectDir = this.getProjectDir(proc.cwd);
|
|
170
|
+
if (dirToCwd.has(projectDir)) continue;
|
|
188
171
|
|
|
189
|
-
|
|
190
|
-
|
|
172
|
+
try {
|
|
173
|
+
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
174
|
+
} catch {
|
|
191
175
|
continue;
|
|
192
176
|
}
|
|
193
177
|
|
|
194
|
-
|
|
195
|
-
usedSessionIds.add(historyEntry.sessionId);
|
|
196
|
-
agents.push(this.mapHistoryToAgent(processInfo, historyEntry, agents));
|
|
178
|
+
dirToCwd.set(projectDir, proc.cwd);
|
|
197
179
|
}
|
|
198
|
-
}
|
|
199
180
|
|
|
200
|
-
|
|
201
|
-
mode: SessionMatchMode,
|
|
202
|
-
claudeProcesses: ProcessInfo[],
|
|
203
|
-
sessions: ClaudeSession[],
|
|
204
|
-
usedSessionIds: Set<string>,
|
|
205
|
-
assignedPids: Set<number>,
|
|
206
|
-
historyBySessionId: Map<string, HistoryEntry>,
|
|
207
|
-
agents: AgentInfo[],
|
|
208
|
-
): void {
|
|
209
|
-
for (const processInfo of claudeProcesses) {
|
|
210
|
-
if (assignedPids.has(processInfo.pid)) {
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
181
|
+
if (dirToCwd.size === 0) return [];
|
|
213
182
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
183
|
+
// Single batched stat call across all directories
|
|
184
|
+
const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
|
|
218
185
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
186
|
+
// Set resolvedCwd based on which project dir the file belongs to
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
|
|
222
189
|
}
|
|
223
|
-
}
|
|
224
190
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
sessions: ClaudeSession[],
|
|
228
|
-
usedSessionIds: Set<string>,
|
|
229
|
-
mode: SessionMatchMode,
|
|
230
|
-
): ClaudeSession | null {
|
|
231
|
-
const candidates = sessions.filter((session) => {
|
|
232
|
-
if (usedSessionIds.has(session.sessionId)) {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
191
|
+
return files;
|
|
192
|
+
}
|
|
235
193
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Attempt to match each process to its session via ~/.claude/sessions/<pid>.json.
|
|
196
|
+
*
|
|
197
|
+
* Returns:
|
|
198
|
+
* direct — processes matched authoritatively via PID file
|
|
199
|
+
* fallback — processes with no valid PID file (sent to legacy matching)
|
|
200
|
+
*
|
|
201
|
+
* Per-process fallback triggers on: file absent, malformed JSON,
|
|
202
|
+
* stale startedAt (>60 s from proc.startTime), or missing JSONL.
|
|
203
|
+
*/
|
|
204
|
+
private tryPidFileMatching(processes: ProcessInfo[]): {
|
|
205
|
+
direct: DirectMatch[];
|
|
206
|
+
fallback: ProcessInfo[];
|
|
207
|
+
} {
|
|
208
|
+
const direct: DirectMatch[] = [];
|
|
209
|
+
const fallback: ProcessInfo[] = [];
|
|
240
210
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
211
|
+
for (const proc of processes) {
|
|
212
|
+
const pidFilePath = path.join(this.sessionsDir, `${proc.pid}.json`);
|
|
213
|
+
try {
|
|
214
|
+
const entry = JSON.parse(
|
|
215
|
+
fs.readFileSync(pidFilePath, 'utf-8'),
|
|
216
|
+
) as PidFileEntry;
|
|
217
|
+
|
|
218
|
+
// Stale-file guard: reject PID files from a previous process with the same PID
|
|
219
|
+
if (proc.startTime) {
|
|
220
|
+
const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
|
|
221
|
+
if (deltaMs > 60000) {
|
|
222
|
+
fallback.push(proc);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
245
226
|
|
|
246
|
-
|
|
247
|
-
|
|
227
|
+
const projectDir = this.getProjectDir(entry.cwd);
|
|
228
|
+
const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`);
|
|
248
229
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
230
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
231
|
+
fallback.push(proc);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
252
234
|
|
|
253
|
-
|
|
254
|
-
|
|
235
|
+
direct.push({
|
|
236
|
+
process: proc,
|
|
237
|
+
sessionFile: {
|
|
238
|
+
sessionId: entry.sessionId,
|
|
239
|
+
filePath: jsonlPath,
|
|
240
|
+
projectDir,
|
|
241
|
+
birthtimeMs: entry.startedAt,
|
|
242
|
+
resolvedCwd: entry.cwd,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
// PID file absent, unreadable, or malformed — fall back per-process
|
|
247
|
+
fallback.push(proc);
|
|
248
|
+
}
|
|
255
249
|
}
|
|
256
250
|
|
|
257
|
-
return
|
|
258
|
-
|
|
259
|
-
const depthB = Math.max(this.pathDepth(b.projectPath), this.pathDepth(b.lastCwd));
|
|
260
|
-
if (depthA !== depthB) {
|
|
261
|
-
return depthB - depthA;
|
|
262
|
-
}
|
|
251
|
+
return { direct, fallback };
|
|
252
|
+
}
|
|
263
253
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Derive the Claude Code project directory for a given CWD.
|
|
256
|
+
*
|
|
257
|
+
* Claude Code encodes paths by replacing '/' with '-':
|
|
258
|
+
* /Users/foo/bar → ~/.claude/projects/-Users-foo-bar/
|
|
259
|
+
*/
|
|
260
|
+
private getProjectDir(cwd: string): string {
|
|
261
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
262
|
+
return path.join(this.projectsDir, encoded);
|
|
268
263
|
}
|
|
269
264
|
|
|
270
265
|
private mapSessionToAgent(
|
|
271
266
|
session: ClaudeSession,
|
|
272
267
|
processInfo: ProcessInfo,
|
|
273
|
-
|
|
274
|
-
existingAgents: AgentInfo[],
|
|
268
|
+
sessionFile: SessionFile,
|
|
275
269
|
): AgentInfo {
|
|
276
|
-
const historyEntry = historyBySessionId.get(session.sessionId);
|
|
277
|
-
|
|
278
270
|
return {
|
|
279
|
-
name:
|
|
271
|
+
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
280
272
|
type: this.type,
|
|
281
273
|
status: this.determineStatus(session),
|
|
282
|
-
summary:
|
|
274
|
+
summary: session.lastUserMessage || 'Session started',
|
|
283
275
|
pid: processInfo.pid,
|
|
284
|
-
projectPath:
|
|
285
|
-
sessionId:
|
|
276
|
+
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
277
|
+
sessionId: sessionFile.sessionId,
|
|
286
278
|
slug: session.slug,
|
|
287
|
-
lastActive: session.lastActive
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private mapProcessOnlyAgent(
|
|
292
|
-
processInfo: ProcessInfo,
|
|
293
|
-
existingAgents: AgentInfo[],
|
|
294
|
-
historyByProjectPath: Map<string, HistoryEntry[]>,
|
|
295
|
-
usedSessionIds: Set<string>,
|
|
296
|
-
): AgentInfo {
|
|
297
|
-
const projectPath = processInfo.cwd || '';
|
|
298
|
-
const historyEntry = this.selectHistoryForProcess(projectPath, historyByProjectPath, usedSessionIds);
|
|
299
|
-
const sessionId = historyEntry?.sessionId || `pid-${processInfo.pid}`;
|
|
300
|
-
const lastActive = historyEntry ? new Date(historyEntry.timestamp) : new Date();
|
|
301
|
-
if (historyEntry) {
|
|
302
|
-
usedSessionIds.add(historyEntry.sessionId);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const processSession: ClaudeSession = {
|
|
306
|
-
sessionId,
|
|
307
|
-
projectPath,
|
|
308
|
-
lastCwd: projectPath,
|
|
309
|
-
sessionLogPath: '',
|
|
310
|
-
lastActive,
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
return {
|
|
314
|
-
name: this.generateAgentName(processSession, existingAgents),
|
|
315
|
-
type: this.type,
|
|
316
|
-
status: AgentStatus.RUNNING,
|
|
317
|
-
summary: historyEntry?.display || 'Claude process running',
|
|
318
|
-
pid: processInfo.pid,
|
|
319
|
-
projectPath,
|
|
320
|
-
sessionId: processSession.sessionId,
|
|
321
|
-
lastActive: processSession.lastActive || new Date(),
|
|
279
|
+
lastActive: session.lastActive,
|
|
322
280
|
};
|
|
323
281
|
}
|
|
324
282
|
|
|
325
|
-
private
|
|
326
|
-
processInfo: ProcessInfo,
|
|
327
|
-
historyEntry: HistoryEntry,
|
|
328
|
-
existingAgents: AgentInfo[],
|
|
329
|
-
): AgentInfo {
|
|
330
|
-
const projectPath = processInfo.cwd || historyEntry.project;
|
|
331
|
-
const historySession: ClaudeSession = {
|
|
332
|
-
sessionId: historyEntry.sessionId,
|
|
333
|
-
projectPath,
|
|
334
|
-
lastCwd: projectPath,
|
|
335
|
-
sessionLogPath: '',
|
|
336
|
-
lastActive: new Date(historyEntry.timestamp),
|
|
337
|
-
};
|
|
338
|
-
|
|
283
|
+
private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
|
|
339
284
|
return {
|
|
340
|
-
name:
|
|
285
|
+
name: generateAgentName(processInfo.cwd || '', processInfo.pid),
|
|
341
286
|
type: this.type,
|
|
342
|
-
status: AgentStatus.
|
|
343
|
-
summary:
|
|
287
|
+
status: AgentStatus.IDLE,
|
|
288
|
+
summary: 'Unknown',
|
|
344
289
|
pid: processInfo.pid,
|
|
345
|
-
projectPath,
|
|
346
|
-
sessionId:
|
|
347
|
-
lastActive:
|
|
290
|
+
projectPath: processInfo.cwd || '',
|
|
291
|
+
sessionId: `pid-${processInfo.pid}`,
|
|
292
|
+
lastActive: new Date(),
|
|
348
293
|
};
|
|
349
294
|
}
|
|
350
295
|
|
|
351
|
-
private indexHistoryByProjectPath(historyEntries: HistoryEntry[]): Map<string, HistoryEntry[]> {
|
|
352
|
-
const grouped = new Map<string, HistoryEntry[]>();
|
|
353
|
-
|
|
354
|
-
for (const entry of historyEntries) {
|
|
355
|
-
const key = this.normalizePath(entry.project);
|
|
356
|
-
const list = grouped.get(key) || [];
|
|
357
|
-
list.push(entry);
|
|
358
|
-
grouped.set(key, list);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
for (const [key, list] of grouped.entries()) {
|
|
362
|
-
grouped.set(
|
|
363
|
-
key,
|
|
364
|
-
[...list].sort((a, b) => b.timestamp - a.timestamp),
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return grouped;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private selectHistoryForProcess(
|
|
372
|
-
processCwd: string,
|
|
373
|
-
historyByProjectPath: Map<string, HistoryEntry[]>,
|
|
374
|
-
usedSessionIds: Set<string>,
|
|
375
|
-
): HistoryEntry | undefined {
|
|
376
|
-
if (!processCwd) {
|
|
377
|
-
return undefined;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const candidates = historyByProjectPath.get(this.normalizePath(processCwd)) || [];
|
|
381
|
-
return candidates.find((entry) => !usedSessionIds.has(entry.sessionId));
|
|
382
|
-
}
|
|
383
|
-
|
|
384
296
|
/**
|
|
385
|
-
*
|
|
297
|
+
* Parse a single session file into ClaudeSession
|
|
386
298
|
*/
|
|
387
|
-
private
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const sessions: ClaudeSession[] = [];
|
|
393
|
-
const projectDirs = fs.readdirSync(this.projectsDir);
|
|
394
|
-
|
|
395
|
-
for (const dirName of projectDirs) {
|
|
396
|
-
if (dirName.startsWith('.')) {
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const projectDir = path.join(this.projectsDir, dirName);
|
|
401
|
-
if (!fs.statSync(projectDir).isDirectory()) {
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
299
|
+
private readSession(
|
|
300
|
+
filePath: string,
|
|
301
|
+
projectPath: string,
|
|
302
|
+
): ClaudeSession | null {
|
|
303
|
+
const sessionId = path.basename(filePath, '.jsonl');
|
|
404
304
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
305
|
+
let content: string;
|
|
306
|
+
try {
|
|
307
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
410
311
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
312
|
+
const allLines = content.trim().split('\n');
|
|
313
|
+
if (allLines.length === 0) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
416
316
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
lastCwd: sessionData.lastCwd,
|
|
430
|
-
slug: sessionData.slug,
|
|
431
|
-
sessionLogPath,
|
|
432
|
-
lastEntry: sessionData.lastEntry,
|
|
433
|
-
lastActive: sessionData.lastActive,
|
|
434
|
-
});
|
|
435
|
-
} catch (error) {
|
|
436
|
-
console.error(`Failed to read session ${sessionId}:`, error);
|
|
437
|
-
continue;
|
|
317
|
+
// Parse first line for sessionStart.
|
|
318
|
+
// Claude Code may emit a "file-history-snapshot" as the first entry, which
|
|
319
|
+
// stores its timestamp inside "snapshot.timestamp" rather than at the root.
|
|
320
|
+
let sessionStart: Date | null = null;
|
|
321
|
+
try {
|
|
322
|
+
const firstEntry = JSON.parse(allLines[0]);
|
|
323
|
+
const rawTs: string | undefined =
|
|
324
|
+
firstEntry.timestamp || firstEntry.snapshot?.timestamp;
|
|
325
|
+
if (rawTs) {
|
|
326
|
+
const ts = new Date(rawTs);
|
|
327
|
+
if (!Number.isNaN(ts.getTime())) {
|
|
328
|
+
sessionStart = ts;
|
|
438
329
|
}
|
|
439
330
|
}
|
|
331
|
+
} catch {
|
|
332
|
+
/* skip */
|
|
440
333
|
}
|
|
441
334
|
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Read a session JSONL file
|
|
447
|
-
* Only reads last 100 lines for performance with large files
|
|
448
|
-
*/
|
|
449
|
-
private readSessionLog(logPath: string): {
|
|
450
|
-
slug?: string;
|
|
451
|
-
lastEntry?: SessionEntry;
|
|
452
|
-
lastActive?: Date;
|
|
453
|
-
lastCwd?: string;
|
|
454
|
-
} {
|
|
455
|
-
const lines = readLastLines(logPath, 100);
|
|
456
|
-
|
|
335
|
+
// Parse all lines for session state (file already in memory)
|
|
457
336
|
let slug: string | undefined;
|
|
458
|
-
let
|
|
337
|
+
let lastEntryType: string | undefined;
|
|
459
338
|
let lastActive: Date | undefined;
|
|
460
339
|
let lastCwd: string | undefined;
|
|
340
|
+
let isInterrupted = false;
|
|
341
|
+
let lastUserMessage: string | undefined;
|
|
461
342
|
|
|
462
|
-
for (const line of
|
|
343
|
+
for (const line of allLines) {
|
|
463
344
|
try {
|
|
464
345
|
const entry: SessionEntry = JSON.parse(line);
|
|
465
346
|
|
|
466
|
-
if (entry.
|
|
467
|
-
|
|
347
|
+
if (entry.timestamp) {
|
|
348
|
+
const ts = new Date(entry.timestamp);
|
|
349
|
+
if (!Number.isNaN(ts.getTime())) {
|
|
350
|
+
lastActive = ts;
|
|
351
|
+
}
|
|
468
352
|
}
|
|
469
353
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (entry.timestamp) {
|
|
473
|
-
lastActive = new Date(entry.timestamp);
|
|
354
|
+
if (entry.slug && !slug) {
|
|
355
|
+
slug = entry.slug;
|
|
474
356
|
}
|
|
475
357
|
|
|
476
358
|
if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
|
|
477
359
|
lastCwd = entry.cwd;
|
|
478
360
|
}
|
|
479
|
-
|
|
361
|
+
|
|
362
|
+
if (entry.type && !this.isMetadataEntryType(entry.type)) {
|
|
363
|
+
lastEntryType = entry.type;
|
|
364
|
+
|
|
365
|
+
if (entry.type === 'user') {
|
|
366
|
+
const msgContent = entry.message?.content;
|
|
367
|
+
isInterrupted =
|
|
368
|
+
Array.isArray(msgContent) &&
|
|
369
|
+
msgContent.some(
|
|
370
|
+
(c) =>
|
|
371
|
+
(c.type === 'text' &&
|
|
372
|
+
c.text?.includes('[Request interrupted')) ||
|
|
373
|
+
(c.type === 'tool_result' &&
|
|
374
|
+
c.content?.includes('[Request interrupted')),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Extract user message text for summary fallback
|
|
378
|
+
const text = this.extractUserMessageText(msgContent);
|
|
379
|
+
if (text) {
|
|
380
|
+
lastUserMessage = text;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
isInterrupted = false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
480
387
|
continue;
|
|
481
388
|
}
|
|
482
389
|
}
|
|
483
390
|
|
|
484
|
-
return {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
391
|
+
return {
|
|
392
|
+
sessionId,
|
|
393
|
+
projectPath: projectPath || lastCwd || '',
|
|
394
|
+
lastCwd,
|
|
395
|
+
slug,
|
|
396
|
+
sessionStart: sessionStart || lastActive || new Date(),
|
|
397
|
+
lastActive: lastActive || new Date(),
|
|
398
|
+
lastEntryType,
|
|
399
|
+
isInterrupted,
|
|
400
|
+
lastUserMessage,
|
|
401
|
+
};
|
|
493
402
|
}
|
|
494
403
|
|
|
495
404
|
/**
|
|
496
|
-
* Determine agent status from session
|
|
405
|
+
* Determine agent status from session state
|
|
497
406
|
*/
|
|
498
407
|
private determineStatus(session: ClaudeSession): AgentStatus {
|
|
499
|
-
if (!session.
|
|
408
|
+
if (!session.lastEntryType) {
|
|
500
409
|
return AgentStatus.UNKNOWN;
|
|
501
410
|
}
|
|
502
411
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
412
|
+
// No age-based IDLE override: every agent in the list is backed by
|
|
413
|
+
// a running process (found via ps), so the entry type is the best
|
|
414
|
+
// indicator of actual state.
|
|
506
415
|
|
|
507
|
-
if (
|
|
508
|
-
return
|
|
416
|
+
if (session.lastEntryType === 'user') {
|
|
417
|
+
return session.isInterrupted
|
|
418
|
+
? AgentStatus.WAITING
|
|
419
|
+
: AgentStatus.RUNNING;
|
|
509
420
|
}
|
|
510
421
|
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const isInterrupted = content.some(c =>
|
|
516
|
-
(c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) ||
|
|
517
|
-
(c.type === 'tool_result' && c.content?.includes('[Request interrupted'))
|
|
518
|
-
);
|
|
519
|
-
if (isInterrupted) return AgentStatus.WAITING;
|
|
520
|
-
}
|
|
422
|
+
if (
|
|
423
|
+
session.lastEntryType === 'progress' ||
|
|
424
|
+
session.lastEntryType === 'thinking'
|
|
425
|
+
) {
|
|
521
426
|
return AgentStatus.RUNNING;
|
|
522
427
|
}
|
|
523
428
|
|
|
524
|
-
if (
|
|
525
|
-
return AgentStatus.RUNNING;
|
|
526
|
-
} else if (entryType === SessionEntryType.ASSISTANT) {
|
|
429
|
+
if (session.lastEntryType === 'assistant') {
|
|
527
430
|
return AgentStatus.WAITING;
|
|
528
|
-
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (session.lastEntryType === 'system') {
|
|
529
434
|
return AgentStatus.IDLE;
|
|
530
435
|
}
|
|
531
436
|
|
|
@@ -533,65 +438,83 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
533
438
|
}
|
|
534
439
|
|
|
535
440
|
/**
|
|
536
|
-
*
|
|
537
|
-
*
|
|
441
|
+
* Extract meaningful text from a user message content.
|
|
442
|
+
* Handles string and array formats, skill command expansion, and noise filtering.
|
|
538
443
|
*/
|
|
539
|
-
private
|
|
540
|
-
|
|
444
|
+
private extractUserMessageText(
|
|
445
|
+
content: string | Array<{ type?: string; text?: string }> | undefined,
|
|
446
|
+
): string | undefined {
|
|
447
|
+
if (!content) {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
541
450
|
|
|
542
|
-
|
|
543
|
-
a => a.projectPath === session.projectPath
|
|
544
|
-
);
|
|
451
|
+
let raw: string | undefined;
|
|
545
452
|
|
|
546
|
-
if (
|
|
547
|
-
|
|
453
|
+
if (typeof content === 'string') {
|
|
454
|
+
raw = content.trim();
|
|
455
|
+
} else if (Array.isArray(content)) {
|
|
456
|
+
for (const block of content) {
|
|
457
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
458
|
+
raw = block.text.trim();
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
548
462
|
}
|
|
549
463
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
// Use first word of slug for brevity (with safety check for format)
|
|
553
|
-
const slugPart = session.slug.includes('-')
|
|
554
|
-
? session.slug.split('-')[0]
|
|
555
|
-
: session.slug.slice(0, 8);
|
|
556
|
-
return `${projectName} (${slugPart})`;
|
|
464
|
+
if (!raw) {
|
|
465
|
+
return undefined;
|
|
557
466
|
}
|
|
558
467
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
private pathEquals(a?: string, b?: string): boolean {
|
|
564
|
-
if (!a || !b) {
|
|
565
|
-
return false;
|
|
468
|
+
// Skill slash-command: extract /command-name and args
|
|
469
|
+
if (raw.startsWith('<command-message>')) {
|
|
470
|
+
return this.parseCommandMessage(raw);
|
|
566
471
|
}
|
|
567
472
|
|
|
568
|
-
|
|
569
|
-
|
|
473
|
+
// Expanded skill content: extract ARGUMENTS line if present, skip otherwise
|
|
474
|
+
if (raw.startsWith('Base directory for this skill:')) {
|
|
475
|
+
const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
|
|
476
|
+
return argsMatch?.[1]?.trim() || undefined;
|
|
477
|
+
}
|
|
570
478
|
|
|
571
|
-
|
|
572
|
-
if (
|
|
573
|
-
return
|
|
479
|
+
// Filter noise
|
|
480
|
+
if (this.isNoiseMessage(raw)) {
|
|
481
|
+
return undefined;
|
|
574
482
|
}
|
|
575
483
|
|
|
576
|
-
|
|
577
|
-
const normalizedParent = this.normalizePath(parent);
|
|
578
|
-
return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
|
|
484
|
+
return raw;
|
|
579
485
|
}
|
|
580
486
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Parse a <command-message> string into "/command args" format.
|
|
489
|
+
*/
|
|
490
|
+
private parseCommandMessage(raw: string): string | undefined {
|
|
491
|
+
const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
|
|
492
|
+
const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
|
|
493
|
+
const name = nameMatch?.[1]?.trim();
|
|
494
|
+
if (!name) {
|
|
495
|
+
return undefined;
|
|
585
496
|
}
|
|
586
|
-
|
|
497
|
+
const args = argsMatch?.[1]?.trim();
|
|
498
|
+
return args ? `${name} ${args}` : name;
|
|
587
499
|
}
|
|
588
500
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Check if a message is noise (not a meaningful user intent).
|
|
503
|
+
*/
|
|
504
|
+
private isNoiseMessage(text: string): boolean {
|
|
505
|
+
return (
|
|
506
|
+
text.startsWith('[Request interrupted') ||
|
|
507
|
+
text === 'Tool loaded.' ||
|
|
508
|
+
text.startsWith('This session is being continued')
|
|
509
|
+
);
|
|
510
|
+
}
|
|
593
511
|
|
|
594
|
-
|
|
512
|
+
/**
|
|
513
|
+
* Check if an entry type is metadata (not conversation state).
|
|
514
|
+
* These should not overwrite lastEntryType used for status determination.
|
|
515
|
+
*/
|
|
516
|
+
private isMetadataEntryType(type: string): boolean {
|
|
517
|
+
return type === 'last-prompt' || type === 'file-history-snapshot';
|
|
595
518
|
}
|
|
596
519
|
|
|
597
520
|
}
|