@ai-devkit/agent-manager 0.11.0 → 0.13.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 +1 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +34 -2
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +145 -41
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/OpenCodeAdapter.d.ts +35 -0
- package/dist/adapters/OpenCodeAdapter.d.ts.map +1 -0
- package/dist/adapters/OpenCodeAdapter.js +329 -0
- package/dist/adapters/OpenCodeAdapter.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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +297 -1
- package/src/__tests__/adapters/OpenCodeAdapter.test.ts +475 -0
- package/src/adapters/AgentAdapter.ts +1 -1
- package/src/adapters/ClaudeCodeAdapter.ts +181 -44
- package/src/adapters/OpenCodeAdapter.ts +340 -0
- package/src/adapters/index.ts +1 -0
- package/src/index.ts +1 -0
|
@@ -28,14 +28,29 @@ interface PidFileEntry {
|
|
|
28
28
|
startedAt: number;
|
|
29
29
|
kind: string;
|
|
30
30
|
entrypoint: string;
|
|
31
|
+
/**
|
|
32
|
+
* Authoritative live status published by the Claude Code process
|
|
33
|
+
* (e.g., 'running', 'waiting', 'idle'). Preferred over JSONL-derived
|
|
34
|
+
* status because trailing entries like 'permission-mode' / 'ai-title'
|
|
35
|
+
* can mask the real conversational state.
|
|
36
|
+
*/
|
|
37
|
+
status?: string;
|
|
38
|
+
/** Short description of what the agent is waiting on (e.g., "approve Read"). */
|
|
39
|
+
waitingFor?: string;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/**
|
|
34
43
|
* A process directly matched to a session via PID file (authoritative path).
|
|
44
|
+
*
|
|
45
|
+
* When the matching PID file also exposes live status/waitingFor metadata,
|
|
46
|
+
* those values are carried here so `mapSessionToAgent` can prefer them
|
|
47
|
+
* over the JSONL-derived heuristic.
|
|
35
48
|
*/
|
|
36
49
|
interface DirectMatch {
|
|
37
50
|
process: ProcessInfo;
|
|
38
51
|
sessionFile: SessionFile;
|
|
52
|
+
pidStatus?: AgentStatus;
|
|
53
|
+
waitingFor?: string;
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
/** Maximum allowed delta (ms) between process start time and PID file startedAt. */
|
|
@@ -81,10 +96,17 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
81
96
|
return [];
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
// Step 1:
|
|
85
|
-
|
|
99
|
+
// Step 1: extract `--resume <id>` from command line — authoritative for
|
|
100
|
+
// resumed sessions where the JSONL predates the process and PID-file/
|
|
101
|
+
// birthtime heuristics can't match it.
|
|
102
|
+
const { direct: resumeDirect, fallback: noResume } = this.tryResumeMatching(processes);
|
|
103
|
+
|
|
104
|
+
// Step 2: try authoritative PID-file matching for the rest
|
|
105
|
+
const { direct: pidDirect, fallback } = this.tryPidFileMatching(noResume);
|
|
86
106
|
|
|
87
|
-
|
|
107
|
+
const direct = [...resumeDirect, ...pidDirect];
|
|
108
|
+
|
|
109
|
+
// Step 3: run legacy CWD+birthtime matching only for processes without a PID file
|
|
88
110
|
const legacySessions = this.discoverSessions(fallback);
|
|
89
111
|
const legacyMatches =
|
|
90
112
|
fallback.length > 0 && legacySessions.length > 0
|
|
@@ -98,11 +120,15 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
98
120
|
|
|
99
121
|
const agents: AgentInfo[] = [];
|
|
100
122
|
|
|
101
|
-
// Build agents from direct (PID-file) matches
|
|
102
|
-
for (const
|
|
123
|
+
// Build agents from direct (resume + PID-file) matches
|
|
124
|
+
for (const match of direct) {
|
|
125
|
+
const { process: proc, sessionFile } = match;
|
|
103
126
|
const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
104
127
|
if (sessionData) {
|
|
105
|
-
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile
|
|
128
|
+
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile, {
|
|
129
|
+
pidStatus: match.pidStatus,
|
|
130
|
+
waitingFor: match.waitingFor,
|
|
131
|
+
}));
|
|
106
132
|
} else {
|
|
107
133
|
matchedPids.delete(proc.pid);
|
|
108
134
|
}
|
|
@@ -167,6 +193,111 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
167
193
|
return files;
|
|
168
194
|
}
|
|
169
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Match processes via `claude --resume <uuid>` in their command line.
|
|
198
|
+
* This works for resumed sessions, where the JSONL was created earlier
|
|
199
|
+
* (so its birthtime is far from the process startTime and the legacy
|
|
200
|
+
* matcher can't pair them) and the PID file may also be misaligned.
|
|
201
|
+
*/
|
|
202
|
+
private tryResumeMatching(processes: ProcessInfo[]): {
|
|
203
|
+
direct: DirectMatch[];
|
|
204
|
+
fallback: ProcessInfo[];
|
|
205
|
+
} {
|
|
206
|
+
const direct: DirectMatch[] = [];
|
|
207
|
+
const fallback: ProcessInfo[] = [];
|
|
208
|
+
|
|
209
|
+
for (const proc of processes) {
|
|
210
|
+
const sessionId = this.extractResumeSessionId(proc.command);
|
|
211
|
+
if (!sessionId || !proc.cwd) {
|
|
212
|
+
fallback.push(proc);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const projectDir = this.getProjectDir(proc.cwd);
|
|
217
|
+
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
218
|
+
|
|
219
|
+
const stat = safeStat(jsonlPath);
|
|
220
|
+
if (!stat) {
|
|
221
|
+
fallback.push(proc);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Best-effort: the PID file (if present for this proc) is the
|
|
226
|
+
// authoritative source of live status. We still match the session
|
|
227
|
+
// via --resume, but we read the PID file alongside to capture
|
|
228
|
+
// status/waitingFor.
|
|
229
|
+
const pidEntry = this.readMatchingPidFile(proc.pid, proc.startTime);
|
|
230
|
+
|
|
231
|
+
direct.push({
|
|
232
|
+
process: proc,
|
|
233
|
+
sessionFile: {
|
|
234
|
+
sessionId,
|
|
235
|
+
filePath: jsonlPath,
|
|
236
|
+
projectDir,
|
|
237
|
+
birthtimeMs: stat.birthtimeMs,
|
|
238
|
+
resolvedCwd: proc.cwd,
|
|
239
|
+
},
|
|
240
|
+
pidStatus: this.mapPidStatus(pidEntry?.status),
|
|
241
|
+
waitingFor: pidEntry?.waitingFor,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { direct, fallback };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private extractResumeSessionId(command: string): string | null {
|
|
249
|
+
const match = command.match(/--resume\s+([0-9a-f-]{36})/i);
|
|
250
|
+
return match?.[1] ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read and parse ~/.claude/sessions/<pid>.json, returning null on any
|
|
255
|
+
* I/O / parse failure or when the file is stale relative to the live
|
|
256
|
+
* process.
|
|
257
|
+
*
|
|
258
|
+
* "Stale" means the PID file's startedAt diverges from the process's
|
|
259
|
+
* start time by more than {@link PID_FILE_STALENESS_MS} — typically
|
|
260
|
+
* a previous Claude Code process recycled the same PID without cleanup.
|
|
261
|
+
*/
|
|
262
|
+
private readMatchingPidFile(pid: number, procStartTime?: Date): PidFileEntry | null {
|
|
263
|
+
const pidFilePath = path.join(this.sessionsDir, `${pid}.json`);
|
|
264
|
+
try {
|
|
265
|
+
const entry = JSON.parse(
|
|
266
|
+
fs.readFileSync(pidFilePath, 'utf-8'),
|
|
267
|
+
) as PidFileEntry;
|
|
268
|
+
|
|
269
|
+
if (procStartTime) {
|
|
270
|
+
const deltaMs = Math.abs(procStartTime.getTime() - entry.startedAt);
|
|
271
|
+
if (deltaMs > PID_FILE_STALENESS_MS) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return entry;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Map the PID file's live status string to {@link AgentStatus}.
|
|
284
|
+
*
|
|
285
|
+
* Returns undefined for missing / unrecognized values so the caller
|
|
286
|
+
* can fall back to JSONL-derived heuristics.
|
|
287
|
+
*/
|
|
288
|
+
private mapPidStatus(status: string | undefined): AgentStatus | undefined {
|
|
289
|
+
switch (status) {
|
|
290
|
+
case 'running':
|
|
291
|
+
return AgentStatus.RUNNING;
|
|
292
|
+
case 'waiting':
|
|
293
|
+
return AgentStatus.WAITING;
|
|
294
|
+
case 'idle':
|
|
295
|
+
return AgentStatus.IDLE;
|
|
296
|
+
default:
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
170
301
|
/**
|
|
171
302
|
* Attempt to match each process to its session via ~/.claude/sessions/<pid>.json.
|
|
172
303
|
*
|
|
@@ -185,43 +316,32 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
185
316
|
const fallback: ProcessInfo[] = [];
|
|
186
317
|
|
|
187
318
|
for (const proc of processes) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
// Stale-file guard: reject PID files from a previous process with the same PID
|
|
195
|
-
if (proc.startTime) {
|
|
196
|
-
const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
|
|
197
|
-
if (deltaMs > PID_FILE_STALENESS_MS) {
|
|
198
|
-
fallback.push(proc);
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
319
|
+
const entry = this.readMatchingPidFile(proc.pid, proc.startTime);
|
|
320
|
+
if (!entry) {
|
|
321
|
+
fallback.push(proc);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
202
324
|
|
|
203
|
-
|
|
204
|
-
|
|
325
|
+
const projectDir = this.getProjectDir(entry.cwd);
|
|
326
|
+
const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`);
|
|
205
327
|
|
|
206
|
-
|
|
207
|
-
fallback.push(proc);
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
direct.push({
|
|
212
|
-
process: proc,
|
|
213
|
-
sessionFile: {
|
|
214
|
-
sessionId: entry.sessionId,
|
|
215
|
-
filePath: jsonlPath,
|
|
216
|
-
projectDir,
|
|
217
|
-
birthtimeMs: entry.startedAt,
|
|
218
|
-
resolvedCwd: entry.cwd,
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
} catch {
|
|
222
|
-
// PID file absent, unreadable, or malformed — fall back per-process
|
|
328
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
223
329
|
fallback.push(proc);
|
|
330
|
+
continue;
|
|
224
331
|
}
|
|
332
|
+
|
|
333
|
+
direct.push({
|
|
334
|
+
process: proc,
|
|
335
|
+
sessionFile: {
|
|
336
|
+
sessionId: entry.sessionId,
|
|
337
|
+
filePath: jsonlPath,
|
|
338
|
+
projectDir,
|
|
339
|
+
birthtimeMs: entry.startedAt,
|
|
340
|
+
resolvedCwd: entry.cwd,
|
|
341
|
+
},
|
|
342
|
+
pidStatus: this.mapPidStatus(entry.status),
|
|
343
|
+
waitingFor: entry.waitingFor,
|
|
344
|
+
});
|
|
225
345
|
}
|
|
226
346
|
|
|
227
347
|
return { direct, fallback };
|
|
@@ -230,11 +350,18 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
230
350
|
/**
|
|
231
351
|
* Derive the Claude Code project directory for a given CWD.
|
|
232
352
|
*
|
|
233
|
-
* Claude Code encodes paths by replacing
|
|
234
|
-
*
|
|
353
|
+
* Claude Code encodes paths by replacing every non-alphanumeric
|
|
354
|
+
* character with '-', so '/', '_', '.', spaces, etc. all collapse:
|
|
355
|
+
* /Users/foo/bar → -Users-foo-bar
|
|
356
|
+
* /Users/foo/my_project → -Users-foo-my-project
|
|
357
|
+
* /Users/foo/.worktrees/x → -Users-foo--worktrees-x
|
|
358
|
+
*
|
|
359
|
+
* The encoding is lossy — multiple real paths can collide on the
|
|
360
|
+
* same encoded dir. Callers that need to disambiguate must read the
|
|
361
|
+
* `cwd` field inside each session JSONL.
|
|
235
362
|
*/
|
|
236
363
|
private getProjectDir(cwd: string): string {
|
|
237
|
-
const encoded = cwd.replace(
|
|
364
|
+
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
238
365
|
return path.join(this.projectsDir, encoded);
|
|
239
366
|
}
|
|
240
367
|
|
|
@@ -242,12 +369,22 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
242
369
|
session: ClaudeSession,
|
|
243
370
|
processInfo: ProcessInfo,
|
|
244
371
|
sessionFile: SessionFile,
|
|
372
|
+
liveInfo?: { pidStatus?: AgentStatus; waitingFor?: string },
|
|
245
373
|
): AgentInfo {
|
|
374
|
+
// Live PID-file status is authoritative when present — JSONL-derived
|
|
375
|
+
// status mis-classifies sessions whose latest entry is a UI-state
|
|
376
|
+
// event like `permission-mode` or `ai-title`.
|
|
377
|
+
const status = liveInfo?.pidStatus ?? this.parser.determineStatus(session);
|
|
378
|
+
const baseSummary = session.lastUserMessage || 'Session started';
|
|
379
|
+
const summary = status === AgentStatus.WAITING && liveInfo?.waitingFor
|
|
380
|
+
? `${baseSummary} — waiting for ${liveInfo.waitingFor}`
|
|
381
|
+
: baseSummary;
|
|
382
|
+
|
|
246
383
|
return {
|
|
247
384
|
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
248
385
|
type: this.type,
|
|
249
|
-
status
|
|
250
|
-
summary
|
|
386
|
+
status,
|
|
387
|
+
summary,
|
|
251
388
|
pid: processInfo.pid,
|
|
252
389
|
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
253
390
|
sessionId: sessionFile.sessionId,
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Adapter
|
|
3
|
+
*
|
|
4
|
+
* Detects running OpenCode agents by:
|
|
5
|
+
* 1. Finding running opencode processes via shared listAgentProcesses()
|
|
6
|
+
* 2. Enriching with CWD and start times via shared enrichProcesses()
|
|
7
|
+
* 3. Querying OpenCode's SQLite DB (~/.local/share/opencode/opencode.db) to
|
|
8
|
+
* find the session matching each process's CWD and read status from message.time.completed
|
|
9
|
+
*
|
|
10
|
+
* sessionFilePath encodes "<dbPath>::<sessionId>" so getConversation() can open the right
|
|
11
|
+
* DB row without extending the AgentAdapter interface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import Database from 'better-sqlite3';
|
|
17
|
+
import type {
|
|
18
|
+
AgentAdapter,
|
|
19
|
+
AgentInfo,
|
|
20
|
+
ProcessInfo,
|
|
21
|
+
ConversationMessage,
|
|
22
|
+
SessionSummary,
|
|
23
|
+
ListSessionsOptions,
|
|
24
|
+
} from './AgentAdapter';
|
|
25
|
+
import { AgentStatus } from './AgentAdapter';
|
|
26
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
27
|
+
import { generateAgentName } from '../utils/matching';
|
|
28
|
+
|
|
29
|
+
const SESSION_REF_SEP = '::';
|
|
30
|
+
|
|
31
|
+
function encodeSessionRef(dbPath: string, sessionId: string): string {
|
|
32
|
+
return `${dbPath}${SESSION_REF_SEP}${sessionId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeSessionRef(ref: string): { dbPath: string; sessionId: string } | null {
|
|
36
|
+
const idx = ref.lastIndexOf(SESSION_REF_SEP);
|
|
37
|
+
if (idx === -1) return null;
|
|
38
|
+
return { dbPath: ref.slice(0, idx), sessionId: ref.slice(idx + SESSION_REF_SEP.length) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OpenCodeSession {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
directory: string;
|
|
44
|
+
timeCreated: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OpenCodeSessionStats {
|
|
48
|
+
lastRole: string | null;
|
|
49
|
+
lastTimeUpdated: number;
|
|
50
|
+
/** OpenCode writes `time.completed` on the assistant message only when the turn finishes. */
|
|
51
|
+
lastAssistantCompleted: boolean;
|
|
52
|
+
lastAssistantErrored: boolean;
|
|
53
|
+
summary: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class OpenCodeAdapter implements AgentAdapter {
|
|
57
|
+
readonly type = 'opencode' as const;
|
|
58
|
+
|
|
59
|
+
private static readonly IDLE_THRESHOLD_MINUTES = 5;
|
|
60
|
+
|
|
61
|
+
private readonly dbPath: string;
|
|
62
|
+
private db: Database.Database | null = null;
|
|
63
|
+
|
|
64
|
+
constructor() {
|
|
65
|
+
this.dbPath = OpenCodeAdapter.resolveDbPath();
|
|
66
|
+
const cleanup = (): void => this.close();
|
|
67
|
+
process.once('exit', cleanup);
|
|
68
|
+
process.once('SIGINT', cleanup);
|
|
69
|
+
process.once('SIGTERM', cleanup);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
if (this.db) {
|
|
74
|
+
try { this.db.close(); } catch { /* ignore */ }
|
|
75
|
+
this.db = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private static resolveDbPath(): string {
|
|
80
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
81
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
82
|
+
const base = xdg || path.join(home, '.local', 'share');
|
|
83
|
+
return path.join(base, 'opencode', 'opencode.db');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
87
|
+
const exe = (processInfo.command.trim().split(/\s+/)[0] || '').toLowerCase();
|
|
88
|
+
const base = path.basename(exe);
|
|
89
|
+
return base === 'opencode' || base === 'opencode.exe';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async detectAgents(): Promise<AgentInfo[]> {
|
|
93
|
+
const processes = enrichProcesses(listAgentProcesses('opencode'));
|
|
94
|
+
if (processes.length === 0) return [];
|
|
95
|
+
|
|
96
|
+
const db = this.openDb();
|
|
97
|
+
if (!db) return processes.map((p) => this.mapProcessOnlyAgent(p));
|
|
98
|
+
|
|
99
|
+
const agents: AgentInfo[] = [];
|
|
100
|
+
for (const proc of processes) {
|
|
101
|
+
if (!proc.cwd) {
|
|
102
|
+
agents.push(this.mapProcessOnlyAgent(proc));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const session = this.findSessionForDirectory(db, proc.cwd);
|
|
107
|
+
if (!session) {
|
|
108
|
+
agents.push(this.mapProcessOnlyAgent(proc));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stats = this.getSessionStats(db, session.sessionId);
|
|
113
|
+
agents.push(this.mapSessionToAgent(session, stats, proc));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return agents;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
120
|
+
const verbose = options?.verbose ?? false;
|
|
121
|
+
const ref = decodeSessionRef(sessionFilePath);
|
|
122
|
+
if (!ref) return [];
|
|
123
|
+
|
|
124
|
+
const db = this.openDb();
|
|
125
|
+
if (!db) return [];
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const rows = db.prepare<[string], { role: string; partData: string; timeCreated: number }>(`
|
|
129
|
+
SELECT json_extract(m.data, '$.role') AS role,
|
|
130
|
+
p.data AS partData,
|
|
131
|
+
p.time_created AS timeCreated
|
|
132
|
+
FROM part p
|
|
133
|
+
JOIN message m ON p.message_id = m.id
|
|
134
|
+
WHERE p.session_id = ?
|
|
135
|
+
ORDER BY p.time_created ASC
|
|
136
|
+
`).all(ref.sessionId);
|
|
137
|
+
|
|
138
|
+
const messages: ConversationMessage[] = [];
|
|
139
|
+
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
let partData: { type?: string; text?: string; reasoning?: string; tool?: string } = {};
|
|
142
|
+
try {
|
|
143
|
+
partData = JSON.parse(row.partData);
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const role = row.role === 'user' ? 'user' : 'assistant';
|
|
149
|
+
|
|
150
|
+
if (partData.type === 'text' && partData.text) {
|
|
151
|
+
messages.push({ role, content: partData.text });
|
|
152
|
+
} else if (partData.type === 'reasoning' && verbose) {
|
|
153
|
+
const text = partData.reasoning || partData.text || '';
|
|
154
|
+
if (text) messages.push({ role: 'assistant', content: `[thinking] ${text}` });
|
|
155
|
+
} else if (partData.type === 'tool' && verbose) {
|
|
156
|
+
const toolName = partData.tool || 'tool';
|
|
157
|
+
messages.push({ role: 'assistant', content: `[tool: ${toolName}]` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return messages;
|
|
162
|
+
} catch {
|
|
163
|
+
this.close();
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
169
|
+
const db = this.openDb();
|
|
170
|
+
if (!db) return [];
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const rows = db.prepare<[], { id: string; directory: string; timeCreated: number }>(`
|
|
174
|
+
SELECT id, directory, time_created AS timeCreated
|
|
175
|
+
FROM session
|
|
176
|
+
ORDER BY time_created DESC
|
|
177
|
+
`).all();
|
|
178
|
+
|
|
179
|
+
const summaries: SessionSummary[] = [];
|
|
180
|
+
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
if (opts?.cwd !== undefined && row.directory !== opts.cwd) continue;
|
|
183
|
+
|
|
184
|
+
const stats = this.getSessionStats(db, row.id);
|
|
185
|
+
const lastActive = stats.lastTimeUpdated > 0
|
|
186
|
+
? new Date(stats.lastTimeUpdated)
|
|
187
|
+
: new Date(row.timeCreated);
|
|
188
|
+
const startedAt = new Date(row.timeCreated);
|
|
189
|
+
|
|
190
|
+
summaries.push({
|
|
191
|
+
type: 'opencode',
|
|
192
|
+
sessionId: row.id,
|
|
193
|
+
cwd: row.directory,
|
|
194
|
+
firstUserMessage: stats.summary,
|
|
195
|
+
lastActive,
|
|
196
|
+
startedAt,
|
|
197
|
+
sessionFilePath: encodeSessionRef(this.dbPath, row.id),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return summaries;
|
|
202
|
+
} catch {
|
|
203
|
+
this.close();
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private findSessionForDirectory(db: Database.Database, directory: string): OpenCodeSession | null {
|
|
209
|
+
try {
|
|
210
|
+
const row = db.prepare<[string], { id: string; directory: string; time_created: number }>(`
|
|
211
|
+
SELECT id, directory, time_created
|
|
212
|
+
FROM session
|
|
213
|
+
WHERE directory = ?
|
|
214
|
+
ORDER BY time_created DESC
|
|
215
|
+
LIMIT 1
|
|
216
|
+
`).get(directory);
|
|
217
|
+
|
|
218
|
+
if (!row) return null;
|
|
219
|
+
return { sessionId: row.id, directory: row.directory, timeCreated: row.time_created };
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private getSessionStats(db: Database.Database, sessionId: string): OpenCodeSessionStats {
|
|
226
|
+
const empty: OpenCodeSessionStats = {
|
|
227
|
+
lastRole: null,
|
|
228
|
+
lastTimeUpdated: 0,
|
|
229
|
+
lastAssistantCompleted: false,
|
|
230
|
+
lastAssistantErrored: false,
|
|
231
|
+
summary: '',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Order by time_created — time_updated can lag when OpenCode appends
|
|
236
|
+
// metadata (e.g. summary diffs) to user messages after a turn finishes.
|
|
237
|
+
const last = db.prepare<[string], { role: string; timeUpdated: number }>(`
|
|
238
|
+
SELECT json_extract(data, '$.role') AS role,
|
|
239
|
+
time_updated AS timeUpdated
|
|
240
|
+
FROM message
|
|
241
|
+
WHERE session_id = ?
|
|
242
|
+
ORDER BY time_created DESC
|
|
243
|
+
LIMIT 1
|
|
244
|
+
`).get(sessionId);
|
|
245
|
+
|
|
246
|
+
const heartbeat = db.prepare<[string], { maxUpdated: number }>(`
|
|
247
|
+
SELECT MAX(time_updated) AS maxUpdated FROM message WHERE session_id = ?
|
|
248
|
+
`).get(sessionId);
|
|
249
|
+
|
|
250
|
+
const lastAssistant = db.prepare<[string], {
|
|
251
|
+
completed: number | null;
|
|
252
|
+
errored: number | null;
|
|
253
|
+
}>(`
|
|
254
|
+
SELECT json_extract(data, '$.time.completed') AS completed,
|
|
255
|
+
json_extract(data, '$.time.error') AS errored
|
|
256
|
+
FROM message
|
|
257
|
+
WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'
|
|
258
|
+
ORDER BY time_created DESC
|
|
259
|
+
LIMIT 1
|
|
260
|
+
`).get(sessionId);
|
|
261
|
+
|
|
262
|
+
const first = db.prepare<[string], { text: string }>(`
|
|
263
|
+
SELECT json_extract(p.data, '$.text') AS text
|
|
264
|
+
FROM part p
|
|
265
|
+
JOIN message m ON p.message_id = m.id
|
|
266
|
+
WHERE p.session_id = ?
|
|
267
|
+
AND json_extract(m.data, '$.role') = 'user'
|
|
268
|
+
AND json_extract(p.data, '$.type') = 'text'
|
|
269
|
+
AND json_extract(p.data, '$.text') IS NOT NULL
|
|
270
|
+
ORDER BY p.time_created ASC
|
|
271
|
+
LIMIT 1
|
|
272
|
+
`).get(sessionId);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
lastRole: last?.role ?? null,
|
|
276
|
+
lastTimeUpdated: heartbeat?.maxUpdated ?? last?.timeUpdated ?? 0,
|
|
277
|
+
lastAssistantCompleted: lastAssistant?.completed != null,
|
|
278
|
+
lastAssistantErrored: lastAssistant?.errored != null,
|
|
279
|
+
summary: first?.text?.trim() ?? '',
|
|
280
|
+
};
|
|
281
|
+
} catch {
|
|
282
|
+
return empty;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private mapSessionToAgent(
|
|
287
|
+
session: OpenCodeSession,
|
|
288
|
+
stats: OpenCodeSessionStats,
|
|
289
|
+
proc: ProcessInfo,
|
|
290
|
+
): AgentInfo {
|
|
291
|
+
const lastActive = stats.lastTimeUpdated > 0
|
|
292
|
+
? new Date(stats.lastTimeUpdated)
|
|
293
|
+
: new Date(session.timeCreated);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
name: generateAgentName(session.directory || proc.cwd || '', proc.pid),
|
|
297
|
+
type: this.type,
|
|
298
|
+
status: this.determineStatus(stats, lastActive),
|
|
299
|
+
summary: stats.summary || 'OpenCode session active',
|
|
300
|
+
pid: proc.pid,
|
|
301
|
+
projectPath: session.directory || proc.cwd || '',
|
|
302
|
+
sessionId: session.sessionId,
|
|
303
|
+
lastActive,
|
|
304
|
+
sessionFilePath: encodeSessionRef(this.dbPath, session.sessionId),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private mapProcessOnlyAgent(proc: ProcessInfo): AgentInfo {
|
|
309
|
+
return {
|
|
310
|
+
name: generateAgentName(proc.cwd || '', proc.pid),
|
|
311
|
+
type: this.type,
|
|
312
|
+
status: AgentStatus.RUNNING,
|
|
313
|
+
summary: 'OpenCode process running',
|
|
314
|
+
pid: proc.pid,
|
|
315
|
+
projectPath: proc.cwd || '',
|
|
316
|
+
sessionId: `pid-${proc.pid}`,
|
|
317
|
+
lastActive: new Date(),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private determineStatus(stats: OpenCodeSessionStats, lastActive: Date): AgentStatus {
|
|
322
|
+
const ageMin = (Date.now() - lastActive.getTime()) / 60000;
|
|
323
|
+
if (ageMin > OpenCodeAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE;
|
|
324
|
+
|
|
325
|
+
if (stats.lastRole === 'assistant' && !stats.lastAssistantCompleted) return AgentStatus.RUNNING;
|
|
326
|
+
if (stats.lastRole === 'assistant') return AgentStatus.WAITING;
|
|
327
|
+
return AgentStatus.RUNNING;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private openDb(): Database.Database | null {
|
|
331
|
+
if (this.db) return this.db;
|
|
332
|
+
if (!fs.existsSync(this.dbPath)) return null;
|
|
333
|
+
try {
|
|
334
|
+
this.db = new Database(this.dbPath, { readonly: true });
|
|
335
|
+
return this.db;
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
package/src/adapters/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ClaudeCodeAdapter } from './ClaudeCodeAdapter';
|
|
2
2
|
export { CodexAdapter } from './CodexAdapter';
|
|
3
3
|
export { GeminiCliAdapter } from './GeminiCliAdapter';
|
|
4
|
+
export { OpenCodeAdapter } from './OpenCodeAdapter';
|
|
4
5
|
export { AgentStatus } from './AgentAdapter';
|
|
5
6
|
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter';
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { AgentManager } from './AgentManager';
|
|
|
3
3
|
export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter';
|
|
4
4
|
export { CodexAdapter } from './adapters/CodexAdapter';
|
|
5
5
|
export { GeminiCliAdapter } from './adapters/GeminiCliAdapter';
|
|
6
|
+
export { OpenCodeAdapter } from './adapters/OpenCodeAdapter';
|
|
6
7
|
export { AgentStatus } from './adapters/AgentAdapter';
|
|
7
8
|
export type {
|
|
8
9
|
AgentAdapter,
|