tuiboard 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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Codex CLI adapter for the agents store.
3
+ *
4
+ * Reads (read-only), under $CODEX_HOME (default ~/.codex):
5
+ * sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl[.zst] — transcripts
6
+ * archived_sessions/rollout-… — archived ones
7
+ * session_index.jsonl — explicit renames
8
+ * state_<n>.sqlite, table `threads` — optional enrichment
9
+ *
10
+ * Format per the Codex source (rust-v0.154.0). Rollouts are authoritative —
11
+ * Codex itself treats the SQLite DB as a rebuildable view — so the list comes
12
+ * from the files and the DB only fills in names and archive flags.
13
+ *
14
+ * Codex keeps no per-session PID file, so liveness is inferred like OpenCode:
15
+ * a `task_started` with no later `task_complete` / `turn_aborted` is a turn in
16
+ * progress (a clean exit writes `turn_aborted`; a killed process leaves the
17
+ * turn open, hence "stale" after CODEX_STALE_AFTER_MS). An idle TUI waiting
18
+ * for input isn't detectable, so Codex sessions are never `live-idle`.
19
+ */
20
+
21
+ import { Database } from "bun:sqlite";
22
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import {
27
+ DORMANT_AFTER_MS,
28
+ cwdShort,
29
+ type AgentAdapter,
30
+ type AgentSession,
31
+ type AgentStatus,
32
+ } from "~/store/agents";
33
+
34
+ /** Open turn untouched longer than this → "stale" (approval prompts write nothing). */
35
+ export const CODEX_STALE_AFTER_MS = 30 * 60 * 1000;
36
+
37
+ export function defaultCodexHome(): string {
38
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
39
+ }
40
+
41
+ /** `rollout-2026-09-17T08-00-00-<uuid>[_<rolloutId>].jsonl[.zst]` → uuid. */
42
+ const ROLLOUT_FILE =
43
+ /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_[^.]+)?\.jsonl(\.zst)?$/i;
44
+
45
+ export function threadIdFromRolloutName(name: string): string | undefined {
46
+ return ROLLOUT_FILE.exec(name)?.[1]?.toLowerCase();
47
+ }
48
+
49
+ const TOOL_ITEM_TYPES = new Set([
50
+ "function_call",
51
+ "custom_tool_call",
52
+ "local_shell_call",
53
+ "web_search_call",
54
+ "tool_search_call",
55
+ "image_generation_call",
56
+ ]);
57
+
58
+ /** Thread sources that aren't a human-driven session. */
59
+ const HIDDEN_THREAD_SOURCES = new Set(["subagent", "guardian_review", "memory_consolidation"]);
60
+
61
+ /** Heading the IDE integrations put before the actual prompt. */
62
+ const REQUEST_HEADING = "## My request for Codex:";
63
+
64
+ export interface RolloutParseResult {
65
+ cwd?: string;
66
+ gitBranch?: string;
67
+ /** Model of the latest turn (`turn_context.model`). */
68
+ model?: string;
69
+ /** Subagent / internal thread — not listed on its own. */
70
+ hidden: boolean;
71
+ firstUser?: string;
72
+ lastUser?: string;
73
+ lastAssistant?: string;
74
+ messageCount: number;
75
+ toolCount: number;
76
+ /** A turn started and hasn't completed or been aborted. */
77
+ turnOpen: boolean;
78
+ }
79
+
80
+ function cleanPrompt(text: string): string {
81
+ const at = text.indexOf(REQUEST_HEADING);
82
+ return (at >= 0 ? text.slice(at + REQUEST_HEADING.length) : text).trim();
83
+ }
84
+
85
+ function joinText(content: unknown): string | undefined {
86
+ if (!Array.isArray(content)) return undefined;
87
+ const texts = content
88
+ .filter((c) => c && typeof c.text === "string" && String(c.type).toLowerCase() === "text")
89
+ .map((c) => c.text as string);
90
+ return texts.length > 0 ? texts.join("\n") : undefined;
91
+ }
92
+
93
+ function isHiddenSource(source: unknown, threadSource: unknown): boolean {
94
+ if (typeof threadSource === "string" && HIDDEN_THREAD_SOURCES.has(threadSource)) return true;
95
+ // SessionSource: "cli" | "vscode" | "exec" | "mcp" | {custom} | {subagent} | {internal}
96
+ return (
97
+ !!source &&
98
+ typeof source === "object" &&
99
+ ("subagent" in source || "internal" in source)
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Single pass over a rollout. Defensive: malformed lines are skipped, since
105
+ * the format is internal to Codex and drifts between versions.
106
+ */
107
+ export function parseRollout(content: string, threadId?: string): RolloutParseResult {
108
+ const out: RolloutParseResult = {
109
+ hidden: false,
110
+ messageCount: 0,
111
+ toolCount: 0,
112
+ turnOpen: false,
113
+ };
114
+ let metaMatched = false;
115
+ let lastAgentFallback: string | undefined;
116
+
117
+ const user = (raw: string) => {
118
+ const text = cleanPrompt(raw);
119
+ out.messageCount++;
120
+ if (!text) return;
121
+ out.lastUser = text;
122
+ out.firstUser ??= text;
123
+ };
124
+ const assistant = (text: string) => {
125
+ out.messageCount++;
126
+ out.lastAssistant = text;
127
+ };
128
+
129
+ for (const line of content.split("\n")) {
130
+ if (!line.trim()) continue;
131
+ let obj: any;
132
+ try {
133
+ obj = JSON.parse(line);
134
+ } catch {
135
+ continue;
136
+ }
137
+ const p = obj?.payload;
138
+ if (!p || typeof p !== "object") continue;
139
+
140
+ if (obj.type === "session_meta") {
141
+ // A forked rollout embeds its parent's meta too; prefer our own.
142
+ const own = threadId !== undefined && String(p.id).toLowerCase() === threadId;
143
+ if (metaMatched || (out.cwd !== undefined && !own)) continue;
144
+ metaMatched = own;
145
+ out.cwd = typeof p.cwd === "string" ? p.cwd : out.cwd;
146
+ out.gitBranch = p.git?.branch ?? out.gitBranch;
147
+ out.hidden = isHiddenSource(p.source, p.thread_source);
148
+ } else if (obj.type === "turn_context") {
149
+ if (typeof p.model === "string") out.model = p.model;
150
+ } else if (obj.type === "response_item") {
151
+ if (TOOL_ITEM_TYPES.has(p.type)) out.toolCount++;
152
+ } else if (obj.type === "event_msg") {
153
+ switch (p.type) {
154
+ case "task_started":
155
+ case "turn_started":
156
+ out.turnOpen = true;
157
+ break;
158
+ case "task_complete":
159
+ case "turn_complete":
160
+ out.turnOpen = false;
161
+ if (typeof p.last_agent_message === "string") lastAgentFallback = p.last_agent_message;
162
+ break;
163
+ case "turn_aborted":
164
+ out.turnOpen = false;
165
+ break;
166
+ // Legacy history mode.
167
+ case "user_message":
168
+ if (typeof p.message === "string") user(p.message);
169
+ break;
170
+ case "agent_message":
171
+ if (typeof p.message === "string") assistant(p.message);
172
+ break;
173
+ // Paginated history mode (default for new threads).
174
+ case "item_completed": {
175
+ const item = p.item;
176
+ if (item?.type === "UserMessage") {
177
+ const text = joinText(item.content);
178
+ if (text !== undefined) user(text);
179
+ } else if (item?.type === "AgentMessage") {
180
+ const text = joinText(item.content);
181
+ if (text !== undefined) assistant(text);
182
+ }
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ out.lastAssistant ??= lastAgentFallback;
190
+ return out;
191
+ }
192
+
193
+ export function classifyCodexStatus(
194
+ now: number,
195
+ lastActivityMs: number,
196
+ turnOpen: boolean,
197
+ archived: boolean,
198
+ ): AgentStatus {
199
+ if (archived) return "archived";
200
+ const age = now - lastActivityMs;
201
+ if (age > DORMANT_AFTER_MS) return "archived";
202
+ if (turnOpen) return age > CODEX_STALE_AFTER_MS ? "stale" : "live-busy";
203
+ return "dormant";
204
+ }
205
+
206
+ // ─── Discovery ──────────────────────────────────────────────────────────────
207
+
208
+ interface RolloutFile {
209
+ threadId: string;
210
+ path: string;
211
+ mtimeMs: number;
212
+ size: number;
213
+ compressed: boolean;
214
+ inArchive: boolean;
215
+ }
216
+
217
+ function listRollouts(dir: string, inArchive: boolean, out: Map<string, RolloutFile>): void {
218
+ let entries;
219
+ try {
220
+ entries = readdirSync(dir, { withFileTypes: true });
221
+ } catch {
222
+ return;
223
+ }
224
+ for (const e of entries) {
225
+ const path = join(dir, e.name);
226
+ if (e.isDirectory()) {
227
+ listRollouts(path, inArchive, out);
228
+ continue;
229
+ }
230
+ const threadId = threadIdFromRolloutName(e.name);
231
+ if (!threadId || !e.isFile()) continue;
232
+ let st;
233
+ try {
234
+ st = statSync(path);
235
+ } catch {
236
+ continue;
237
+ }
238
+ // A reverted thread has several rollouts; the newest one is current.
239
+ const prev = out.get(threadId);
240
+ if (prev && prev.mtimeMs >= st.mtimeMs) continue;
241
+ out.set(threadId, {
242
+ threadId,
243
+ path,
244
+ mtimeMs: st.mtimeMs,
245
+ size: st.size,
246
+ compressed: e.name.endsWith(".zst"),
247
+ inArchive,
248
+ });
249
+ }
250
+ }
251
+
252
+ /** Last explicit rename per thread (append-only file, last entry wins). */
253
+ function readSessionIndex(path: string): Map<string, string> {
254
+ const names = new Map<string, string>();
255
+ let content: string;
256
+ try {
257
+ content = readFileSync(path, "utf-8");
258
+ } catch {
259
+ return names;
260
+ }
261
+ for (const line of content.split("\n")) {
262
+ try {
263
+ const e = JSON.parse(line);
264
+ if (typeof e?.id === "string" && typeof e.thread_name === "string") {
265
+ names.set(e.id.toLowerCase(), e.thread_name);
266
+ }
267
+ } catch {
268
+ // skip partial/malformed lines
269
+ }
270
+ }
271
+ return names;
272
+ }
273
+
274
+ interface ThreadRow {
275
+ id: string;
276
+ title: string | null;
277
+ name: string | null;
278
+ archived: number | null;
279
+ cwd: string | null;
280
+ git_branch: string | null;
281
+ model?: string | null;
282
+ }
283
+
284
+ /** Newest `state_<n>.sqlite`, if any. */
285
+ function findStateDb(dir: string): string | undefined {
286
+ let best: { n: number; path: string } | undefined;
287
+ try {
288
+ for (const f of readdirSync(dir)) {
289
+ const m = /^state_(\d+)\.sqlite$/.exec(f);
290
+ if (m && (!best || Number(m[1]) > best.n)) best = { n: Number(m[1]), path: join(dir, f) };
291
+ }
292
+ } catch {
293
+ return undefined;
294
+ }
295
+ return best?.path;
296
+ }
297
+
298
+ function readThreadRows(dbPath: string | undefined): Map<string, ThreadRow> {
299
+ const rows = new Map<string, ThreadRow>();
300
+ if (!dbPath) return rows;
301
+ let db: Database | undefined;
302
+ try {
303
+ db = new Database(dbPath, { readonly: true });
304
+ for (const r of db
305
+ // SELECT * so optional columns (e.g. `model`, added in later schema
306
+ // versions) are read when present without failing when absent.
307
+ .query<ThreadRow, []>(`SELECT * FROM threads`)
308
+ .all()) {
309
+ rows.set(r.id.toLowerCase(), r);
310
+ }
311
+ } catch {
312
+ // Missing table or schema drift: rollouts alone are enough.
313
+ } finally {
314
+ db?.close();
315
+ }
316
+ return rows;
317
+ }
318
+
319
+ function firstLine(text: string | undefined): string | undefined {
320
+ return text?.split("\n").find((l) => l.trim().length > 0)?.trim().slice(0, 60);
321
+ }
322
+
323
+ export function createCodexAdapter(codexHome = defaultCodexHome()): AgentAdapter {
324
+ const sessionsDir = join(codexHome, "sessions");
325
+ const archiveDir = join(codexHome, "archived_sessions");
326
+ const indexPath = join(codexHome, "session_index.jsonl");
327
+ const sqliteHome = process.env.CODEX_SQLITE_HOME || codexHome;
328
+
329
+ // Rollouts only grow while a turn runs, and every append triggers a refresh:
330
+ // re-parse a file only when its mtime/size changed.
331
+ const cache = new Map<string, { mtimeMs: number; size: number; parsed: RolloutParseResult }>();
332
+
333
+ function parseFile(f: RolloutFile): RolloutParseResult {
334
+ const hit = cache.get(f.path);
335
+ if (hit && hit.mtimeMs === f.mtimeMs && hit.size === f.size) return hit.parsed;
336
+ let parsed: RolloutParseResult;
337
+ try {
338
+ const raw = readFileSync(f.path);
339
+ const text = new TextDecoder().decode(f.compressed ? Bun.zstdDecompressSync(raw) : raw);
340
+ parsed = parseRollout(text, f.threadId);
341
+ } catch {
342
+ parsed = { hidden: false, messageCount: 0, toolCount: 0, turnOpen: false };
343
+ }
344
+ cache.set(f.path, { mtimeMs: f.mtimeMs, size: f.size, parsed });
345
+ return parsed;
346
+ }
347
+
348
+ function discover(now: number): AgentSession[] {
349
+ if (!existsSync(codexHome)) return [];
350
+ const files = new Map<string, RolloutFile>();
351
+ listRollouts(sessionsDir, false, files);
352
+ listRollouts(archiveDir, true, files);
353
+ const renames = readSessionIndex(indexPath);
354
+ const threads = readThreadRows(findStateDb(sqliteHome));
355
+
356
+ const sessions: AgentSession[] = [];
357
+ for (const f of files.values()) {
358
+ const parsed = parseFile(f);
359
+ if (parsed.hidden) continue;
360
+ const row = threads.get(f.threadId);
361
+ const cwd = parsed.cwd ?? row?.cwd ?? "";
362
+ const customTitle = renames.get(f.threadId) ?? row?.name ?? undefined;
363
+ const fallback = firstLine(parsed.firstUser) ?? firstLine(row?.title ?? undefined);
364
+ const id8 = f.threadId.slice(0, 8);
365
+ const displayName = customTitle
366
+ ? customTitle.slice(0, 60)
367
+ : fallback
368
+ ? `${fallback} · ${id8}`
369
+ : id8;
370
+ sessions.push({
371
+ provider: "codex",
372
+ sessionId: f.threadId,
373
+ sourcePath: f.path,
374
+ cwd,
375
+ cwdShort: cwdShort(cwd),
376
+ status: classifyCodexStatus(
377
+ now,
378
+ f.mtimeMs,
379
+ parsed.turnOpen,
380
+ f.inArchive || Boolean(row?.archived),
381
+ ),
382
+ lastActivityMs: f.mtimeMs,
383
+ customTitle,
384
+ displayName,
385
+ messageCount: parsed.messageCount,
386
+ toolCount: parsed.toolCount,
387
+ lastUser: parsed.lastUser,
388
+ lastAssistant: parsed.lastAssistant,
389
+ gitBranch: parsed.gitBranch ?? row?.git_branch ?? undefined,
390
+ model: parsed.model ?? row?.model ?? undefined,
391
+ resumeCommand: `codex resume ${f.threadId}`,
392
+ resumeArgv: ["codex", "resume", f.threadId],
393
+ });
394
+ }
395
+ // Drop cache entries for files that disappeared (archived / deleted).
396
+ const live = new Set([...files.values()].map((f) => f.path));
397
+ for (const path of cache.keys()) if (!live.has(path)) cache.delete(path);
398
+ return sessions;
399
+ }
400
+
401
+ return {
402
+ provider: "codex",
403
+ watchPaths: () => [sessionsDir, archiveDir, indexPath],
404
+ discover,
405
+ };
406
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Every agent CLI the Agents zone knows how to read. Order doesn't matter —
3
+ * the store sorts the merged list by status and recency.
4
+ */
5
+
6
+ import type { AgentAdapter } from "~/store/agents";
7
+ import { claudeCodeAdapter } from "./claude-code";
8
+ import { createCodexAdapter } from "./codex";
9
+ import { createOpenCodeAdapter } from "./opencode";
10
+ import { createPiAdapter } from "./pi";
11
+
12
+ export const AGENT_ADAPTERS: AgentAdapter[] = [
13
+ claudeCodeAdapter,
14
+ createCodexAdapter(),
15
+ createOpenCodeAdapter(),
16
+ createPiAdapter(),
17
+ ];
@@ -0,0 +1,196 @@
1
+ /**
2
+ * OpenCode adapter for the agents store.
3
+ *
4
+ * Reads (read-only):
5
+ * $XDG_DATA_HOME/opencode/opencode.db — SQLite, WAL mode
6
+ * session one row per session (cwd, AI title, parent_id for subagents)
7
+ * message JSON `data`: role, time.completed, error
8
+ * part JSON `data`: type text | reasoning | tool | step-start | …
9
+ *
10
+ * OpenCode keeps no PID/lock registry, so liveness is inferred from the
11
+ * transcript: the newest message being an unfinished assistant turn means
12
+ * "busy" — unless nothing has been written for STALE_AFTER_MS, which is what
13
+ * a killed process leaves behind. An idle TUI waiting for input can't be told
14
+ * apart from a closed one, so OpenCode sessions are never `live-idle`.
15
+ */
16
+
17
+ import { Database } from "bun:sqlite";
18
+ import { existsSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import {
23
+ DORMANT_AFTER_MS,
24
+ cwdShort,
25
+ type AgentAdapter,
26
+ type AgentSession,
27
+ type AgentStatus,
28
+ } from "~/store/agents";
29
+
30
+ /**
31
+ * Unfinished turn untouched longer than this → "stale". Generous on purpose:
32
+ * a long-running tool call doesn't update its part while it runs.
33
+ */
34
+ export const OPENCODE_STALE_AFTER_MS = 30 * 60 * 1000;
35
+
36
+ export function defaultOpenCodeDbPath(): string {
37
+ const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
38
+ return join(dataHome, "opencode", "opencode.db");
39
+ }
40
+
41
+ export interface LastMessageInfo {
42
+ role?: string;
43
+ completed?: number;
44
+ error?: unknown;
45
+ }
46
+
47
+ export function classifyOpenCodeStatus(
48
+ now: number,
49
+ lastActivityMs: number,
50
+ last: LastMessageInfo | undefined,
51
+ archived: boolean,
52
+ ): AgentStatus {
53
+ if (archived) return "archived";
54
+ const age = now - lastActivityMs;
55
+ if (age > DORMANT_AFTER_MS) return "archived";
56
+ // A user message with no reply yet also counts: the assistant message is
57
+ // only created once the model starts answering.
58
+ const unfinished =
59
+ last !== undefined &&
60
+ last.error == null &&
61
+ (last.role === "user" || (last.role === "assistant" && !last.completed));
62
+ if (unfinished) return age > OPENCODE_STALE_AFTER_MS ? "stale" : "live-busy";
63
+ return "dormant";
64
+ }
65
+
66
+ /** Title OpenCode assigns until the AI-generated one arrives. */
67
+ const PLACEHOLDER_TITLE = /^(New|Child) session - \d{4}-\d{2}-\d{2}T/;
68
+
69
+ function parseJson(raw: unknown): any {
70
+ if (typeof raw !== "string") return undefined;
71
+ try {
72
+ return JSON.parse(raw);
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** Visible text of a message's parts, skipping synthetic/ignored ones. */
79
+ function textOf(partRows: { data: string }[]): string | undefined {
80
+ const texts: string[] = [];
81
+ for (const row of partRows) {
82
+ const p = parseJson(row.data);
83
+ if (!p || p.type !== "text" || typeof p.text !== "string") continue;
84
+ if (p.synthetic || p.ignored) continue;
85
+ texts.push(p.text);
86
+ }
87
+ return texts.length > 0 ? texts.join("\n") : undefined;
88
+ }
89
+
90
+ interface SessionRow {
91
+ id: string;
92
+ directory: string;
93
+ title: string;
94
+ time_updated: number;
95
+ time_archived: number | null;
96
+ }
97
+
98
+ export function readOpenCodeSessions(dbPath: string, now: number): AgentSession[] {
99
+ if (!existsSync(dbPath)) return [];
100
+ const db = new Database(dbPath, { readonly: true });
101
+ try {
102
+ const sessions = db
103
+ .query<SessionRow, []>(
104
+ `SELECT id, directory, title, time_updated, time_archived
105
+ FROM session WHERE parent_id IS NULL`,
106
+ )
107
+ .all();
108
+ const lastMessage = db.query<{ data: string; time_updated: number }, [string]>(
109
+ `SELECT data, time_updated FROM message
110
+ WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT 1`,
111
+ );
112
+ const messageCount = db.query<{ n: number }, [string]>(
113
+ `SELECT count(*) AS n FROM message WHERE session_id = ?`,
114
+ );
115
+ const partStats = db.query<{ tools: number; updated: number | null }, [string]>(
116
+ `SELECT sum(json_extract(data, '$.type') = 'tool') AS tools,
117
+ max(time_updated) AS updated
118
+ FROM part WHERE session_id = ?`,
119
+ );
120
+ const lastOfRole = db.query<{ id: string }, [string, string]>(
121
+ `SELECT m.id FROM message m
122
+ WHERE m.session_id = ? AND json_extract(m.data, '$.role') = ?
123
+ AND EXISTS (SELECT 1 FROM part p WHERE p.message_id = m.id
124
+ AND json_extract(p.data, '$.type') = 'text')
125
+ ORDER BY m.time_created DESC, m.id DESC LIMIT 1`,
126
+ );
127
+ const partsOf = db.query<{ data: string }, [string]>(
128
+ `SELECT data FROM part WHERE message_id = ? ORDER BY id`,
129
+ );
130
+ const lastText = (sessionId: string, role: string) => {
131
+ const m = lastOfRole.get(sessionId, role);
132
+ return m ? textOf(partsOf.all(m.id)) : undefined;
133
+ };
134
+
135
+ return sessions.map((s) => {
136
+ const last = lastMessage.get(s.id);
137
+ const lastData = parseJson(last?.data);
138
+ const stats = partStats.get(s.id);
139
+ const lastActivityMs = Math.max(
140
+ s.time_updated,
141
+ last?.time_updated ?? 0,
142
+ stats?.updated ?? 0,
143
+ );
144
+ const title = PLACEHOLDER_TITLE.test(s.title) ? "" : s.title.trim();
145
+ const lastUser = lastText(s.id, "user");
146
+ const promptLine = lastUser
147
+ ?.split("\n")
148
+ .find((l) => l.trim().length > 0)
149
+ ?.slice(0, 60);
150
+ const displayName = title
151
+ ? title.slice(0, 60)
152
+ : promptLine
153
+ ? `${promptLine} · ${s.id.slice(4, 12)}`
154
+ : s.id;
155
+ return {
156
+ provider: "opencode",
157
+ sessionId: s.id,
158
+ sourcePath: dbPath,
159
+ cwd: s.directory,
160
+ cwdShort: cwdShort(s.directory),
161
+ status: classifyOpenCodeStatus(
162
+ now,
163
+ lastActivityMs,
164
+ lastData && {
165
+ role: lastData.role,
166
+ completed: lastData.time?.completed,
167
+ error: lastData.error,
168
+ },
169
+ s.time_archived != null,
170
+ ),
171
+ lastActivityMs,
172
+ aiTitle: title || undefined,
173
+ displayName,
174
+ messageCount: messageCount.get(s.id)?.n ?? 0,
175
+ toolCount: stats?.tools ?? 0,
176
+ lastUser,
177
+ lastAssistant: lastText(s.id, "assistant"),
178
+ // Assistant messages carry `modelID`; user messages `model.modelID`.
179
+ model: lastData?.modelID ?? lastData?.model?.modelID,
180
+ resumeCommand: `opencode --session ${s.id}`,
181
+ resumeArgv: ["opencode", "--session", s.id],
182
+ } satisfies AgentSession;
183
+ });
184
+ } finally {
185
+ db.close();
186
+ }
187
+ }
188
+
189
+ export function createOpenCodeAdapter(dbPath = defaultOpenCodeDbPath()): AgentAdapter {
190
+ return {
191
+ provider: "opencode",
192
+ // OpenCode commits to the WAL; the main file only changes on checkpoint.
193
+ watchPaths: () => [dbPath, `${dbPath}-wal`],
194
+ discover: (now) => readOpenCodeSessions(dbPath, now),
195
+ };
196
+ }