@ai-devkit/agent-manager 0.38.0 → 0.39.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__/harnesses/devin/DevinAdapter.test.js +597 -0
- package/dist/__tests__/harnesses/devin/DevinAdapter.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js +91 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js +192 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js +407 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/fixtures.js +95 -0
- package/dist/__tests__/harnesses/devin/fixtures.js.map +1 -0
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js +64 -1
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js.map +1 -1
- package/dist/__tests__/terminal/GhosttyBackend.test.js +334 -0
- package/dist/__tests__/terminal/GhosttyBackend.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 +2 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/harnesses/devin/DevinAdapter.d.ts +39 -0
- package/dist/harnesses/devin/DevinAdapter.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAdapter.js +177 -0
- package/dist/harnesses/devin/DevinAdapter.js.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts +11 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.js +35 -0
- package/dist/harnesses/devin/DevinAgentMapper.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts +46 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.js +176 -0
- package/dist/harnesses/devin/DevinSessionLocator.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts +29 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.js +166 -0
- package/dist/harnesses/devin/DevinSessionParser.js.map +1 -0
- package/dist/harnesses/devin/readiness.d.ts +3 -0
- package/dist/harnesses/devin/readiness.d.ts.map +1 -0
- package/dist/harnesses/devin/readiness.js +39 -0
- package/dist/harnesses/devin/readiness.js.map +1 -0
- package/dist/harnesses/index.d.ts.map +1 -1
- package/dist/harnesses/index.js +3 -1
- package/dist/harnesses/index.js.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.d.ts.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.js +3 -1
- package/dist/harnesses/readiness/AgentReadiness.js.map +1 -1
- package/dist/harnesses/readiness/types.d.ts +1 -1
- package/dist/harnesses/readiness/types.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.js +2 -1
- package/dist/harnesses/runtimeProfiles.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +4 -3
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/terminal/backends/ghostty.d.ts +3 -0
- package/dist/terminal/backends/ghostty.d.ts.map +1 -0
- package/dist/terminal/backends/ghostty.js +161 -0
- package/dist/terminal/backends/ghostty.js.map +1 -0
- package/dist/terminal/backends/index.d.ts.map +1 -1
- package/dist/terminal/backends/index.js +2 -0
- package/dist/terminal/backends/index.js.map +1 -1
- package/dist/terminal/backends/shared.d.ts +2 -0
- package/dist/terminal/backends/shared.d.ts.map +1 -1
- package/dist/terminal/backends/shared.js +4 -1
- package/dist/terminal/backends/shared.js.map +1 -1
- package/dist/terminal/types.d.ts +1 -0
- package/dist/terminal/types.d.ts.map +1 -1
- package/dist/terminal/types.js +1 -0
- package/dist/terminal/types.js.map +1 -1
- package/dist/utils/process.d.ts +2 -0
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +3 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/harnesses/devin/DevinAdapter.test.ts +292 -0
- package/src/__tests__/harnesses/devin/DevinAgentMapper.test.ts +88 -0
- package/src/__tests__/harnesses/devin/DevinSessionLocator.test.ts +139 -0
- package/src/__tests__/harnesses/devin/DevinSessionParser.test.ts +218 -0
- package/src/__tests__/harnesses/devin/fixtures.ts +144 -0
- package/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +41 -0
- package/src/__tests__/terminal/GhosttyBackend.test.ts +271 -0
- package/src/adapters/AgentAdapter.ts +1 -0
- package/src/harnesses/devin/DevinAdapter.ts +224 -0
- package/src/harnesses/devin/DevinAgentMapper.ts +43 -0
- package/src/harnesses/devin/DevinSessionLocator.ts +230 -0
- package/src/harnesses/devin/DevinSessionParser.ts +236 -0
- package/src/harnesses/devin/readiness.ts +36 -0
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/readiness/AgentReadiness.ts +2 -0
- package/src/harnesses/runtimeProfiles.ts +1 -0
- package/src/terminal/TtyWriter.ts +5 -5
- package/src/terminal/backends/ghostty.ts +180 -0
- package/src/terminal/backends/index.ts +2 -0
- package/src/terminal/backends/shared.ts +6 -1
- package/src/terminal/types.ts +1 -0
- package/src/utils/process.ts +5 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devin Adapter
|
|
3
|
+
*
|
|
4
|
+
* Detects running Devin CLI agents by:
|
|
5
|
+
* 1. Filtering `devin` processes from a shared process snapshot, rejecting
|
|
6
|
+
* utility subcommands (list, doctor, ssh, ...) and `devin acp` children
|
|
7
|
+
* spawned by a Devin TUI
|
|
8
|
+
* 2. Matching processes to sessions via `session_locks/<slug>.lock` files,
|
|
9
|
+
* which contain the PID of the session's `devin acp` backend (lock-first;
|
|
10
|
+
* a TUI is matched through its backend child's ppid)
|
|
11
|
+
* 3. Falling back to `sessions.working_directory` matching in
|
|
12
|
+
* ~/.local/share/devin/cli/sessions.db (WAL SQLite, opened read-only)
|
|
13
|
+
*
|
|
14
|
+
* sessionFilePath encodes "<dbPath>::<sessionSlug>" so getConversation() can
|
|
15
|
+
* address a session row without extending the AgentAdapter interface.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AgentAdapter,
|
|
20
|
+
AgentInfo,
|
|
21
|
+
ProcessInfo,
|
|
22
|
+
ConversationMessage,
|
|
23
|
+
ConversationOptions,
|
|
24
|
+
SessionSummary,
|
|
25
|
+
ListSessionsOptions,
|
|
26
|
+
AgentDetectionContext,
|
|
27
|
+
} from "../../adapters/AgentAdapter.js";
|
|
28
|
+
import { DevinAgentMapper } from "./DevinAgentMapper.js";
|
|
29
|
+
import { DevinSessionLocator, decodeDevinSessionRef } from "./DevinSessionLocator.js";
|
|
30
|
+
import { DevinSessionParser } from "./DevinSessionParser.js";
|
|
31
|
+
import { findHarnessProcesses, matchesExecutable } from "../shared.js";
|
|
32
|
+
import { executablePath } from "../../utils/process.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `devin` invocations that are utilities, not agent sessions. `acp` is
|
|
36
|
+
* deliberately absent: a standalone `devin acp` serves an external client
|
|
37
|
+
* and is itself an agent.
|
|
38
|
+
*/
|
|
39
|
+
const UTILITY_SUBCOMMANDS = new Set([
|
|
40
|
+
"auth",
|
|
41
|
+
"cloud",
|
|
42
|
+
"desktop",
|
|
43
|
+
"doctor",
|
|
44
|
+
"forward",
|
|
45
|
+
"help",
|
|
46
|
+
"list",
|
|
47
|
+
"ls",
|
|
48
|
+
"mcp",
|
|
49
|
+
"migrate",
|
|
50
|
+
"models",
|
|
51
|
+
"plugins",
|
|
52
|
+
"rm",
|
|
53
|
+
"rules",
|
|
54
|
+
"sandbox",
|
|
55
|
+
"setup",
|
|
56
|
+
"skills",
|
|
57
|
+
"ssh",
|
|
58
|
+
"uninstall",
|
|
59
|
+
"update",
|
|
60
|
+
"version",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Flags that always consume the next argv token as their value.
|
|
65
|
+
* (`devin --help`, main-command options.)
|
|
66
|
+
*/
|
|
67
|
+
const REQUIRED_VALUE_FLAGS = new Set([
|
|
68
|
+
"--prompt-file",
|
|
69
|
+
"--config",
|
|
70
|
+
"--permission-mode",
|
|
71
|
+
"--model",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Flags whose value is optional: the next token is consumed only when it
|
|
76
|
+
* isn't itself a flag (commander `[<VALUE>]` semantics).
|
|
77
|
+
*/
|
|
78
|
+
const OPTIONAL_VALUE_FLAGS = new Set([
|
|
79
|
+
"-p",
|
|
80
|
+
"--print",
|
|
81
|
+
"-r",
|
|
82
|
+
"--resume",
|
|
83
|
+
"--export",
|
|
84
|
+
"--respect-workspace-trust",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* First positional argv token after argv[0] — the subcommand candidate.
|
|
89
|
+
* Flag values are skipped (so `devin -p "update deps"` isn't mistaken for
|
|
90
|
+
* the `update` subcommand); `--` ends the search.
|
|
91
|
+
*/
|
|
92
|
+
function firstPositionalToken(command: string): string | null {
|
|
93
|
+
const argv0 = executablePath(command);
|
|
94
|
+
const tokens = command
|
|
95
|
+
.trim()
|
|
96
|
+
.slice(argv0.length)
|
|
97
|
+
.trim()
|
|
98
|
+
.split(/\s+/);
|
|
99
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
100
|
+
const token = tokens[i];
|
|
101
|
+
if (!token) continue;
|
|
102
|
+
if (token === "--") return null;
|
|
103
|
+
if (!token.startsWith("-")) return token;
|
|
104
|
+
const flag = token.split("=", 1)[0];
|
|
105
|
+
if (flag === token) {
|
|
106
|
+
if (REQUIRED_VALUE_FLAGS.has(flag)) {
|
|
107
|
+
i++;
|
|
108
|
+
} else if (OPTIONAL_VALUE_FLAGS.has(flag) && !tokens[i + 1]?.startsWith("-")) {
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class DevinAdapter implements AgentAdapter {
|
|
117
|
+
readonly type = "devin" as const;
|
|
118
|
+
readonly processNames = ["devin"] as const;
|
|
119
|
+
|
|
120
|
+
private readonly parser: DevinSessionParser;
|
|
121
|
+
private readonly locator: DevinSessionLocator;
|
|
122
|
+
private readonly mapper: DevinAgentMapper;
|
|
123
|
+
private readonly cleanup = (): void => this.close();
|
|
124
|
+
|
|
125
|
+
constructor(dbPath?: string, locksDir?: string) {
|
|
126
|
+
this.parser = new DevinSessionParser();
|
|
127
|
+
// Locator defaults resolve dbPath itself and derive locksDir from it.
|
|
128
|
+
this.locator = new DevinSessionLocator(dbPath, locksDir, this.parser);
|
|
129
|
+
this.mapper = new DevinAgentMapper(this.locator.dbFilePath);
|
|
130
|
+
process.once("exit", this.cleanup);
|
|
131
|
+
process.once("SIGINT", this.cleanup);
|
|
132
|
+
process.once("SIGTERM", this.cleanup);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
close(): void {
|
|
136
|
+
process.off("exit", this.cleanup);
|
|
137
|
+
process.off("SIGINT", this.cleanup);
|
|
138
|
+
process.off("SIGTERM", this.cleanup);
|
|
139
|
+
this.locator.close();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
143
|
+
if (!matchesExecutable(processInfo.command, "devin")) return false;
|
|
144
|
+
const subcommand = firstPositionalToken(processInfo.command);
|
|
145
|
+
return subcommand === null || !UTILITY_SUBCOMMANDS.has(subcommand.toLowerCase());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
|
|
149
|
+
const { processes } = await findHarnessProcesses(this, context);
|
|
150
|
+
if (processes.length === 0) return [];
|
|
151
|
+
|
|
152
|
+
const db = this.locator.openDb();
|
|
153
|
+
const processByPid = new Map(processes.map((proc) => [proc.pid, proc]));
|
|
154
|
+
|
|
155
|
+
// Locks map a session to its holder PID (the `devin acp` backend); a TUI
|
|
156
|
+
// is matched through its backend child's ppid. Only trust locks whose
|
|
157
|
+
// holder is a live devin process in this snapshot (stale files and PID
|
|
158
|
+
// reuse are filtered out).
|
|
159
|
+
const sessionByPid = new Map<number, string>();
|
|
160
|
+
for (const lock of this.locator.listActiveLocks()) {
|
|
161
|
+
const holder = processByPid.get(lock.pid);
|
|
162
|
+
if (!holder || !this.canHandle(holder)) continue;
|
|
163
|
+
sessionByPid.set(lock.pid, lock.sessionId);
|
|
164
|
+
if (holder.ppid !== undefined) sessionByPid.set(holder.ppid, lock.sessionId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const agents: AgentInfo[] = [];
|
|
168
|
+
|
|
169
|
+
for (const proc of processes) {
|
|
170
|
+
if (this.isAcpBackendChild(proc, processByPid)) continue;
|
|
171
|
+
|
|
172
|
+
const slug = sessionByPid.get(proc.pid);
|
|
173
|
+
const session = !db
|
|
174
|
+
? null
|
|
175
|
+
: slug
|
|
176
|
+
? this.locator.findSessionById(db, slug)
|
|
177
|
+
: proc.cwd
|
|
178
|
+
? this.locator.findSessionForDirectory(db, proc.cwd)
|
|
179
|
+
: null;
|
|
180
|
+
|
|
181
|
+
if (!session || !db) {
|
|
182
|
+
agents.push(this.mapper.mapProcessOnlyAgent(proc));
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const stats = this.parser.getSessionStats(db, session.sessionId);
|
|
187
|
+
agents.push(this.mapper.mapSessionToAgent(session, stats, proc));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return agents;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getConversation(sessionFilePath: string, options?: ConversationOptions): ConversationMessage[] {
|
|
194
|
+
const sessionId = decodeDevinSessionRef(sessionFilePath);
|
|
195
|
+
if (!sessionId) return [];
|
|
196
|
+
|
|
197
|
+
const db = this.locator.openDb();
|
|
198
|
+
if (!db) return [];
|
|
199
|
+
|
|
200
|
+
return this.parser.getConversation(db, sessionId, options);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
204
|
+
return this.locator.listSessions(opts);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async findSessionsById(sessionId: string): Promise<SessionSummary[]> {
|
|
208
|
+
return this.locator.findSessionsById(sessionId);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A `devin acp` process spawned by another devin process is that session's
|
|
213
|
+
* backend, not a separate agent. Standalone `acp` processes (external ACP
|
|
214
|
+
* clients) keep their own agent entry.
|
|
215
|
+
*/
|
|
216
|
+
private isAcpBackendChild(
|
|
217
|
+
proc: ProcessInfo,
|
|
218
|
+
processByPid: Map<number, ProcessInfo>,
|
|
219
|
+
): boolean {
|
|
220
|
+
if (firstPositionalToken(proc.command)?.toLowerCase() !== "acp") return false;
|
|
221
|
+
if (proc.ppid === undefined) return false;
|
|
222
|
+
return processByPid.has(proc.ppid);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AgentInfo, ProcessInfo } from "../../adapters/AgentAdapter.js";
|
|
2
|
+
import { AgentStatus } from "../../adapters/AgentAdapter.js";
|
|
3
|
+
import { generateAgentName } from "../../utils/matching.js";
|
|
4
|
+
import type { DevinSession } from "./DevinSessionLocator.js";
|
|
5
|
+
import { encodeDevinSessionRef } from "./DevinSessionLocator.js";
|
|
6
|
+
import type { DevinSessionStats } from "./DevinSessionParser.js";
|
|
7
|
+
import { isIdle, processOnlyAgent } from "../shared.js";
|
|
8
|
+
|
|
9
|
+
export class DevinAgentMapper {
|
|
10
|
+
constructor(private readonly dbPath: string) {}
|
|
11
|
+
|
|
12
|
+
mapSessionToAgent(
|
|
13
|
+
session: DevinSession,
|
|
14
|
+
stats: DevinSessionStats,
|
|
15
|
+
proc: ProcessInfo,
|
|
16
|
+
): AgentInfo {
|
|
17
|
+
const lastActive =
|
|
18
|
+
stats.lastTimeUpdated > 0
|
|
19
|
+
? new Date(stats.lastTimeUpdated)
|
|
20
|
+
: new Date(session.lastActivityAt || session.timeCreated);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
name: generateAgentName(session.directory || proc.cwd || "", proc.pid),
|
|
24
|
+
type: "devin",
|
|
25
|
+
status: this.determineStatus(stats, lastActive),
|
|
26
|
+
summary: stats.summary || session.title || "Devin session active",
|
|
27
|
+
pid: proc.pid,
|
|
28
|
+
projectPath: session.directory || proc.cwd || "",
|
|
29
|
+
sessionId: session.sessionId,
|
|
30
|
+
lastActive,
|
|
31
|
+
sessionFilePath: encodeDevinSessionRef(this.dbPath, session.sessionId),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mapProcessOnlyAgent(proc: ProcessInfo): AgentInfo {
|
|
36
|
+
return processOnlyAgent("devin", proc, { summary: "Devin process running" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private determineStatus(stats: DevinSessionStats, lastActive: Date): AgentStatus {
|
|
40
|
+
if (isIdle(lastActive)) return AgentStatus.IDLE;
|
|
41
|
+
return stats.lastRole === "assistant" ? AgentStatus.WAITING : AgentStatus.RUNNING;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import type { ListSessionsOptions, SessionSummary } from "../../adapters/AgentAdapter.js";
|
|
5
|
+
import { DevinSessionParser } from "./DevinSessionParser.js";
|
|
6
|
+
import { homeDir } from "../shared.js";
|
|
7
|
+
|
|
8
|
+
const SESSION_REF_SEP = "::";
|
|
9
|
+
const LOCK_FILE_SUFFIX = ".lock";
|
|
10
|
+
|
|
11
|
+
export function encodeDevinSessionRef(dbPath: string, sessionId: string): string {
|
|
12
|
+
return `${dbPath}${SESSION_REF_SEP}${sessionId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Inverse of {@link encodeDevinSessionRef}; null when the ref has no separator. */
|
|
16
|
+
export function decodeDevinSessionRef(ref: string): string | null {
|
|
17
|
+
const idx = ref.lastIndexOf(SESSION_REF_SEP);
|
|
18
|
+
return idx === -1 ? null : ref.slice(idx + SESSION_REF_SEP.length);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DevinSession {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
directory: string;
|
|
24
|
+
title: string | null;
|
|
25
|
+
/** Epoch milliseconds. */
|
|
26
|
+
timeCreated: number;
|
|
27
|
+
/** Epoch milliseconds. */
|
|
28
|
+
lastActivityAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A live `session_locks/<slug>.lock` entry: the session and its holder PID. */
|
|
32
|
+
export interface DevinSessionLock {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
pid: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DevinSessionRow {
|
|
38
|
+
id: string;
|
|
39
|
+
working_directory: string;
|
|
40
|
+
title: string | null;
|
|
41
|
+
created_at: number;
|
|
42
|
+
last_activity_at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toDevinSession(row: DevinSessionRow): DevinSession {
|
|
46
|
+
return {
|
|
47
|
+
sessionId: row.id,
|
|
48
|
+
directory: row.working_directory,
|
|
49
|
+
title: row.title,
|
|
50
|
+
timeCreated: row.created_at * 1000,
|
|
51
|
+
lastActivityAt: row.last_activity_at * 1000,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class DevinSessionLocator {
|
|
56
|
+
private readonly dbPath: string;
|
|
57
|
+
private readonly locksDir: string;
|
|
58
|
+
private db: Database.Database | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
dbPath = DevinSessionLocator.resolveDbPath(),
|
|
62
|
+
locksDir = DevinSessionLocator.defaultLocksDir(dbPath),
|
|
63
|
+
private readonly parser: DevinSessionParser = new DevinSessionParser(),
|
|
64
|
+
) {
|
|
65
|
+
this.dbPath = dbPath;
|
|
66
|
+
this.locksDir = locksDir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static resolveDbPath(): string {
|
|
70
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
71
|
+
const base = xdg || path.join(homeDir(), ".local", "share");
|
|
72
|
+
return path.join(base, "devin", "cli", "sessions.db");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static defaultLocksDir(dbPath: string): string {
|
|
76
|
+
return path.join(path.dirname(dbPath), "session_locks");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
openDb(): Database.Database | null {
|
|
80
|
+
if (this.db) return this.db;
|
|
81
|
+
if (!fs.existsSync(this.dbPath)) return null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
this.db = new Database(this.dbPath, { readonly: true });
|
|
85
|
+
return this.db;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
close(): void {
|
|
92
|
+
if (this.db) {
|
|
93
|
+
try {
|
|
94
|
+
this.db.close();
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
this.db = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read `session_locks/*.lock` entries. Each file name is a session slug and
|
|
104
|
+
* its body is the PID of the `devin acp` backend holding the session.
|
|
105
|
+
* Malformed files are skipped; callers validate liveness against the
|
|
106
|
+
* process snapshot.
|
|
107
|
+
*/
|
|
108
|
+
listActiveLocks(): DevinSessionLock[] {
|
|
109
|
+
let entries: string[];
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(this.locksDir);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const locks: DevinSessionLock[] = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.endsWith(LOCK_FILE_SUFFIX)) continue;
|
|
119
|
+
const sessionId = entry.slice(0, -LOCK_FILE_SUFFIX.length);
|
|
120
|
+
if (!sessionId) continue;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const raw = fs.readFileSync(path.join(this.locksDir, entry), "utf-8").trim();
|
|
124
|
+
const pid = parseInt(raw, 10);
|
|
125
|
+
if (Number.isFinite(pid) && pid > 0) locks.push({ sessionId, pid });
|
|
126
|
+
} catch {
|
|
127
|
+
/* skip unreadable lock */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return locks;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
findSessionById(db: Database.Database, sessionId: string): DevinSession | null {
|
|
134
|
+
try {
|
|
135
|
+
const row = db
|
|
136
|
+
.prepare<[string], DevinSessionRow>(`
|
|
137
|
+
SELECT id, working_directory, title, created_at, last_activity_at
|
|
138
|
+
FROM sessions
|
|
139
|
+
WHERE id = ? AND hidden = 0
|
|
140
|
+
`)
|
|
141
|
+
.get(sessionId);
|
|
142
|
+
return row ? toDevinSession(row) : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
findSessionForDirectory(db: Database.Database, directory: string): DevinSession | null {
|
|
149
|
+
try {
|
|
150
|
+
const row = db
|
|
151
|
+
.prepare<[string], DevinSessionRow>(`
|
|
152
|
+
SELECT id, working_directory, title, created_at, last_activity_at
|
|
153
|
+
FROM sessions
|
|
154
|
+
WHERE working_directory = ? AND hidden = 0
|
|
155
|
+
ORDER BY last_activity_at DESC
|
|
156
|
+
LIMIT 1
|
|
157
|
+
`)
|
|
158
|
+
.get(directory);
|
|
159
|
+
return row ? toDevinSession(row) : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
listSessions(opts?: ListSessionsOptions): SessionSummary[] {
|
|
166
|
+
const db = this.openDb();
|
|
167
|
+
if (!db) return [];
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const rows = db
|
|
171
|
+
.prepare<[], DevinSessionRow>(`
|
|
172
|
+
SELECT id, working_directory, title, created_at, last_activity_at
|
|
173
|
+
FROM sessions
|
|
174
|
+
WHERE hidden = 0
|
|
175
|
+
ORDER BY last_activity_at DESC
|
|
176
|
+
`)
|
|
177
|
+
.all();
|
|
178
|
+
|
|
179
|
+
const summaries: SessionSummary[] = [];
|
|
180
|
+
for (const row of rows) {
|
|
181
|
+
if (opts?.cwd !== undefined && row.working_directory !== opts.cwd) continue;
|
|
182
|
+
summaries.push(this.toSessionSummary(db, row));
|
|
183
|
+
}
|
|
184
|
+
return summaries;
|
|
185
|
+
} catch {
|
|
186
|
+
this.close();
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
findSessionsById(sessionId: string): SessionSummary[] {
|
|
192
|
+
const db = this.openDb();
|
|
193
|
+
if (!db) return [];
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const row = db
|
|
197
|
+
.prepare<[string], DevinSessionRow>(`
|
|
198
|
+
SELECT id, working_directory, title, created_at, last_activity_at
|
|
199
|
+
FROM sessions
|
|
200
|
+
WHERE id = ? AND hidden = 0
|
|
201
|
+
`)
|
|
202
|
+
.get(sessionId);
|
|
203
|
+
return row ? [this.toSessionSummary(db, row)] : [];
|
|
204
|
+
} catch {
|
|
205
|
+
this.close();
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private toSessionSummary(db: Database.Database, row: DevinSessionRow): SessionSummary {
|
|
211
|
+
const session = toDevinSession(row);
|
|
212
|
+
return {
|
|
213
|
+
type: "devin",
|
|
214
|
+
sessionId: session.sessionId,
|
|
215
|
+
cwd: session.directory,
|
|
216
|
+
firstUserMessage: this.parser.getFirstUserMessage(db, session.sessionId),
|
|
217
|
+
lastActive: new Date(session.lastActivityAt),
|
|
218
|
+
startedAt: new Date(session.timeCreated),
|
|
219
|
+
sessionFilePath: encodeDevinSessionRef(this.dbPath, session.sessionId),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get dbFilePath(): string {
|
|
224
|
+
return this.dbPath;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get sessionLocksDir(): string {
|
|
228
|
+
return this.locksDir;
|
|
229
|
+
}
|
|
230
|
+
}
|