@ai-devkit/agent-manager 0.33.1 → 0.34.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.
Files changed (81) hide show
  1. package/dist/__tests__/capacity/codex.test.js +2 -1
  2. package/dist/__tests__/capacity/codex.test.js.map +1 -1
  3. package/dist/__tests__/capacity/index.test.js +6 -3
  4. package/dist/__tests__/capacity/index.test.js.map +1 -1
  5. package/dist/__tests__/capacity/zai.test.js +213 -0
  6. package/dist/__tests__/capacity/zai.test.js.map +1 -0
  7. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js +189 -0
  8. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js.map +1 -0
  9. package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js +89 -0
  10. package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js.map +1 -0
  11. package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js +94 -0
  12. package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js.map +1 -0
  13. package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js +147 -0
  14. package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js.map +1 -0
  15. package/dist/adapters/index.d.ts +1 -1
  16. package/dist/adapters/index.d.ts.map +1 -1
  17. package/dist/adapters/index.js +1 -1
  18. package/dist/adapters/index.js.map +1 -1
  19. package/dist/capacity/codex.d.ts +1 -0
  20. package/dist/capacity/codex.d.ts.map +1 -1
  21. package/dist/capacity/codex.js +9 -4
  22. package/dist/capacity/codex.js.map +1 -1
  23. package/dist/capacity/index.d.ts +8 -0
  24. package/dist/capacity/index.d.ts.map +1 -1
  25. package/dist/capacity/index.js +10 -9
  26. package/dist/capacity/index.js.map +1 -1
  27. package/dist/capacity/types.d.ts +8 -0
  28. package/dist/capacity/types.d.ts.map +1 -1
  29. package/dist/capacity/types.js.map +1 -1
  30. package/dist/capacity/zai.d.ts +14 -0
  31. package/dist/capacity/zai.d.ts.map +1 -0
  32. package/dist/capacity/zai.js +203 -0
  33. package/dist/capacity/zai.js.map +1 -0
  34. package/dist/index.d.ts +3 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/{adapters → providers/opencode}/OpenCodeAdapter.d.ts +5 -12
  39. package/dist/providers/opencode/OpenCodeAdapter.d.ts.map +1 -0
  40. package/dist/providers/opencode/OpenCodeAdapter.js +86 -0
  41. package/dist/providers/opencode/OpenCodeAdapter.js.map +1 -0
  42. package/dist/providers/opencode/OpenCodeAgentMapper.d.ts +11 -0
  43. package/dist/providers/opencode/OpenCodeAgentMapper.d.ts.map +1 -0
  44. package/dist/providers/opencode/OpenCodeAgentMapper.js +50 -0
  45. package/dist/providers/opencode/OpenCodeAgentMapper.js.map +1 -0
  46. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts +21 -0
  47. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts.map +1 -0
  48. package/dist/providers/opencode/OpenCodeSessionLocator.js +99 -0
  49. package/dist/providers/opencode/OpenCodeSessionLocator.js.map +1 -0
  50. package/dist/providers/opencode/OpenCodeSessionParser.d.ts +18 -0
  51. package/dist/providers/opencode/OpenCodeSessionParser.d.ts.map +1 -0
  52. package/dist/providers/opencode/OpenCodeSessionParser.js +104 -0
  53. package/dist/providers/opencode/OpenCodeSessionParser.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/__tests__/capacity/codex.test.ts +94 -26
  56. package/src/__tests__/capacity/fixtures/zai-auth.json +6 -0
  57. package/src/__tests__/capacity/fixtures/zai-quota-invalid.json +7 -0
  58. package/src/__tests__/capacity/fixtures/zai-quota.json +38 -0
  59. package/src/__tests__/capacity/index.test.ts +10 -3
  60. package/src/__tests__/capacity/zai.test.ts +186 -0
  61. package/src/__tests__/providers/opencode/OpenCodeAdapter.test.ts +197 -0
  62. package/src/__tests__/providers/opencode/OpenCodeAgentMapper.test.ts +83 -0
  63. package/src/__tests__/providers/opencode/OpenCodeSessionLocator.test.ts +84 -0
  64. package/src/__tests__/providers/opencode/OpenCodeSessionParser.test.ts +148 -0
  65. package/src/adapters/index.ts +1 -1
  66. package/src/capacity/codex.ts +96 -28
  67. package/src/capacity/index.ts +29 -11
  68. package/src/capacity/types.ts +8 -0
  69. package/src/capacity/zai.ts +237 -0
  70. package/src/index.ts +8 -3
  71. package/src/providers/opencode/OpenCodeAdapter.ts +112 -0
  72. package/src/providers/opencode/OpenCodeAgentMapper.ts +61 -0
  73. package/src/providers/opencode/OpenCodeSessionLocator.ts +123 -0
  74. package/src/providers/opencode/OpenCodeSessionParser.ts +145 -0
  75. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +0 -636
  76. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +0 -1
  77. package/dist/adapters/OpenCodeAdapter.d.ts.map +0 -1
  78. package/dist/adapters/OpenCodeAdapter.js +0 -289
  79. package/dist/adapters/OpenCodeAdapter.js.map +0 -1
  80. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +0 -544
  81. package/src/adapters/OpenCodeAdapter.ts +0 -373
@@ -0,0 +1,237 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type {
5
+ Availability,
6
+ CapacityLimitType,
7
+ CapacityReport,
8
+ CapacityWindow,
9
+ } from "./types.js";
10
+
11
+ const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
12
+ const MISSING_API_KEY_MESSAGE =
13
+ "z.ai API key not found; set Z_AI_API_KEY or configure Pi provider zai";
14
+ const LIMIT_METADATA = {
15
+ TOKENS_LIMIT: { id: "tokens", label: "Tokens" },
16
+ CREDIT_LIMIT: { id: "credit", label: "Credit" },
17
+ TIME_LIMIT: { id: "time", label: "Time" },
18
+ } as const satisfies Record<CapacityLimitType, { id: string; label: string }>;
19
+ const UNIT_METADATA = new Map<number, { minutes: number; label: string }>([
20
+ [1, { minutes: 24 * 60, label: "day" }],
21
+ [3, { minutes: 60, label: "hour" }],
22
+ [5, { minutes: 1, label: "minute" }],
23
+ [6, { minutes: 7 * 24 * 60, label: "week" }],
24
+ ]);
25
+ type UnknownRecord = Record<string, unknown>;
26
+
27
+ export type ZaiCredentialOptions = {
28
+ env?: NodeJS.ProcessEnv;
29
+ readFile?: (path: string, encoding: BufferEncoding) => Promise<string>;
30
+ };
31
+
32
+ export type ZaiProbeOptions = ZaiCredentialOptions & {
33
+ checkedAt: string;
34
+ fetch?: typeof globalThis.fetch;
35
+ timeoutMs?: number;
36
+ };
37
+
38
+ function record(value: unknown): UnknownRecord | null {
39
+ return value !== null && typeof value === "object" && !Array.isArray(value)
40
+ ? (value as UnknownRecord)
41
+ : null;
42
+ }
43
+
44
+ function nonEmptyText(value: unknown): string | null {
45
+ return typeof value === "string" && value.trim() ? value.trim() : null;
46
+ }
47
+
48
+ function optionalNumber(value: unknown, field: string): number | null {
49
+ if (value === undefined || value === null) return null;
50
+ if (typeof value !== "number" || !Number.isFinite(value)) {
51
+ throw new Error(`Invalid z.ai quota ${field}`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function clampPercent(value: number): number {
57
+ return Math.max(0, Math.min(100, value));
58
+ }
59
+
60
+ function integer(value: unknown): number | null {
61
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
62
+ }
63
+
64
+ function isCapacityLimitType(value: string): value is CapacityLimitType {
65
+ return Object.hasOwn(LIMIT_METADATA, value);
66
+ }
67
+
68
+ function durationMinutes(
69
+ unit: number,
70
+ number: number,
71
+ type: CapacityLimitType,
72
+ ): number | null {
73
+ if (type === "TIME_LIMIT" && unit === 5 && number === 1) return 30 * 24 * 60;
74
+ const metadata = UNIT_METADATA.get(unit);
75
+ return metadata === undefined || number <= 0
76
+ ? null
77
+ : metadata.minutes * number;
78
+ }
79
+
80
+ function windowLabel(
81
+ type: CapacityLimitType,
82
+ unit: number,
83
+ number: number,
84
+ ): string {
85
+ if (type === "TIME_LIMIT" && unit === 5 && number === 1)
86
+ return "MCP · monthly";
87
+ const kind = LIMIT_METADATA[type].label;
88
+ const unitLabel = UNIT_METADATA.get(unit)?.label;
89
+ if (!unitLabel) return kind;
90
+ return `${kind} · ${number} ${unitLabel}${number === 1 ? "" : "s"}`;
91
+ }
92
+
93
+ function resetTime(value: unknown): string | null {
94
+ const milliseconds = optionalNumber(value, "limit nextResetTime");
95
+ if (milliseconds === null) return null;
96
+ const date = new Date(milliseconds);
97
+ if (Number.isNaN(date.getTime()))
98
+ throw new Error("Invalid z.ai quota limit nextResetTime");
99
+ return date.toISOString();
100
+ }
101
+
102
+ function availability(windows: CapacityWindow[]): Availability {
103
+ if (windows.length === 0) return "unknown";
104
+ if (
105
+ windows.some(
106
+ (window) => window.usedPercent !== null && window.usedPercent < 100,
107
+ )
108
+ ) {
109
+ return "yes";
110
+ }
111
+ return "no";
112
+ }
113
+
114
+ function parseLimit(value: unknown, index: number): CapacityWindow | null {
115
+ const limit = record(value);
116
+ if (!limit) throw new Error("Invalid z.ai quota limit entry");
117
+ const unit = integer(limit.unit);
118
+ const count = integer(limit.number);
119
+ if (typeof limit.type !== "string" || unit === null || count === null) {
120
+ throw new Error("Invalid z.ai quota limit entry");
121
+ }
122
+ const serverPercent = optionalNumber(limit.percentage, "limit percentage");
123
+ if (serverPercent === null)
124
+ throw new Error("Invalid z.ai quota limit percentage");
125
+ if (!isCapacityLimitType(limit.type)) return null;
126
+
127
+ const total = optionalNumber(limit.usage, "limit usage");
128
+ const current = optionalNumber(limit.currentValue, "limit currentValue");
129
+ const remaining = optionalNumber(limit.remaining, "limit remaining");
130
+ let consumed =
131
+ total !== null && remaining !== null ? total - remaining : null;
132
+ if (current !== null) consumed = Math.max(consumed ?? current, current);
133
+
134
+ let usedPercent = serverPercent;
135
+ if (total !== null && total > 0 && consumed !== null) {
136
+ usedPercent = (consumed / total) * 100;
137
+ }
138
+ const metadata = LIMIT_METADATA[limit.type];
139
+
140
+ return {
141
+ id: `zai:${metadata.id}:${index + 1}`,
142
+ label: windowLabel(limit.type, unit, count),
143
+ limitType: limit.type,
144
+ durationMinutes: durationMinutes(unit, count, limit.type),
145
+ usedPercent: clampPercent(usedPercent),
146
+ resetsAt: resetTime(limit.nextResetTime),
147
+ total,
148
+ current,
149
+ remaining,
150
+ };
151
+ }
152
+
153
+ export async function resolveZaiApiKey(
154
+ options: ZaiCredentialOptions = {},
155
+ ): Promise<string> {
156
+ const env = options.env ?? process.env;
157
+ const environmentKey = nonEmptyText(env.Z_AI_API_KEY);
158
+ if (environmentKey) return environmentKey;
159
+
160
+ const authPath = join(env.HOME || homedir(), ".pi", "agent", "auth.json");
161
+ let parsed: UnknownRecord;
162
+ try {
163
+ const contents = await (options.readFile ?? readFile)(authPath, "utf8");
164
+ const root = record(JSON.parse(contents));
165
+ if (!root) throw new Error("invalid");
166
+ parsed = root;
167
+ } catch (error) {
168
+ if (record(error)?.code === "ENOENT") {
169
+ throw new Error(MISSING_API_KEY_MESSAGE);
170
+ }
171
+ throw new Error("z.ai Pi auth file is malformed");
172
+ }
173
+
174
+ const credential = record(parsed.zai);
175
+ if (!credential) {
176
+ throw new Error(MISSING_API_KEY_MESSAGE);
177
+ }
178
+ const key =
179
+ credential.type === "api_key" ? nonEmptyText(credential.key) : null;
180
+ if (!key) throw new Error("z.ai Pi auth file has a malformed zai credential");
181
+ return key;
182
+ }
183
+
184
+ export function parseZaiQuota(raw: unknown, checkedAt: string): CapacityReport {
185
+ const root = record(raw);
186
+ const data = record(root?.data);
187
+ if (
188
+ root?.success !== true ||
189
+ root.code !== 200 ||
190
+ !data ||
191
+ !Array.isArray(data.limits)
192
+ ) {
193
+ throw new Error("Invalid z.ai quota response");
194
+ }
195
+ const windows = data.limits
196
+ .map(parseLimit)
197
+ .filter((window): window is CapacityWindow => window !== null);
198
+ return {
199
+ harness: "pi",
200
+ provider: "zai",
201
+ generatedAt: checkedAt,
202
+ authenticated: true,
203
+ available: availability(windows),
204
+ windows,
205
+ creditsRemaining: null,
206
+ };
207
+ }
208
+
209
+ export async function probeZaiCapacity(
210
+ options: ZaiProbeOptions,
211
+ ): Promise<CapacityReport> {
212
+ const key = await resolveZaiApiKey(options);
213
+ const controller = new AbortController();
214
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
215
+ let response: Response;
216
+ try {
217
+ response = await (options.fetch ?? globalThis.fetch)(ZAI_QUOTA_URL, {
218
+ method: "GET",
219
+ headers: { Authorization: `Bearer ${key}` },
220
+ signal: controller.signal,
221
+ });
222
+ } catch {
223
+ throw new Error("z.ai quota request failed");
224
+ } finally {
225
+ clearTimeout(timer);
226
+ }
227
+ if (response.status !== 200) {
228
+ throw new Error(`z.ai quota request failed: HTTP ${response.status}`);
229
+ }
230
+ let raw: unknown;
231
+ try {
232
+ raw = await response.json();
233
+ } catch {
234
+ throw new Error("z.ai quota response is not valid JSON");
235
+ }
236
+ return parseZaiQuota(raw, options.checkedAt);
237
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export { AgentManager, AgentNotRunningError } from "./AgentManager.js";
2
- export { getCodexCapacityReport } from "./capacity/index.js";
3
- export type { CapacityProbeOptions, CapacityReport, CapacityWindow } from "./capacity/index.js";
2
+ export { getCodexCapacityReport, getZaiCapacityReport } from "./capacity/index.js";
3
+ export type {
4
+ CapacityProbeOptions,
5
+ CapacityReport,
6
+ CapacityWindow,
7
+ ZaiCapacityOptions,
8
+ } from "./capacity/index.js";
4
9
  export {
5
10
  getAgentReadinessReport,
6
11
  getAgentReadinessReports,
@@ -25,7 +30,7 @@ export { CodexAdapter } from "./providers/codex/CodexAdapter.js";
25
30
  export { CopilotAdapter } from "./providers/copilot/CopilotAdapter.js";
26
31
  export { GeminiCliAdapter } from "./providers/gemini/GeminiCliAdapter.js";
27
32
  export { GrokCliAdapter } from "./adapters/GrokCliAdapter.js";
28
- export { OpenCodeAdapter } from "./adapters/OpenCodeAdapter.js";
33
+ export { OpenCodeAdapter } from "./providers/opencode/OpenCodeAdapter.js";
29
34
  export { PiAdapter } from "./providers/pi/PiAdapter.js";
30
35
  export { AgentStatus } from "./adapters/AgentAdapter.js";
31
36
  export type {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * OpenCode Adapter
3
+ *
4
+ * Detects running OpenCode agents by:
5
+ * 1. Filtering OpenCode processes from a shared asynchronous process snapshot
6
+ * 2. Using snapshot CWD and start-time enrichment
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 type {
15
+ AgentAdapter,
16
+ AgentInfo,
17
+ ProcessInfo,
18
+ ConversationMessage,
19
+ SessionSummary,
20
+ ListSessionsOptions,
21
+ AgentDetectionContext,
22
+ } from "../../adapters/AgentAdapter.js";
23
+ import {
24
+ captureProcessSnapshot,
25
+ executableBasename,
26
+ filterByProcessNames,
27
+ } from "../../utils/process.js";
28
+ import { OpenCodeAgentMapper } from "./OpenCodeAgentMapper.js";
29
+ import { OpenCodeSessionLocator } from "./OpenCodeSessionLocator.js";
30
+ import { OpenCodeSessionParser } from "./OpenCodeSessionParser.js";
31
+
32
+ const SESSION_REF_SEP = "::";
33
+
34
+ function decodeSessionRef(ref: string): { sessionId: string } | null {
35
+ const idx = ref.lastIndexOf(SESSION_REF_SEP);
36
+ if (idx === -1) return null;
37
+ return { sessionId: ref.slice(idx + SESSION_REF_SEP.length) };
38
+ }
39
+
40
+ export class OpenCodeAdapter implements AgentAdapter {
41
+ readonly type = "opencode" as const;
42
+ readonly processNames = ["opencode"] as const;
43
+
44
+ private readonly parser: OpenCodeSessionParser;
45
+ private readonly locator: OpenCodeSessionLocator;
46
+ private readonly mapper: OpenCodeAgentMapper;
47
+ private readonly cleanup = (): void => this.close();
48
+
49
+ constructor(dbPath?: string) {
50
+ this.parser = new OpenCodeSessionParser();
51
+ this.locator = new OpenCodeSessionLocator(dbPath, this.parser);
52
+ this.mapper = new OpenCodeAgentMapper(this.locator.dbFilePath);
53
+ process.once("exit", this.cleanup);
54
+ process.once("SIGINT", this.cleanup);
55
+ process.once("SIGTERM", this.cleanup);
56
+ }
57
+
58
+ close(): void {
59
+ process.off("exit", this.cleanup);
60
+ process.off("SIGINT", this.cleanup);
61
+ process.off("SIGTERM", this.cleanup);
62
+ this.locator.close();
63
+ }
64
+
65
+ canHandle(processInfo: ProcessInfo): boolean {
66
+ const base = executableBasename(processInfo.command);
67
+ return base === "opencode" || base === "opencode.exe";
68
+ }
69
+
70
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
71
+ const snapshot = context?.processes ?? (await captureProcessSnapshot(this.processNames));
72
+ const relevant = filterByProcessNames(snapshot, this.processNames);
73
+ const processes = relevant.filter((process) => this.canHandle(process));
74
+ if (processes.length === 0) return [];
75
+
76
+ const db = this.locator.openDb();
77
+ if (!db) return processes.map((process) => this.mapper.mapProcessOnlyAgent(process));
78
+
79
+ const agents: AgentInfo[] = [];
80
+ for (const proc of processes) {
81
+ if (!proc.cwd) {
82
+ agents.push(this.mapper.mapProcessOnlyAgent(proc));
83
+ continue;
84
+ }
85
+
86
+ const session = this.locator.findSessionForDirectory(db, proc.cwd);
87
+ if (!session) {
88
+ agents.push(this.mapper.mapProcessOnlyAgent(proc));
89
+ continue;
90
+ }
91
+
92
+ const stats = this.parser.getSessionStats(db, session.sessionId);
93
+ agents.push(this.mapper.mapSessionToAgent(session, stats, proc));
94
+ }
95
+
96
+ return agents;
97
+ }
98
+
99
+ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
100
+ const ref = decodeSessionRef(sessionFilePath);
101
+ if (!ref) return [];
102
+
103
+ const db = this.locator.openDb();
104
+ if (!db) return [];
105
+
106
+ return this.parser.getConversation(db, ref.sessionId, options);
107
+ }
108
+
109
+ async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
110
+ return this.locator.listSessions(opts);
111
+ }
112
+ }
@@ -0,0 +1,61 @@
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 { OpenCodeSession } from "./OpenCodeSessionLocator.js";
5
+ import type { OpenCodeSessionStats } from "./OpenCodeSessionParser.js";
6
+
7
+ const SESSION_REF_SEP = "::";
8
+ const IDLE_THRESHOLD_MINUTES = 5;
9
+
10
+ function encodeSessionRef(dbPath: string, sessionId: string): string {
11
+ return `${dbPath}${SESSION_REF_SEP}${sessionId}`;
12
+ }
13
+
14
+ export class OpenCodeAgentMapper {
15
+ constructor(private readonly dbPath: string) {}
16
+
17
+ mapSessionToAgent(
18
+ session: OpenCodeSession,
19
+ stats: OpenCodeSessionStats,
20
+ proc: ProcessInfo,
21
+ ): AgentInfo {
22
+ const lastActive =
23
+ stats.lastTimeUpdated > 0 ? new Date(stats.lastTimeUpdated) : new Date(session.timeCreated);
24
+
25
+ return {
26
+ name: generateAgentName(session.directory || proc.cwd || "", proc.pid),
27
+ type: "opencode",
28
+ status: this.determineStatus(stats, lastActive),
29
+ summary: stats.summary || "OpenCode session active",
30
+ pid: proc.pid,
31
+ projectPath: session.directory || proc.cwd || "",
32
+ sessionId: session.sessionId,
33
+ lastActive,
34
+ sessionFilePath: encodeSessionRef(this.dbPath, session.sessionId),
35
+ };
36
+ }
37
+
38
+ mapProcessOnlyAgent(proc: ProcessInfo): AgentInfo {
39
+ return {
40
+ name: generateAgentName(proc.cwd || "", proc.pid),
41
+ type: "opencode",
42
+ status: AgentStatus.RUNNING,
43
+ summary: "OpenCode process running",
44
+ pid: proc.pid,
45
+ projectPath: proc.cwd || "",
46
+ sessionId: `pid-${proc.pid}`,
47
+ lastActive: new Date(),
48
+ };
49
+ }
50
+
51
+ private determineStatus(stats: OpenCodeSessionStats, lastActive: Date): AgentStatus {
52
+ const ageMin = (Date.now() - lastActive.getTime()) / 60000;
53
+ if (ageMin > IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE;
54
+
55
+ if (stats.lastRole === "assistant" && !stats.lastAssistantCompleted) {
56
+ return AgentStatus.RUNNING;
57
+ }
58
+ if (stats.lastRole === "assistant") return AgentStatus.WAITING;
59
+ return AgentStatus.RUNNING;
60
+ }
61
+ }
@@ -0,0 +1,123 @@
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 { OpenCodeSessionParser } from "./OpenCodeSessionParser.js";
6
+
7
+ const SESSION_REF_SEP = "::";
8
+
9
+ function encodeOpenCodeSessionRef(dbPath: string, sessionId: string): string {
10
+ return `${dbPath}${SESSION_REF_SEP}${sessionId}`;
11
+ }
12
+
13
+ export interface OpenCodeSession {
14
+ sessionId: string;
15
+ directory: string;
16
+ timeCreated: number;
17
+ }
18
+
19
+ export class OpenCodeSessionLocator {
20
+ private readonly dbPath: string;
21
+ private db: Database.Database | null = null;
22
+
23
+ constructor(
24
+ dbPath = OpenCodeSessionLocator.resolveDbPath(),
25
+ private readonly parser: OpenCodeSessionParser = new OpenCodeSessionParser(),
26
+ ) {
27
+ this.dbPath = dbPath;
28
+ }
29
+
30
+ static resolveDbPath(): string {
31
+ const xdg = process.env.XDG_DATA_HOME;
32
+ const home = process.env.HOME || process.env.USERPROFILE || "";
33
+ const base = xdg || path.join(home, ".local", "share");
34
+ return path.join(base, "opencode", "opencode.db");
35
+ }
36
+
37
+ openDb(): Database.Database | null {
38
+ if (this.db) return this.db;
39
+ if (!fs.existsSync(this.dbPath)) return null;
40
+
41
+ try {
42
+ this.db = new Database(this.dbPath, { readonly: true });
43
+ return this.db;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ close(): void {
50
+ if (this.db) {
51
+ try {
52
+ this.db.close();
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ this.db = null;
57
+ }
58
+ }
59
+
60
+ findSessionForDirectory(db: Database.Database, directory: string): OpenCodeSession | null {
61
+ try {
62
+ const row = db
63
+ .prepare<[string], { id: string; directory: string; time_created: number }>(`
64
+ SELECT id, directory, time_created
65
+ FROM session
66
+ WHERE directory = ?
67
+ ORDER BY time_created DESC
68
+ LIMIT 1
69
+ `)
70
+ .get(directory);
71
+
72
+ if (!row) return null;
73
+ return { sessionId: row.id, directory: row.directory, timeCreated: row.time_created };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ listSessions(opts?: ListSessionsOptions): SessionSummary[] {
80
+ const db = this.openDb();
81
+ if (!db) return [];
82
+
83
+ try {
84
+ const rows = db
85
+ .prepare<[], { id: string; directory: string; timeCreated: number }>(`
86
+ SELECT id, directory, time_created AS timeCreated
87
+ FROM session
88
+ ORDER BY time_created DESC
89
+ `)
90
+ .all();
91
+
92
+ const summaries: SessionSummary[] = [];
93
+
94
+ for (const row of rows) {
95
+ if (opts?.cwd !== undefined && row.directory !== opts.cwd) continue;
96
+
97
+ const stats = this.parser.getSessionStats(db, row.id);
98
+ const lastActive =
99
+ stats.lastTimeUpdated > 0 ? new Date(stats.lastTimeUpdated) : new Date(row.timeCreated);
100
+ const startedAt = new Date(row.timeCreated);
101
+
102
+ summaries.push({
103
+ type: "opencode",
104
+ sessionId: row.id,
105
+ cwd: row.directory,
106
+ firstUserMessage: stats.summary,
107
+ lastActive,
108
+ startedAt,
109
+ sessionFilePath: encodeOpenCodeSessionRef(this.dbPath, row.id),
110
+ });
111
+ }
112
+
113
+ return summaries;
114
+ } catch {
115
+ this.close();
116
+ return [];
117
+ }
118
+ }
119
+
120
+ get dbFilePath(): string {
121
+ return this.dbPath;
122
+ }
123
+ }
@@ -0,0 +1,145 @@
1
+ import type { ConversationMessage } from "../../adapters/AgentAdapter.js";
2
+ import type Database from "better-sqlite3";
3
+
4
+ export interface OpenCodeSessionStats {
5
+ lastRole: string | null;
6
+ lastTimeUpdated: number;
7
+ /** OpenCode writes `time.completed` on the assistant message only when the turn finishes. */
8
+ lastAssistantCompleted: boolean;
9
+ lastAssistantErrored: boolean;
10
+ summary: string;
11
+ }
12
+
13
+ interface OpenCodePartData {
14
+ type?: string;
15
+ text?: string;
16
+ reasoning?: string;
17
+ tool?: string;
18
+ }
19
+
20
+ const EMPTY_STATS: OpenCodeSessionStats = {
21
+ lastRole: null,
22
+ lastTimeUpdated: 0,
23
+ lastAssistantCompleted: false,
24
+ lastAssistantErrored: false,
25
+ summary: "",
26
+ };
27
+
28
+ export class OpenCodeSessionParser {
29
+ getConversation(
30
+ db: Database.Database,
31
+ sessionId: string,
32
+ options?: { verbose?: boolean },
33
+ ): ConversationMessage[] {
34
+ const verbose = options?.verbose ?? false;
35
+
36
+ try {
37
+ const rows = db
38
+ .prepare<[string], { role: string; partData: string; timeCreated: number }>(`
39
+ SELECT json_extract(m.data, '$.role') AS role,
40
+ p.data AS partData,
41
+ p.time_created AS timeCreated
42
+ FROM part p
43
+ JOIN message m ON p.message_id = m.id
44
+ WHERE p.session_id = ?
45
+ ORDER BY p.time_created ASC
46
+ `)
47
+ .all(sessionId);
48
+
49
+ const messages: ConversationMessage[] = [];
50
+
51
+ for (const row of rows) {
52
+ const partData = this.parsePartData(row.partData);
53
+ if (!partData) continue;
54
+
55
+ const role = row.role === "user" ? "user" : "assistant";
56
+
57
+ if (partData.type === "text" && partData.text) {
58
+ messages.push({ role, content: partData.text });
59
+ } else if (partData.type === "reasoning" && verbose) {
60
+ const text = partData.reasoning || partData.text || "";
61
+ if (text) messages.push({ role: "assistant", content: `[thinking] ${text}` });
62
+ } else if (partData.type === "tool" && verbose) {
63
+ const toolName = partData.tool || "tool";
64
+ messages.push({ role: "assistant", content: `[tool: ${toolName}]` });
65
+ }
66
+ }
67
+
68
+ return messages;
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ getSessionStats(db: Database.Database, sessionId: string): OpenCodeSessionStats {
75
+ try {
76
+ // Order by time_created — time_updated can lag when OpenCode appends
77
+ // metadata (e.g. summary diffs) to user messages after a turn finishes.
78
+ const last = db
79
+ .prepare<[string], { role: string; timeUpdated: number }>(`
80
+ SELECT json_extract(data, '$.role') AS role,
81
+ time_updated AS timeUpdated
82
+ FROM message
83
+ WHERE session_id = ?
84
+ ORDER BY time_created DESC
85
+ LIMIT 1
86
+ `)
87
+ .get(sessionId);
88
+
89
+ const heartbeat = db
90
+ .prepare<[string], { maxUpdated: number }>(`
91
+ SELECT MAX(time_updated) AS maxUpdated FROM message WHERE session_id = ?
92
+ `)
93
+ .get(sessionId);
94
+
95
+ const lastAssistant = db
96
+ .prepare<
97
+ [string],
98
+ {
99
+ completed: number | null;
100
+ errored: number | null;
101
+ }
102
+ >(`
103
+ SELECT json_extract(data, '$.time.completed') AS completed,
104
+ json_extract(data, '$.time.error') AS errored
105
+ FROM message
106
+ WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'
107
+ ORDER BY time_created DESC
108
+ LIMIT 1
109
+ `)
110
+ .get(sessionId);
111
+
112
+ const first = db
113
+ .prepare<[string], { text: string }>(`
114
+ SELECT json_extract(p.data, '$.text') AS text
115
+ FROM part p
116
+ JOIN message m ON p.message_id = m.id
117
+ WHERE p.session_id = ?
118
+ AND json_extract(m.data, '$.role') = 'user'
119
+ AND json_extract(p.data, '$.type') = 'text'
120
+ AND json_extract(p.data, '$.text') IS NOT NULL
121
+ ORDER BY p.time_created ASC
122
+ LIMIT 1
123
+ `)
124
+ .get(sessionId);
125
+
126
+ return {
127
+ lastRole: last?.role ?? null,
128
+ lastTimeUpdated: heartbeat?.maxUpdated ?? last?.timeUpdated ?? 0,
129
+ lastAssistantCompleted: lastAssistant?.completed != null,
130
+ lastAssistantErrored: lastAssistant?.errored != null,
131
+ summary: first?.text?.trim() ?? "",
132
+ };
133
+ } catch {
134
+ return EMPTY_STATS;
135
+ }
136
+ }
137
+
138
+ private parsePartData(partData: string): OpenCodePartData | null {
139
+ try {
140
+ return JSON.parse(partData) as OpenCodePartData;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ }