@ai-devkit/agent-manager 0.33.1 → 0.35.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 (191) hide show
  1. package/dist/AgentManager.d.ts +2 -0
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +25 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/__tests__/AgentManager.test.js +74 -0
  6. package/dist/__tests__/AgentManager.test.js.map +1 -1
  7. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +28 -0
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -1
  9. package/dist/__tests__/adapters/CodexAdapter.test.js +7 -0
  10. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
  11. package/dist/__tests__/capacity/codex.test.js +2 -1
  12. package/dist/__tests__/capacity/codex.test.js.map +1 -1
  13. package/dist/__tests__/capacity/index.test.js +6 -3
  14. package/dist/__tests__/capacity/index.test.js.map +1 -1
  15. package/dist/__tests__/capacity/zai.test.js +213 -0
  16. package/dist/__tests__/capacity/zai.test.js.map +1 -0
  17. package/dist/__tests__/providers/copilot/CopilotAdapter.test.js +18 -0
  18. package/dist/__tests__/providers/copilot/CopilotAdapter.test.js.map +1 -1
  19. package/dist/__tests__/providers/gemini/GeminiCliAdapter.test.js +36 -0
  20. package/dist/__tests__/providers/gemini/GeminiCliAdapter.test.js.map +1 -1
  21. package/dist/__tests__/providers/grok/GrokAgentMapper.test.js +56 -0
  22. package/dist/__tests__/providers/grok/GrokAgentMapper.test.js.map +1 -0
  23. package/dist/__tests__/{adapters → providers/grok}/GrokCliAdapter.test.js +19 -6
  24. package/dist/__tests__/providers/grok/GrokCliAdapter.test.js.map +1 -0
  25. package/dist/__tests__/providers/grok/GrokSessionLocator.test.js +133 -0
  26. package/dist/__tests__/providers/grok/GrokSessionLocator.test.js.map +1 -0
  27. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js +213 -0
  28. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js.map +1 -0
  29. package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js +89 -0
  30. package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js.map +1 -0
  31. package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js +94 -0
  32. package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js.map +1 -0
  33. package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js +147 -0
  34. package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js.map +1 -0
  35. package/dist/__tests__/providers/pi/PiAdapter.test.js +30 -0
  36. package/dist/__tests__/providers/pi/PiAdapter.test.js.map +1 -1
  37. package/dist/__tests__/utils/session.test.js +18 -1
  38. package/dist/__tests__/utils/session.test.js.map +1 -1
  39. package/dist/adapters/AgentAdapter.d.ts +8 -0
  40. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  41. package/dist/adapters/AgentAdapter.js.map +1 -1
  42. package/dist/adapters/index.d.ts +2 -2
  43. package/dist/adapters/index.d.ts.map +1 -1
  44. package/dist/adapters/index.js +2 -2
  45. package/dist/adapters/index.js.map +1 -1
  46. package/dist/capacity/codex.d.ts +1 -0
  47. package/dist/capacity/codex.d.ts.map +1 -1
  48. package/dist/capacity/codex.js +9 -4
  49. package/dist/capacity/codex.js.map +1 -1
  50. package/dist/capacity/index.d.ts +8 -0
  51. package/dist/capacity/index.d.ts.map +1 -1
  52. package/dist/capacity/index.js +10 -9
  53. package/dist/capacity/index.js.map +1 -1
  54. package/dist/capacity/types.d.ts +8 -0
  55. package/dist/capacity/types.d.ts.map +1 -1
  56. package/dist/capacity/types.js.map +1 -1
  57. package/dist/capacity/zai.d.ts +14 -0
  58. package/dist/capacity/zai.d.ts.map +1 -0
  59. package/dist/capacity/zai.js +203 -0
  60. package/dist/capacity/zai.js.map +1 -0
  61. package/dist/index.d.ts +4 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +3 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/providers/claude/ClaudeCodeAdapter.d.ts +2 -0
  66. package/dist/providers/claude/ClaudeCodeAdapter.d.ts.map +1 -1
  67. package/dist/providers/claude/ClaudeCodeAdapter.js +22 -19
  68. package/dist/providers/claude/ClaudeCodeAdapter.js.map +1 -1
  69. package/dist/providers/claude/ClaudeSessionLocator.d.ts +4 -0
  70. package/dist/providers/claude/ClaudeSessionLocator.d.ts.map +1 -1
  71. package/dist/providers/claude/ClaudeSessionLocator.js +18 -1
  72. package/dist/providers/claude/ClaudeSessionLocator.js.map +1 -1
  73. package/dist/providers/codex/CodexAdapter.d.ts +1 -0
  74. package/dist/providers/codex/CodexAdapter.d.ts.map +1 -1
  75. package/dist/providers/codex/CodexAdapter.js +8 -0
  76. package/dist/providers/codex/CodexAdapter.js.map +1 -1
  77. package/dist/providers/copilot/CopilotAdapter.d.ts +2 -0
  78. package/dist/providers/copilot/CopilotAdapter.d.ts.map +1 -1
  79. package/dist/providers/copilot/CopilotAdapter.js +24 -9
  80. package/dist/providers/copilot/CopilotAdapter.js.map +1 -1
  81. package/dist/providers/copilot/CopilotSessionLocator.d.ts +1 -0
  82. package/dist/providers/copilot/CopilotSessionLocator.d.ts.map +1 -1
  83. package/dist/providers/copilot/CopilotSessionLocator.js +9 -1
  84. package/dist/providers/copilot/CopilotSessionLocator.js.map +1 -1
  85. package/dist/providers/gemini/GeminiCliAdapter.d.ts +1 -0
  86. package/dist/providers/gemini/GeminiCliAdapter.d.ts.map +1 -1
  87. package/dist/providers/gemini/GeminiCliAdapter.js +9 -0
  88. package/dist/providers/gemini/GeminiCliAdapter.js.map +1 -1
  89. package/dist/providers/grok/GrokAgentMapper.d.ts +14 -0
  90. package/dist/providers/grok/GrokAgentMapper.d.ts.map +1 -0
  91. package/dist/providers/grok/GrokAgentMapper.js +36 -0
  92. package/dist/providers/grok/GrokAgentMapper.js.map +1 -0
  93. package/dist/providers/grok/GrokCliAdapter.d.ts +36 -0
  94. package/dist/providers/grok/GrokCliAdapter.d.ts.map +1 -0
  95. package/dist/providers/grok/GrokCliAdapter.js +98 -0
  96. package/dist/providers/grok/GrokCliAdapter.js.map +1 -0
  97. package/dist/providers/grok/GrokSessionLocator.d.ts +62 -0
  98. package/dist/providers/grok/GrokSessionLocator.d.ts.map +1 -0
  99. package/dist/providers/grok/GrokSessionLocator.js +140 -0
  100. package/dist/providers/grok/GrokSessionLocator.js.map +1 -0
  101. package/dist/providers/grok/GrokSessionParser.d.ts +54 -0
  102. package/dist/providers/grok/GrokSessionParser.d.ts.map +1 -0
  103. package/dist/providers/grok/GrokSessionParser.js +122 -0
  104. package/dist/providers/grok/GrokSessionParser.js.map +1 -0
  105. package/dist/{adapters → providers/opencode}/OpenCodeAdapter.d.ts +6 -12
  106. package/dist/providers/opencode/OpenCodeAdapter.d.ts.map +1 -0
  107. package/dist/providers/opencode/OpenCodeAdapter.js +89 -0
  108. package/dist/providers/opencode/OpenCodeAdapter.js.map +1 -0
  109. package/dist/providers/opencode/OpenCodeAgentMapper.d.ts +11 -0
  110. package/dist/providers/opencode/OpenCodeAgentMapper.d.ts.map +1 -0
  111. package/dist/providers/opencode/OpenCodeAgentMapper.js +50 -0
  112. package/dist/providers/opencode/OpenCodeAgentMapper.js.map +1 -0
  113. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts +23 -0
  114. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts.map +1 -0
  115. package/dist/providers/opencode/OpenCodeSessionLocator.js +118 -0
  116. package/dist/providers/opencode/OpenCodeSessionLocator.js.map +1 -0
  117. package/dist/providers/opencode/OpenCodeSessionParser.d.ts +18 -0
  118. package/dist/providers/opencode/OpenCodeSessionParser.d.ts.map +1 -0
  119. package/dist/providers/opencode/OpenCodeSessionParser.js +104 -0
  120. package/dist/providers/opencode/OpenCodeSessionParser.js.map +1 -0
  121. package/dist/providers/pi/PiAdapter.d.ts +1 -0
  122. package/dist/providers/pi/PiAdapter.d.ts.map +1 -1
  123. package/dist/providers/pi/PiAdapter.js +10 -0
  124. package/dist/providers/pi/PiAdapter.js.map +1 -1
  125. package/dist/providers/pi/PiSessionLocator.d.ts +1 -0
  126. package/dist/providers/pi/PiSessionLocator.d.ts.map +1 -1
  127. package/dist/providers/pi/PiSessionLocator.js +8 -1
  128. package/dist/providers/pi/PiSessionLocator.js.map +1 -1
  129. package/dist/utils/session.d.ts +2 -0
  130. package/dist/utils/session.d.ts.map +1 -1
  131. package/dist/utils/session.js +3 -0
  132. package/dist/utils/session.js.map +1 -1
  133. package/package.json +1 -1
  134. package/src/AgentManager.ts +34 -0
  135. package/src/__tests__/AgentManager.test.ts +60 -0
  136. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +23 -0
  137. package/src/__tests__/adapters/CodexAdapter.test.ts +7 -0
  138. package/src/__tests__/capacity/codex.test.ts +94 -26
  139. package/src/__tests__/capacity/fixtures/zai-auth.json +6 -0
  140. package/src/__tests__/capacity/fixtures/zai-quota-invalid.json +7 -0
  141. package/src/__tests__/capacity/fixtures/zai-quota.json +38 -0
  142. package/src/__tests__/capacity/index.test.ts +10 -3
  143. package/src/__tests__/capacity/zai.test.ts +186 -0
  144. package/src/__tests__/providers/copilot/CopilotAdapter.test.ts +12 -0
  145. package/src/__tests__/providers/gemini/GeminiCliAdapter.test.ts +21 -0
  146. package/src/__tests__/providers/grok/GrokAgentMapper.test.ts +57 -0
  147. package/src/__tests__/{adapters → providers/grok}/GrokCliAdapter.test.ts +18 -9
  148. package/src/__tests__/providers/grok/GrokSessionLocator.test.ts +106 -0
  149. package/src/__tests__/providers/opencode/OpenCodeAdapter.test.ts +215 -0
  150. package/src/__tests__/providers/opencode/OpenCodeAgentMapper.test.ts +83 -0
  151. package/src/__tests__/providers/opencode/OpenCodeSessionLocator.test.ts +84 -0
  152. package/src/__tests__/providers/opencode/OpenCodeSessionParser.test.ts +148 -0
  153. package/src/__tests__/providers/pi/PiAdapter.test.ts +15 -0
  154. package/src/__tests__/utils/session.test.ts +11 -1
  155. package/src/adapters/AgentAdapter.ts +9 -0
  156. package/src/adapters/index.ts +2 -2
  157. package/src/capacity/codex.ts +96 -28
  158. package/src/capacity/index.ts +29 -11
  159. package/src/capacity/types.ts +8 -0
  160. package/src/capacity/zai.ts +237 -0
  161. package/src/index.ts +9 -4
  162. package/src/providers/claude/ClaudeCodeAdapter.ts +28 -23
  163. package/src/providers/claude/ClaudeSessionLocator.ts +20 -0
  164. package/src/providers/codex/CodexAdapter.ts +8 -0
  165. package/src/providers/copilot/CopilotAdapter.ts +26 -10
  166. package/src/providers/copilot/CopilotSessionLocator.ts +7 -1
  167. package/src/providers/gemini/GeminiCliAdapter.ts +8 -0
  168. package/src/providers/grok/GrokAgentMapper.ts +41 -0
  169. package/src/providers/grok/GrokCliAdapter.ts +125 -0
  170. package/src/providers/grok/GrokSessionLocator.ts +179 -0
  171. package/src/providers/grok/GrokSessionParser.ts +166 -0
  172. package/src/providers/opencode/OpenCodeAdapter.ts +116 -0
  173. package/src/providers/opencode/OpenCodeAgentMapper.ts +61 -0
  174. package/src/providers/opencode/OpenCodeSessionLocator.ts +151 -0
  175. package/src/providers/opencode/OpenCodeSessionParser.ts +145 -0
  176. package/src/providers/pi/PiAdapter.ts +12 -0
  177. package/src/providers/pi/PiSessionLocator.ts +16 -1
  178. package/src/utils/session.ts +11 -0
  179. package/dist/__tests__/adapters/GrokCliAdapter.test.js.map +0 -1
  180. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +0 -636
  181. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +0 -1
  182. package/dist/adapters/GrokCliAdapter.d.ts +0 -80
  183. package/dist/adapters/GrokCliAdapter.d.ts.map +0 -1
  184. package/dist/adapters/GrokCliAdapter.js +0 -310
  185. package/dist/adapters/GrokCliAdapter.js.map +0 -1
  186. package/dist/adapters/OpenCodeAdapter.d.ts.map +0 -1
  187. package/dist/adapters/OpenCodeAdapter.js +0 -289
  188. package/dist/adapters/OpenCodeAdapter.js.map +0 -1
  189. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +0 -544
  190. package/src/adapters/GrokCliAdapter.ts +0 -408
  191. package/src/adapters/OpenCodeAdapter.ts +0 -373
@@ -0,0 +1,106 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import type { ProcessInfo } from "../../../adapters/AgentAdapter.js";
7
+ import { GrokSessionLocator } from "../../../providers/grok/GrokSessionLocator.js";
8
+
9
+ describe("GrokSessionLocator", () => {
10
+ let baseDir: string;
11
+ let locator: GrokSessionLocator;
12
+
13
+ beforeEach(() => {
14
+ baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "grok-locator-test-"));
15
+ locator = new GrokSessionLocator({ baseDir });
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(baseDir, { recursive: true, force: true });
20
+ });
21
+
22
+ function writeSession(cwd: string, id: string, mtime?: Date): string {
23
+ const sessionDir = path.join(baseDir, "sessions", encodeURIComponent(cwd), id);
24
+ fs.mkdirSync(sessionDir, { recursive: true });
25
+ const chatPath = path.join(sessionDir, "chat_history.jsonl");
26
+ fs.writeFileSync(chatPath, "");
27
+ if (mtime) fs.utimesSync(chatPath, mtime, mtime);
28
+ return sessionDir;
29
+ }
30
+
31
+ function proc(overrides: Partial<ProcessInfo> = {}): ProcessInfo {
32
+ return { pid: 4242, command: "grok", cwd: "/repo/app", tty: "ttys001", ...overrides };
33
+ }
34
+
35
+ describe("matchRunningProcesses", () => {
36
+ it("prefers the active_sessions.json cwd over the process cwd", () => {
37
+ const sessionDir = writeSession("/repo/real", "s1");
38
+ fs.writeFileSync(
39
+ path.join(baseDir, "active_sessions.json"),
40
+ JSON.stringify([{ pid: 4242, cwd: "/repo/real" }]),
41
+ );
42
+
43
+ expect(locator.matchRunningProcesses([proc({ cwd: "/wrong" })])).toEqual([
44
+ { process: expect.objectContaining({ pid: 4242 }), cwd: "/repo/real", sessionDir },
45
+ ]);
46
+ });
47
+
48
+ it("ignores a malformed active_sessions.json and falls back to the process cwd", () => {
49
+ const sessionDir = writeSession("/repo/app", "s1");
50
+ fs.writeFileSync(path.join(baseDir, "active_sessions.json"), "{not json");
51
+
52
+ expect(locator.matchRunningProcesses([proc()])[0]).toMatchObject({
53
+ cwd: "/repo/app",
54
+ sessionDir,
55
+ });
56
+ });
57
+
58
+ it("picks the session with the newest chat_history.jsonl", () => {
59
+ writeSession("/repo/app", "old", new Date(Date.now() - 60_000));
60
+ const newest = writeSession("/repo/app", "new");
61
+
62
+ expect(locator.matchRunningProcesses([proc()])[0].sessionDir).toBe(newest);
63
+ });
64
+
65
+ it("returns a null sessionDir when no cwd is known", () => {
66
+ expect(locator.matchRunningProcesses([proc({ cwd: "" })])[0]).toMatchObject({
67
+ cwd: "",
68
+ sessionDir: null,
69
+ });
70
+ });
71
+ });
72
+
73
+ describe("historical sessions", () => {
74
+ it("discovers session dirs with the decoded group cwd", () => {
75
+ const a = writeSession("/repo/a", "s1");
76
+ const b = writeSession("/repo/b", "s2");
77
+
78
+ expect(locator.discoverHistoricalSessionDirs()).toEqual(
79
+ expect.arrayContaining([
80
+ { sessionDir: a, defaultCwd: "/repo/a" },
81
+ { sessionDir: b, defaultCwd: "/repo/b" },
82
+ ]),
83
+ );
84
+ });
85
+
86
+ it("prefers the .cwd file over the encoded group name", () => {
87
+ const groupDir = path.join(baseDir, "sessions", "slug-abc123");
88
+ fs.mkdirSync(path.join(groupDir, "s1"), { recursive: true });
89
+ fs.writeFileSync(path.join(groupDir, ".cwd"), "/very/long/path\n");
90
+
91
+ expect(locator.discoverHistoricalSessionDirs()).toEqual([
92
+ { sessionDir: path.join(groupDir, "s1"), defaultCwd: "/very/long/path" },
93
+ ]);
94
+ });
95
+
96
+ it("finds session dirs by id and rejects unsafe ids", () => {
97
+ const dir = writeSession("/repo/a", "s1");
98
+ writeSession("/repo/b", "s2");
99
+
100
+ expect(locator.findHistoricalSessionDirsById("s1")).toEqual([
101
+ { sessionDir: dir, defaultCwd: "/repo/a" },
102
+ ]);
103
+ expect(locator.findHistoricalSessionDirsById("../s1")).toEqual([]);
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,215 @@
1
+ import Database from "better-sqlite3";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { AgentStatus, type ProcessInfo } from "../../../adapters/AgentAdapter.js";
7
+ import { OpenCodeAdapter } from "../../../providers/opencode/OpenCodeAdapter.js";
8
+
9
+ describe("OpenCodeAdapter", () => {
10
+ let adapter: OpenCodeAdapter;
11
+ let tmpDir: string;
12
+ let dbPath: string;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-adapter-test-"));
16
+ dbPath = path.join(tmpDir, "opencode.db");
17
+ adapter = new OpenCodeAdapter(dbPath);
18
+ });
19
+
20
+ afterEach(() => {
21
+ adapter.close();
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("detects opencode executables", () => {
26
+ expect(adapter.canHandle({ pid: 1, command: "opencode", cwd: "/repo" })).toBe(true);
27
+ expect(adapter.canHandle({ pid: 2, command: "/usr/bin/opencode.exe", cwd: "/repo" })).toBe(
28
+ true,
29
+ );
30
+ expect(adapter.canHandle({ pid: 3, command: "node /repo/opencode-plugin.js" })).toBe(false);
31
+ });
32
+
33
+ it("returns process-only agents when the database is unavailable", async () => {
34
+ const agents = await adapter.detectAgents({
35
+ processes: [{ pid: 100, command: "opencode", cwd: "/repo", tty: "ttys001" }],
36
+ });
37
+
38
+ expect(agents).toEqual([
39
+ expect.objectContaining({
40
+ type: "opencode",
41
+ status: AgentStatus.RUNNING,
42
+ pid: 100,
43
+ projectPath: "/repo",
44
+ sessionId: "pid-100",
45
+ summary: "OpenCode process running",
46
+ }),
47
+ ]);
48
+ });
49
+
50
+ it("maps matching OpenCode sessions to agents", async () => {
51
+ const now = Date.now();
52
+ writeDatabase(dbPath, {
53
+ sessions: [{ id: "sess-001", directory: "/repo", timeCreated: now - 60_000 }],
54
+ messages: [
55
+ {
56
+ id: "msg-user",
57
+ sessionId: "sess-001",
58
+ role: "user",
59
+ timeCreated: now - 50_000,
60
+ timeUpdated: now - 50_000,
61
+ },
62
+ {
63
+ id: "msg-assistant",
64
+ sessionId: "sess-001",
65
+ role: "assistant",
66
+ timeCreated: now - 30_000,
67
+ timeUpdated: now - 30_000,
68
+ completed: now - 20_000,
69
+ },
70
+ ],
71
+ parts: [
72
+ {
73
+ messageId: "msg-user",
74
+ sessionId: "sess-001",
75
+ type: "text",
76
+ text: "Refactor this module",
77
+ timeCreated: now - 50_000,
78
+ },
79
+ ],
80
+ });
81
+
82
+ const process: ProcessInfo = { pid: 200, command: "opencode", cwd: "/repo", tty: "ttys002" };
83
+ const [agent] = await adapter.detectAgents({ processes: [process] });
84
+
85
+ expect(agent).toMatchObject({
86
+ type: "opencode",
87
+ status: AgentStatus.WAITING,
88
+ pid: 200,
89
+ projectPath: "/repo",
90
+ sessionId: "sess-001",
91
+ summary: "Refactor this module",
92
+ sessionFilePath: `${dbPath}::sess-001`,
93
+ });
94
+ });
95
+
96
+ it("returns conversation messages for encoded OpenCode session refs", () => {
97
+ writeDatabase(dbPath, {
98
+ sessions: [{ id: "sess-abc", directory: "/repo", timeCreated: Date.now() }],
99
+ messages: [
100
+ {
101
+ id: "msg-user",
102
+ sessionId: "sess-abc",
103
+ role: "user",
104
+ timeCreated: 1000,
105
+ timeUpdated: 1000,
106
+ },
107
+ ],
108
+ parts: [
109
+ {
110
+ messageId: "msg-user",
111
+ sessionId: "sess-abc",
112
+ type: "text",
113
+ text: "Hello agent",
114
+ timeCreated: 1000,
115
+ },
116
+ ],
117
+ });
118
+
119
+ expect(adapter.getConversation(`${dbPath}::sess-abc`)).toEqual([
120
+ { role: "user", content: "Hello agent" },
121
+ ]);
122
+ expect(adapter.getConversation("/not-an-opencode-ref")).toEqual([]);
123
+ });
124
+
125
+ it("finds an exact session ID with a targeted database query", async () => {
126
+ const now = Date.now();
127
+ writeDatabase(dbPath, {
128
+ sessions: [
129
+ { id: "wanted", directory: "/repo", timeCreated: now },
130
+ { id: "other", directory: "/other", timeCreated: now - 1_000 },
131
+ ],
132
+ });
133
+
134
+ await expect(adapter.findSessionsById("wanted")).resolves.toEqual([
135
+ expect.objectContaining({
136
+ type: "opencode",
137
+ sessionId: "wanted",
138
+ sessionFilePath: `${dbPath}::wanted`,
139
+ }),
140
+ ]);
141
+ });
142
+ });
143
+
144
+ function writeDatabase(
145
+ filePath: string,
146
+ data: {
147
+ sessions: Array<{ id: string; directory: string; timeCreated: number }>;
148
+ messages?: Array<{
149
+ id: string;
150
+ sessionId: string;
151
+ role: string;
152
+ timeCreated: number;
153
+ timeUpdated: number;
154
+ completed?: number;
155
+ }>;
156
+ parts?: Array<{
157
+ messageId: string;
158
+ sessionId: string;
159
+ type: string;
160
+ text?: string;
161
+ reasoning?: string;
162
+ tool?: string;
163
+ timeCreated: number;
164
+ }>;
165
+ },
166
+ ): void {
167
+ const db = new Database(filePath);
168
+ db.exec(`
169
+ CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER);
170
+ CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
171
+ CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER);
172
+ `);
173
+
174
+ const insertSession = db.prepare(
175
+ "INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)",
176
+ );
177
+ for (const session of data.sessions) {
178
+ insertSession.run(session.id, session.directory, session.timeCreated);
179
+ }
180
+
181
+ const insertMessage = db.prepare(
182
+ "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
183
+ );
184
+ for (const message of data.messages ?? []) {
185
+ insertMessage.run(
186
+ message.id,
187
+ message.sessionId,
188
+ JSON.stringify({
189
+ role: message.role,
190
+ time: message.completed === undefined ? {} : { completed: message.completed },
191
+ }),
192
+ message.timeCreated,
193
+ message.timeUpdated,
194
+ );
195
+ }
196
+
197
+ const insertPart = db.prepare(
198
+ "INSERT INTO part (message_id, session_id, data, time_created) VALUES (?, ?, ?, ?)",
199
+ );
200
+ for (const part of data.parts ?? []) {
201
+ insertPart.run(
202
+ part.messageId,
203
+ part.sessionId,
204
+ JSON.stringify({
205
+ type: part.type,
206
+ text: part.text,
207
+ reasoning: part.reasoning,
208
+ tool: part.tool,
209
+ }),
210
+ part.timeCreated,
211
+ );
212
+ }
213
+
214
+ db.close();
215
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AgentStatus } from "../../../adapters/AgentAdapter.js";
3
+ import { OpenCodeAgentMapper } from "../../../providers/opencode/OpenCodeAgentMapper.js";
4
+
5
+ describe("OpenCodeAgentMapper", () => {
6
+ const mapper = new OpenCodeAgentMapper("/tmp/opencode.db");
7
+
8
+ it("maps completed assistant sessions as waiting", () => {
9
+ const now = Date.now();
10
+
11
+ const agent = mapper.mapSessionToAgent(
12
+ { sessionId: "sess-1", directory: "/repo", timeCreated: now - 60_000 },
13
+ {
14
+ lastRole: "assistant",
15
+ lastTimeUpdated: now - 30_000,
16
+ lastAssistantCompleted: true,
17
+ lastAssistantErrored: false,
18
+ summary: "Ship the feature",
19
+ },
20
+ { pid: 123, command: "opencode", cwd: "/repo" },
21
+ );
22
+
23
+ expect(agent).toMatchObject({
24
+ type: "opencode",
25
+ status: AgentStatus.WAITING,
26
+ summary: "Ship the feature",
27
+ pid: 123,
28
+ projectPath: "/repo",
29
+ sessionId: "sess-1",
30
+ sessionFilePath: "/tmp/opencode.db::sess-1",
31
+ });
32
+ });
33
+
34
+ it("keeps incomplete assistant sessions running even when they are quiet briefly", () => {
35
+ const now = Date.now();
36
+
37
+ const agent = mapper.mapSessionToAgent(
38
+ { sessionId: "sess-2", directory: "/repo", timeCreated: now - 120_000 },
39
+ {
40
+ lastRole: "assistant",
41
+ lastTimeUpdated: now - 120_000,
42
+ lastAssistantCompleted: false,
43
+ lastAssistantErrored: false,
44
+ summary: "",
45
+ },
46
+ { pid: 124, command: "opencode", cwd: "/repo" },
47
+ );
48
+
49
+ expect(agent.status).toBe(AgentStatus.RUNNING);
50
+ expect(agent.summary).toBe("OpenCode session active");
51
+ });
52
+
53
+ it("marks stale sessions idle", () => {
54
+ const staleTime = Date.now() - 10 * 60 * 1000;
55
+
56
+ const agent = mapper.mapSessionToAgent(
57
+ { sessionId: "sess-3", directory: "/old", timeCreated: staleTime },
58
+ {
59
+ lastRole: "assistant",
60
+ lastTimeUpdated: staleTime,
61
+ lastAssistantCompleted: true,
62
+ lastAssistantErrored: false,
63
+ summary: "",
64
+ },
65
+ { pid: 125, command: "opencode", cwd: "/old" },
66
+ );
67
+
68
+ expect(agent.status).toBe(AgentStatus.IDLE);
69
+ });
70
+
71
+ it("maps unmatched processes as running process-only agents", () => {
72
+ const agent = mapper.mapProcessOnlyAgent({ pid: 500, command: "opencode", cwd: "/repo" });
73
+
74
+ expect(agent).toMatchObject({
75
+ type: "opencode",
76
+ status: AgentStatus.RUNNING,
77
+ summary: "OpenCode process running",
78
+ pid: 500,
79
+ projectPath: "/repo",
80
+ sessionId: "pid-500",
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,84 @@
1
+ import Database from "better-sqlite3";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { OpenCodeSessionLocator } from "../../../providers/opencode/OpenCodeSessionLocator.js";
7
+ import { OpenCodeSessionParser } from "../../../providers/opencode/OpenCodeSessionParser.js";
8
+
9
+ describe("OpenCodeSessionLocator", () => {
10
+ let tmpDir: string;
11
+ let dbPath: string;
12
+ let locator: OpenCodeSessionLocator;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-locator-test-"));
16
+ dbPath = path.join(tmpDir, "opencode.db");
17
+ locator = new OpenCodeSessionLocator(dbPath, new OpenCodeSessionParser());
18
+ });
19
+
20
+ afterEach(() => {
21
+ locator.close();
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("returns null when the database file does not exist", () => {
26
+ expect(locator.openDb()).toBeNull();
27
+ expect(locator.listSessions()).toEqual([]);
28
+ });
29
+
30
+ it("finds the newest session for a directory", () => {
31
+ writeDatabase(dbPath, [
32
+ { id: "old", directory: "/repo", timeCreated: 1000 },
33
+ { id: "new", directory: "/repo", timeCreated: 2000 },
34
+ { id: "other", directory: "/other", timeCreated: 3000 },
35
+ ]);
36
+
37
+ const db = locator.openDb();
38
+ expect(db).not.toBeNull();
39
+ expect(locator.findSessionForDirectory(db!, "/repo")).toEqual({
40
+ sessionId: "new",
41
+ directory: "/repo",
42
+ timeCreated: 2000,
43
+ });
44
+ });
45
+
46
+ it("lists sessions and filters by cwd", () => {
47
+ writeDatabase(dbPath, [
48
+ { id: "sess-a", directory: "/repo-a", timeCreated: 1000 },
49
+ { id: "sess-b", directory: "/repo-b", timeCreated: 2000 },
50
+ ]);
51
+
52
+ const sessions = locator.listSessions({ cwd: "/repo-a" });
53
+
54
+ expect(sessions).toEqual([
55
+ expect.objectContaining({
56
+ type: "opencode",
57
+ sessionId: "sess-a",
58
+ cwd: "/repo-a",
59
+ sessionFilePath: `${dbPath}::sess-a`,
60
+ }),
61
+ ]);
62
+ });
63
+ });
64
+
65
+ function writeDatabase(
66
+ filePath: string,
67
+ sessions: Array<{ id: string; directory: string; timeCreated: number }>,
68
+ ): void {
69
+ const db = new Database(filePath);
70
+ db.exec(`
71
+ CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER);
72
+ CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
73
+ CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER);
74
+ `);
75
+
76
+ const insertSession = db.prepare(
77
+ "INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)",
78
+ );
79
+ for (const session of sessions) {
80
+ insertSession.run(session.id, session.directory, session.timeCreated);
81
+ }
82
+
83
+ db.close();
84
+ }
@@ -0,0 +1,148 @@
1
+ import Database from "better-sqlite3";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { OpenCodeSessionParser } from "../../../providers/opencode/OpenCodeSessionParser.js";
4
+
5
+ describe("OpenCodeSessionParser", () => {
6
+ let db: Database.Database;
7
+ let parser: OpenCodeSessionParser;
8
+
9
+ beforeEach(() => {
10
+ db = new Database(":memory:");
11
+ db.exec(`
12
+ CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
13
+ CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER);
14
+ `);
15
+ parser = new OpenCodeSessionParser();
16
+ });
17
+
18
+ afterEach(() => {
19
+ db.close();
20
+ });
21
+
22
+ it("extracts session stats from OpenCode messages and parts", () => {
23
+ insertMessage(db, {
24
+ id: "msg-user",
25
+ sessionId: "sess-1",
26
+ role: "user",
27
+ timeCreated: 1000,
28
+ timeUpdated: 1000,
29
+ });
30
+ insertMessage(db, {
31
+ id: "msg-assistant",
32
+ sessionId: "sess-1",
33
+ role: "assistant",
34
+ timeCreated: 2000,
35
+ timeUpdated: 2500,
36
+ completed: 2400,
37
+ });
38
+ insertPart(db, {
39
+ messageId: "msg-user",
40
+ sessionId: "sess-1",
41
+ data: { type: "text", text: "Plan the migration" },
42
+ timeCreated: 1000,
43
+ });
44
+
45
+ expect(parser.getSessionStats(db, "sess-1")).toEqual({
46
+ lastRole: "assistant",
47
+ lastTimeUpdated: 2500,
48
+ lastAssistantCompleted: true,
49
+ lastAssistantErrored: false,
50
+ summary: "Plan the migration",
51
+ });
52
+ });
53
+
54
+ it("orders stats by message creation time, not metadata update time", () => {
55
+ insertMessage(db, {
56
+ id: "msg-user",
57
+ sessionId: "sess-1",
58
+ role: "user",
59
+ timeCreated: 1000,
60
+ timeUpdated: 3000,
61
+ });
62
+ insertMessage(db, {
63
+ id: "msg-assistant",
64
+ sessionId: "sess-1",
65
+ role: "assistant",
66
+ timeCreated: 2000,
67
+ timeUpdated: 2100,
68
+ completed: 2200,
69
+ });
70
+
71
+ expect(parser.getSessionStats(db, "sess-1").lastRole).toBe("assistant");
72
+ });
73
+
74
+ it("reads visible conversation text and hides verbose-only parts by default", () => {
75
+ insertMessage(db, {
76
+ id: "msg-user",
77
+ sessionId: "sess-1",
78
+ role: "user",
79
+ timeCreated: 1000,
80
+ timeUpdated: 1000,
81
+ });
82
+ insertMessage(db, {
83
+ id: "msg-assistant",
84
+ sessionId: "sess-1",
85
+ role: "assistant",
86
+ timeCreated: 2000,
87
+ timeUpdated: 2000,
88
+ });
89
+ insertPart(db, {
90
+ messageId: "msg-user",
91
+ sessionId: "sess-1",
92
+ data: { type: "text", text: "Hello" },
93
+ timeCreated: 1000,
94
+ });
95
+ insertPart(db, {
96
+ messageId: "msg-assistant",
97
+ sessionId: "sess-1",
98
+ data: { type: "reasoning", reasoning: "private thought" },
99
+ timeCreated: 2000,
100
+ });
101
+ insertPart(db, {
102
+ messageId: "msg-assistant",
103
+ sessionId: "sess-1",
104
+ data: { type: "tool", tool: "read_file" },
105
+ timeCreated: 3000,
106
+ });
107
+
108
+ expect(parser.getConversation(db, "sess-1")).toEqual([{ role: "user", content: "Hello" }]);
109
+ expect(parser.getConversation(db, "sess-1", { verbose: true })).toEqual([
110
+ { role: "user", content: "Hello" },
111
+ { role: "assistant", content: "[thinking] private thought" },
112
+ { role: "assistant", content: "[tool: read_file]" },
113
+ ]);
114
+ });
115
+ });
116
+
117
+ function insertMessage(
118
+ db: Database.Database,
119
+ input: {
120
+ id: string;
121
+ sessionId: string;
122
+ role: string;
123
+ timeCreated: number;
124
+ timeUpdated: number;
125
+ completed?: number;
126
+ },
127
+ ): void {
128
+ db.prepare(
129
+ "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
130
+ ).run(
131
+ input.id,
132
+ input.sessionId,
133
+ JSON.stringify({
134
+ role: input.role,
135
+ time: input.completed === undefined ? {} : { completed: input.completed },
136
+ }),
137
+ input.timeCreated,
138
+ input.timeUpdated,
139
+ );
140
+ }
141
+
142
+ function insertPart(
143
+ db: Database.Database,
144
+ input: { messageId: string; sessionId: string; data: unknown; timeCreated: number },
145
+ ): void {
146
+ db.prepare("INSERT INTO part (message_id, session_id, data, time_created) VALUES (?, ?, ?, ?)")
147
+ .run(input.messageId, input.sessionId, JSON.stringify(input.data), input.timeCreated);
148
+ }
@@ -260,6 +260,21 @@ describe("PiAdapter", () => {
260
260
  ]);
261
261
  });
262
262
 
263
+ it("finds an exact session ID from the filename suffix", async () => {
264
+ const wanted = writePiSession("/repo/wanted", [
265
+ { timestamp: "2026-06-10T08:58:20.754Z", sessionId: "sess-wanted", cwd: "/repo/wanted" },
266
+ { role: "user", content: "wanted" },
267
+ ]);
268
+ writePiSession("/repo/other", [
269
+ { timestamp: "2026-06-10T08:58:20.754Z", sessionId: "sess-other", cwd: "/repo/other" },
270
+ { role: "user", content: "other" },
271
+ ]);
272
+
273
+ await expect(adapter.findSessionsById("sess-wanted")).resolves.toEqual([
274
+ expect.objectContaining({ sessionId: "sess-wanted", sessionFilePath: wanted }),
275
+ ]);
276
+ });
277
+
263
278
  function makeProcess(overrides: Partial<ProcessInfo>): ProcessInfo {
264
279
  return {
265
280
  pid: 1,
@@ -5,7 +5,7 @@
5
5
  import type { MockedFunction } from "vitest";
6
6
 
7
7
  import * as fs from "fs";
8
- import { batchGetSessionFileBirthtimes } from "../../utils/session.js";
8
+ import { batchGetSessionFileBirthtimes, isSafePathSegment } from "../../utils/session.js";
9
9
 
10
10
  vi.mock("fs", () => ({
11
11
  readdirSync: vi.fn(),
@@ -15,6 +15,16 @@ vi.mock("fs", () => ({
15
15
  const mockedReaddirSync = fs.readdirSync as MockedFunction<typeof fs.readdirSync>;
16
16
  const mockedStatSync = fs.statSync as MockedFunction<typeof fs.statSync>;
17
17
 
18
+ describe("isSafePathSegment", () => {
19
+ it.each(["", ".", "..", "nested/session", "bad\0id"])("rejects unsafe segment %j", (value) => {
20
+ expect(isSafePathSegment(value)).toBe(false);
21
+ });
22
+
23
+ it.each(["session-id", ".hidden-session"])("accepts safe segment %j", (value) => {
24
+ expect(isSafePathSegment(value)).toBe(true);
25
+ });
26
+ });
27
+
18
28
  describe("batchGetSessionFileBirthtimes", () => {
19
29
  beforeEach(() => {
20
30
  mockedReaddirSync.mockReset();
@@ -208,4 +208,13 @@ export interface AgentAdapter {
208
208
  * @returns Array of sessions discovered on disk
209
209
  */
210
210
  listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]>;
211
+
212
+ /**
213
+ * Resolve exact historical session matches without enumerating every summary.
214
+ *
215
+ * Built-in adapters implement this using their provider-native storage shape.
216
+ * The method remains optional so external adapters can fall back to
217
+ * {@link listSessions} until they adopt direct lookup.
218
+ */
219
+ findSessionsById?(sessionId: string): Promise<SessionSummary[]>;
211
220
  }