spiracha 2.0.0 → 2.2.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 (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -1,7 +1,10 @@
1
1
  import { constants, Database } from 'bun:sqlite';
2
- import { stat } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { chmod, readdir, rename, rm, stat } from 'node:fs/promises';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { pathToFileURL } from 'node:url';
7
+ import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
5
8
  import {
6
9
  getDefaultOpenCodeDataDir,
7
10
  type OpenCodeModelInfo,
@@ -21,12 +24,30 @@ import {
21
24
  asString,
22
25
  isWorkspacePathQuery,
23
26
  type JsonValue,
27
+ pathExists,
24
28
  workspacePathMatchesQuery,
25
29
  } from './shared';
30
+ import { runWithSqliteRetry } from './sqlite-retry';
26
31
 
27
32
  export { getDefaultOpenCodeDataDir, resolveOpenCodeDbPath };
28
33
 
29
- export const OPENCODE_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
34
+ export const OPENCODE_READ_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READWRITE | constants.SQLITE_OPEN_URI;
35
+ const OPENCODE_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
36
+ const DEFAULT_OPENCODE_DB_CONCURRENCY = 2;
37
+ const DESKTOP_STATE_FILE_CONCURRENCY = 4;
38
+ const OPENCODE_OPTIONAL_SESSION_TABLES = [
39
+ 'session_context_epoch',
40
+ 'session_input',
41
+ 'session_message',
42
+ 'session_share',
43
+ 'todo',
44
+ ] as const;
45
+
46
+ type OpenCodeOptionalSessionTable = (typeof OPENCODE_OPTIONAL_SESSION_TABLES)[number];
47
+
48
+ export type DeleteOpenCodeSessionResult = {
49
+ deletedSessionIds: string[];
50
+ };
30
51
 
31
52
  type WorkspaceRow = {
32
53
  archivedSessionCount: number;
@@ -61,11 +82,11 @@ type SessionRow = {
61
82
  summaryFiles: number | null;
62
83
  textPartCount: number;
63
84
  title: string;
64
- tokensCacheRead: number;
65
- tokensCacheWrite: number;
66
- tokensInput: number;
67
- tokensOutput: number;
68
- tokensReasoning: number;
85
+ tokensCacheRead: number | null;
86
+ tokensCacheWrite: number | null;
87
+ tokensInput: number | null;
88
+ tokensOutput: number | null;
89
+ tokensReasoning: number | null;
69
90
  toolPartCount: number;
70
91
  worktree: string;
71
92
  };
@@ -87,20 +108,136 @@ type PartRow = {
87
108
 
88
109
  const MAIN_SESSION_FILTER = 's.parent_id IS NULL';
89
110
 
90
- const pathExists = async (target: string): Promise<boolean> => {
91
- return await stat(target)
92
- .then(() => true)
93
- .catch(() => false);
111
+ let nextOpenCodeDbLoadId = 1;
112
+ let activeOpenCodeDbLoads = 0;
113
+ let queuedOpenCodeDbLoads = 0;
114
+
115
+ export const resolveOpenCodeDbConcurrency = (value = process.env.SPIRACHA_OPENCODE_DB_CONCURRENCY): number => {
116
+ const parsed = Number.parseInt(value ?? '', 10);
117
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OPENCODE_DB_CONCURRENCY;
94
118
  };
95
119
 
96
- export const getOpenCodeReadonlyDbUri = (dbPath: string): string => {
120
+ const openCodeDbLimiter = createConcurrencyLimiter(resolveOpenCodeDbConcurrency());
121
+ const openCodeDesktopStateLimiter = createConcurrencyLimiter(1);
122
+
123
+ const logOpenCodeDb = (event: string, details: Record<string, unknown>) => {
124
+ if (process.env.SPIRACHA_OPENCODE_DB_LOGS === '1') {
125
+ console.info(`[spiracha:opencode-db] ${event}`, details);
126
+ }
127
+ };
128
+
129
+ const runWithOpenCodeDbLimit = async <T>(operation: string, dbPath: string, action: () => T): Promise<T> => {
130
+ const loadId = nextOpenCodeDbLoadId;
131
+ nextOpenCodeDbLoadId += 1;
132
+ queuedOpenCodeDbLoads += 1;
133
+ const queuedAt = Date.now();
134
+
135
+ return openCodeDbLimiter(async () => {
136
+ queuedOpenCodeDbLoads -= 1;
137
+ activeOpenCodeDbLoads += 1;
138
+ const startedAt = Date.now();
139
+ logOpenCodeDb('start', {
140
+ active: activeOpenCodeDbLoads,
141
+ dbPath,
142
+ loadId,
143
+ operation,
144
+ queued: queuedOpenCodeDbLoads,
145
+ waitMs: startedAt - queuedAt,
146
+ });
147
+
148
+ try {
149
+ return action();
150
+ } catch (error) {
151
+ logOpenCodeDb('error', {
152
+ active: activeOpenCodeDbLoads,
153
+ dbPath,
154
+ durationMs: Date.now() - startedAt,
155
+ error: error instanceof Error ? error.message : String(error),
156
+ loadId,
157
+ operation,
158
+ queued: queuedOpenCodeDbLoads,
159
+ });
160
+ throw error;
161
+ } finally {
162
+ activeOpenCodeDbLoads -= 1;
163
+ logOpenCodeDb('finish', {
164
+ active: activeOpenCodeDbLoads,
165
+ dbPath,
166
+ durationMs: Date.now() - startedAt,
167
+ loadId,
168
+ operation,
169
+ queued: queuedOpenCodeDbLoads,
170
+ });
171
+ }
172
+ });
173
+ };
174
+
175
+ const getDefaultOpenCodeDesktopStateDir = (
176
+ env: NodeJS.ProcessEnv = process.env,
177
+ homeDir = os.homedir(),
178
+ ): string | null => {
179
+ const configured = env.SPIRACHA_OPENCODE_DESKTOP_STATE_DIR?.trim();
180
+ if (configured) {
181
+ return configured;
182
+ }
183
+
184
+ return process.platform === 'darwin'
185
+ ? path.join(homeDir, 'Library', 'Application Support', 'ai.opencode.desktop')
186
+ : null;
187
+ };
188
+
189
+ export const getOpenCodeReadDbUri = (dbPath: string, mode: 'ro' | 'rw' = 'rw'): string => {
97
190
  const url = pathToFileURL(dbPath);
98
- url.searchParams.set('mode', 'ro');
191
+ url.searchParams.set('mode', mode);
99
192
  return url.href;
100
193
  };
101
194
 
102
- export const openOpenCodeReadonlyDb = (dbPath: string): Database => {
103
- return new Database(getOpenCodeReadonlyDbUri(dbPath), OPENCODE_READONLY_DB_OPEN_FLAGS);
195
+ const configureOpenCodeReadDb = (db: Database): Database => {
196
+ try {
197
+ db.exec('PRAGMA busy_timeout = 1000');
198
+ db.exec('PRAGMA query_only = ON');
199
+ db.query('SELECT 1 FROM sqlite_schema LIMIT 1').get();
200
+ return db;
201
+ } catch (error) {
202
+ db.close();
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ export const openOpenCodeReadDb = (dbPath: string): Database => {
208
+ try {
209
+ return configureOpenCodeReadDb(
210
+ new Database(getOpenCodeReadDbUri(dbPath, 'ro'), OPENCODE_READONLY_DB_OPEN_FLAGS),
211
+ );
212
+ } catch {
213
+ return configureOpenCodeReadDb(new Database(getOpenCodeReadDbUri(dbPath), OPENCODE_READ_DB_OPEN_FLAGS));
214
+ }
215
+ };
216
+
217
+ const withOpenCodeReadonlyDb = <T>(dbPath: string, action: (db: Database) => T): T => {
218
+ return runWithSqliteRetry({
219
+ action: () => {
220
+ const db = openOpenCodeReadDb(dbPath);
221
+ try {
222
+ return action(db);
223
+ } finally {
224
+ db.close();
225
+ }
226
+ },
227
+ });
228
+ };
229
+
230
+ const withOpenCodeWritableDb = <T>(dbPath: string, action: (db: Database) => T): T => {
231
+ return runWithSqliteRetry({
232
+ action: () => {
233
+ const db = new Database(dbPath);
234
+ try {
235
+ return action(db);
236
+ } finally {
237
+ db.close();
238
+ }
239
+ },
240
+ });
104
241
  };
105
242
 
106
243
  const parseJsonValue = (value: string | null): JsonValue | string | null => {
@@ -120,6 +257,30 @@ const parseJsonObject = (value: string): Record<string, JsonValue> => {
120
257
  return asObject(parsed) ?? {};
121
258
  };
122
259
 
260
+ const parseMutableJsonObject = (value: string): Record<string, unknown> | null => {
261
+ try {
262
+ const parsed = JSON.parse(value) as unknown;
263
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
264
+ ? (parsed as Record<string, unknown>)
265
+ : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ };
270
+
271
+ const parseMutableJsonArray = (value: string): unknown[] | null => {
272
+ try {
273
+ const parsed = JSON.parse(value) as unknown;
274
+ return Array.isArray(parsed) ? parsed : null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ };
279
+
280
+ const encodeOpenCodeDirectoryKey = (directory: string): string => {
281
+ return Buffer.from(directory).toString('base64').replace(/=+$/u, '');
282
+ };
283
+
123
284
  const cleanLabel = (value: string | null | undefined): string | null => {
124
285
  const cleaned = value?.replace(/\s+/g, ' ').trim();
125
286
  return cleaned ? cleaned : null;
@@ -197,6 +358,11 @@ const toWorkspaceGroup = (row: WorkspaceRow): OpenCodeWorkspaceGroup => {
197
358
  const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
198
359
  const label = getWorkspaceLabel(row.projectName, row.worktree);
199
360
  const model = parseModelInfo(row.model);
361
+ const tokensCacheRead = row.tokensCacheRead ?? 0;
362
+ const tokensCacheWrite = row.tokensCacheWrite ?? 0;
363
+ const tokensInput = row.tokensInput ?? 0;
364
+ const tokensOutput = row.tokensOutput ?? 0;
365
+ const tokensReasoning = row.tokensReasoning ?? 0;
200
366
  return {
201
367
  agent: row.agent,
202
368
  archivedAtMs: row.archivedAtMs,
@@ -219,14 +385,13 @@ const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
219
385
  summaryFiles: row.summaryFiles,
220
386
  textPartCount: row.textPartCount,
221
387
  title: cleanLabel(row.title) ?? row.sessionId,
222
- tokensCacheRead: row.tokensCacheRead,
223
- tokensCacheWrite: row.tokensCacheWrite,
224
- tokensInput: row.tokensInput,
225
- tokensOutput: row.tokensOutput,
226
- tokensReasoning: row.tokensReasoning,
388
+ tokensCacheRead,
389
+ tokensCacheWrite,
390
+ tokensInput,
391
+ tokensOutput,
392
+ tokensReasoning,
227
393
  toolPartCount: row.toolPartCount,
228
- totalTokens:
229
- row.tokensInput + row.tokensOutput + row.tokensReasoning + row.tokensCacheRead + row.tokensCacheWrite,
394
+ totalTokens: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead + tokensCacheWrite,
230
395
  workspaceKey: getWorkspaceKey(row.projectId),
231
396
  workspaceLabel: label,
232
397
  worktree: row.worktree,
@@ -336,15 +501,14 @@ export const listOpenCodeWorkspaceGroups = async (
336
501
  return [];
337
502
  }
338
503
 
339
- const db = openOpenCodeReadonlyDb(dbPath);
340
- try {
341
- const rows = db
342
- .query(`${workspaceRowsQuery} ORDER BY lastActiveMs DESC, p.worktree ASC`)
343
- .all() as WorkspaceRow[];
344
- return rows.map(toWorkspaceGroup);
345
- } finally {
346
- db.close();
347
- }
504
+ return runWithOpenCodeDbLimit('list-workspaces', dbPath, () =>
505
+ withOpenCodeReadonlyDb(dbPath, (db) => {
506
+ const rows = db
507
+ .query(`${workspaceRowsQuery} ORDER BY lastActiveMs DESC, p.worktree ASC`)
508
+ .all() as WorkspaceRow[];
509
+ return rows.map(toWorkspaceGroup);
510
+ }),
511
+ );
348
512
  };
349
513
 
350
514
  const openCodeWorkspaceMatchesQuery = (workspace: OpenCodeWorkspaceGroup, query: string): boolean => {
@@ -392,16 +556,391 @@ export const listOpenCodeSessionsForGroup = async (
392
556
  return [];
393
557
  }
394
558
 
395
- const db = openOpenCodeReadonlyDb(dbPath);
559
+ return runWithOpenCodeDbLimit('list-sessions', dbPath, () =>
560
+ withOpenCodeReadonlyDb(dbPath, (db) => {
561
+ return readSessionSummaries(db, `s.project_id = ? AND ${MAIN_SESSION_FILTER}`, [projectId]);
562
+ }),
563
+ );
564
+ };
565
+
566
+ const readOpenCodeSessionSummary = (db: Database, sessionId: string): OpenCodeSessionSummary | null => {
567
+ return readSessionSummaries(db, `s.id = ? AND ${MAIN_SESSION_FILTER}`, [sessionId])[0] ?? null;
568
+ };
569
+
570
+ const readOpenCodeSessionTreeIds = (db: Database, sessionId: string): string[] => {
571
+ const rows = db
572
+ .query(
573
+ `WITH RECURSIVE session_tree(id) AS (
574
+ SELECT id FROM session WHERE id = ?
575
+ UNION ALL
576
+ SELECT child.id
577
+ FROM session child
578
+ JOIN session_tree parent ON child.parent_id = parent.id
579
+ )
580
+ SELECT id FROM session_tree`,
581
+ )
582
+ .all(sessionId) as Array<{ id: string }>;
583
+ return rows.map((row) => row.id);
584
+ };
585
+
586
+ const hasOpenCodeTable = (db: Database, tableName: string): boolean => {
587
+ return Boolean(db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
588
+ };
589
+
590
+ const deleteRowsBySessionId = (
591
+ db: Database,
592
+ tableName: OpenCodeOptionalSessionTable,
593
+ sessionIds: string[],
594
+ placeholders: string,
595
+ ) => {
596
+ if (hasOpenCodeTable(db, tableName)) {
597
+ db.query(`DELETE FROM ${tableName} WHERE session_id IN (${placeholders})`).run(...sessionIds);
598
+ }
599
+ };
600
+
601
+ const deleteOpenCodeEventRows = (db: Database, sessionIds: string[], placeholders: string) => {
602
+ if (hasOpenCodeTable(db, 'event')) {
603
+ db.query(`DELETE FROM event WHERE aggregate_id IN (${placeholders})`).run(...sessionIds);
604
+ }
605
+ if (hasOpenCodeTable(db, 'event_sequence')) {
606
+ db.query(`DELETE FROM event_sequence WHERE aggregate_id IN (${placeholders})`).run(...sessionIds);
607
+ }
608
+ };
609
+
610
+ const deleteEmptyOpenCodeProjects = (
611
+ db: Database,
612
+ projects: Array<{ projectId: string; worktree: string }>,
613
+ ): string[] => {
614
+ const emptyWorktrees: string[] = [];
615
+ for (const project of projects) {
616
+ const remaining = db
617
+ .query('SELECT COUNT(*) AS count FROM session WHERE project_id = ?')
618
+ .get(project.projectId) as { count: number };
619
+ if (remaining.count === 0) {
620
+ db.query('DELETE FROM project WHERE id = ?').run(project.projectId);
621
+ emptyWorktrees.push(project.worktree);
622
+ }
623
+ }
624
+ return emptyWorktrees;
625
+ };
626
+
627
+ const deleteOpenCodeSessionRows = (
628
+ db: Database,
629
+ sessionIds: string[],
630
+ placeholders: string,
631
+ projectRows: Array<{ projectId: string; worktree: string }>,
632
+ ): string[] => {
633
+ deleteOpenCodeEventRows(db, sessionIds, placeholders);
634
+ for (const tableName of OPENCODE_OPTIONAL_SESSION_TABLES) {
635
+ deleteRowsBySessionId(db, tableName, sessionIds, placeholders);
636
+ }
637
+ db.query(`DELETE FROM part WHERE session_id IN (${placeholders})`).run(...sessionIds);
638
+ db.query(`DELETE FROM message WHERE session_id IN (${placeholders})`).run(...sessionIds);
639
+ db.query(`DELETE FROM session WHERE id IN (${placeholders})`).run(...sessionIds);
640
+ return deleteEmptyOpenCodeProjects(db, projectRows);
641
+ };
642
+
643
+ const removeObjectKeysForSessionIds = (target: Record<string, unknown>, sessionIds: Set<string>) => {
644
+ let changed = false;
645
+ const sessionIdList = [...sessionIds];
646
+ for (const key of Object.keys(target)) {
647
+ if (sessionIds.has(key) || sessionIdList.some((sessionId) => key.endsWith(`/${sessionId}`))) {
648
+ delete target[key];
649
+ changed = true;
650
+ }
651
+ }
652
+ return changed;
653
+ };
654
+
655
+ const cloneMutableJsonObject = (value: unknown): Record<string, unknown> | null => {
656
+ return value && typeof value === 'object' && !Array.isArray(value)
657
+ ? (structuredClone(value) as Record<string, unknown>)
658
+ : null;
659
+ };
660
+
661
+ const updateJsonStringField = (
662
+ state: Record<string, unknown>,
663
+ key: string,
664
+ update: (value: string) => { changed: boolean; next: unknown },
665
+ ) => {
666
+ const current = state[key];
667
+ if (typeof current !== 'string') {
668
+ return false;
669
+ }
670
+
671
+ const result = update(current);
672
+ if (!result.changed) {
673
+ return false;
674
+ }
675
+
676
+ state[key] = JSON.stringify(result.next);
677
+ return true;
678
+ };
679
+
680
+ const cleanOpenCodeServerProjects = (projects: Record<string, unknown>, worktrees: Set<string>): boolean => {
681
+ let changed = false;
682
+ for (const [serverId, entries] of Object.entries(projects)) {
683
+ if (!Array.isArray(entries)) {
684
+ continue;
685
+ }
686
+
687
+ const nextEntries = entries.filter((entry) => {
688
+ return !(
689
+ entry &&
690
+ typeof entry === 'object' &&
691
+ worktrees.has(String((entry as Record<string, unknown>).worktree ?? ''))
692
+ );
693
+ });
694
+ if (nextEntries.length !== entries.length) {
695
+ projects[serverId] = nextEntries;
696
+ changed = true;
697
+ }
698
+ }
699
+ return changed;
700
+ };
701
+
702
+ const cleanOpenCodeServerLastProject = (lastProject: Record<string, unknown>, worktrees: Set<string>): boolean => {
703
+ let changed = false;
704
+ for (const [serverId, directory] of Object.entries(lastProject)) {
705
+ if (worktrees.has(String(directory))) {
706
+ delete lastProject[serverId];
707
+ changed = true;
708
+ }
709
+ }
710
+ return changed;
711
+ };
712
+
713
+ const cleanOpenCodeServerState = (value: string, worktrees: Set<string>) => {
714
+ const parsed = parseMutableJsonObject(value);
715
+ const projects = cloneMutableJsonObject(parsed?.projects);
716
+ const lastProject = cloneMutableJsonObject(parsed?.lastProject);
717
+ if (!parsed) {
718
+ return { changed: false, next: parsed };
719
+ }
720
+
721
+ const projectsChanged = projects ? cleanOpenCodeServerProjects(projects, worktrees) : false;
722
+ const lastProjectChanged = lastProject ? cleanOpenCodeServerLastProject(lastProject, worktrees) : false;
723
+ parsed.projects = projects ?? parsed.projects;
724
+ parsed.lastProject = lastProject ?? parsed.lastProject;
725
+
726
+ const changed = projectsChanged || lastProjectChanged;
727
+ return changed ? { changed: true, next: parsed } : { changed: false, next: parsed };
728
+ };
729
+
730
+ const cleanOpenCodeDesktopStateObject = (
731
+ state: Record<string, unknown>,
732
+ sessionIds: Set<string>,
733
+ worktrees: Set<string>,
734
+ ): boolean => {
735
+ let changed = false;
736
+ const encodedWorktrees = new Set([...worktrees].map(encodeOpenCodeDirectoryKey));
737
+ const sessionIdList = [...sessionIds];
738
+
739
+ for (const key of Object.keys(state)) {
740
+ if (sessionIdList.some((sessionId) => key.startsWith(`session:${sessionId}:`))) {
741
+ delete state[key];
742
+ changed = true;
743
+ }
744
+ }
745
+
746
+ changed =
747
+ updateJsonStringField(state, 'notification', (value) => {
748
+ const parsed = parseMutableJsonObject(value);
749
+ const list = parsed?.list;
750
+ if (!Array.isArray(list)) {
751
+ return { changed: false, next: parsed };
752
+ }
753
+
754
+ const nextList = list.filter((item) => {
755
+ return !(
756
+ item &&
757
+ typeof item === 'object' &&
758
+ (sessionIds.has(String((item as Record<string, unknown>).session ?? '')) ||
759
+ worktrees.has(String((item as Record<string, unknown>).directory ?? '')))
760
+ );
761
+ });
762
+ if (nextList.length === list.length) {
763
+ return { changed: false, next: parsed };
764
+ }
765
+
766
+ return { changed: true, next: { ...parsed, list: nextList } };
767
+ }) || changed;
768
+
769
+ changed =
770
+ updateJsonStringField(state, 'layout.page', (value) => {
771
+ const parsed = parseMutableJsonObject(value);
772
+ const lastProjectSession = cloneMutableJsonObject(parsed?.lastProjectSession);
773
+ if (!parsed || !lastProjectSession) {
774
+ return { changed: false, next: parsed };
775
+ }
776
+
777
+ let nextChanged = false;
778
+ for (const [directory, session] of Object.entries(lastProjectSession)) {
779
+ if (
780
+ session &&
781
+ typeof session === 'object' &&
782
+ (sessionIds.has(String((session as Record<string, unknown>).id ?? '')) ||
783
+ worktrees.has(directory) ||
784
+ worktrees.has(String((session as Record<string, unknown>).directory ?? '')))
785
+ ) {
786
+ delete lastProjectSession[directory];
787
+ nextChanged = true;
788
+ }
789
+ }
790
+
791
+ return nextChanged
792
+ ? { changed: true, next: { ...parsed, lastProjectSession } }
793
+ : { changed: false, next: parsed };
794
+ }) || changed;
795
+
796
+ changed = updateJsonStringField(state, 'server', (value) => cleanOpenCodeServerState(value, worktrees)) || changed;
797
+
798
+ changed =
799
+ updateJsonStringField(state, 'permission', (value) => {
800
+ const parsed = parseMutableJsonObject(value);
801
+ const autoAccept = cloneMutableJsonObject(parsed?.autoAccept);
802
+ if (!parsed || !autoAccept) {
803
+ return { changed: false, next: parsed };
804
+ }
805
+
806
+ const nextChanged = removeObjectKeysForSessionIds(autoAccept, sessionIds);
807
+ return nextChanged ? { changed: true, next: { ...parsed, autoAccept } } : { changed: false, next: parsed };
808
+ }) || changed;
809
+
810
+ changed =
811
+ updateJsonStringField(state, 'tabs', (value) => {
812
+ const parsed = parseMutableJsonArray(value);
813
+ if (!parsed) {
814
+ return { changed: false, next: parsed };
815
+ }
816
+
817
+ const next = parsed.filter((item) => {
818
+ return !(
819
+ item &&
820
+ typeof item === 'object' &&
821
+ (sessionIds.has(String((item as Record<string, unknown>).sessionId ?? '')) ||
822
+ encodedWorktrees.has(String((item as Record<string, unknown>).dirBase64 ?? '')))
823
+ );
824
+ });
825
+ return next.length === parsed.length ? { changed: false, next: parsed } : { changed: true, next };
826
+ }) || changed;
827
+
828
+ changed =
829
+ updateJsonStringField(state, 'workspace:model-selection', (value) => {
830
+ const parsed = parseMutableJsonObject(value);
831
+ const session = cloneMutableJsonObject(parsed?.session);
832
+ if (!parsed || !session) {
833
+ return { changed: false, next: parsed };
834
+ }
835
+
836
+ const nextChanged = removeObjectKeysForSessionIds(session, sessionIds);
837
+ return nextChanged ? { changed: true, next: { ...parsed, session } } : { changed: false, next: parsed };
838
+ }) || changed;
839
+
840
+ changed =
841
+ updateJsonStringField(state, 'workspace:followup', (value) => {
842
+ const parsed = parseMutableJsonObject(value);
843
+ if (!parsed) {
844
+ return { changed: false, next: parsed };
845
+ }
846
+
847
+ let nextChanged = false;
848
+ for (const key of ['items', 'failed', 'paused', 'edit']) {
849
+ const child = cloneMutableJsonObject(parsed[key]);
850
+ if (child && removeObjectKeysForSessionIds(child, sessionIds)) {
851
+ parsed[key] = child;
852
+ nextChanged = true;
853
+ }
854
+ }
855
+
856
+ return nextChanged ? { changed: true, next: parsed } : { changed: false, next: parsed };
857
+ }) || changed;
858
+
859
+ return changed;
860
+ };
861
+
862
+ const writeOpenCodeDesktopState = async (filePath: string, state: Record<string, unknown>): Promise<void> => {
863
+ const tempPath = `${filePath}.${randomUUID()}.tmp`;
864
+ const mode = (await stat(filePath)).mode & 0o777;
396
865
  try {
397
- return readSessionSummaries(db, `s.project_id = ? AND ${MAIN_SESSION_FILTER}`, [projectId]);
866
+ await Bun.write(tempPath, `${JSON.stringify(state, null, '\t')}\n`);
867
+ await chmod(tempPath, mode);
868
+ await rename(tempPath, filePath);
398
869
  } finally {
399
- db.close();
870
+ await rm(tempPath, { force: true });
400
871
  }
401
872
  };
402
873
 
403
- const readOpenCodeSessionSummary = (db: Database, sessionId: string): OpenCodeSessionSummary | null => {
404
- return readSessionSummaries(db, `s.id = ? AND ${MAIN_SESSION_FILTER}`, [sessionId])[0] ?? null;
874
+ export const deleteOpenCodeDesktopSessionState = async (
875
+ sessionIds: string[],
876
+ stateDir = getDefaultOpenCodeDesktopStateDir(),
877
+ worktrees: string[] = [],
878
+ ): Promise<string[]> => {
879
+ return openCodeDesktopStateLimiter(async () => {
880
+ if (!stateDir || (sessionIds.length === 0 && worktrees.length === 0) || !(await pathExists(stateDir))) {
881
+ return [];
882
+ }
883
+
884
+ const sessionIdSet = new Set(sessionIds);
885
+ const worktreeSet = new Set(worktrees);
886
+ const fileNames = (await readdir(stateDir)).filter((fileName) => fileName.endsWith('.dat'));
887
+ const changedFiles = await mapWithConcurrency(
888
+ fileNames,
889
+ DESKTOP_STATE_FILE_CONCURRENCY,
890
+ async (fileName): Promise<string | null> => {
891
+ const filePath = path.join(stateDir, fileName);
892
+ const state = parseMutableJsonObject(await Bun.file(filePath).text());
893
+ if (!state || !cleanOpenCodeDesktopStateObject(state, sessionIdSet, worktreeSet)) {
894
+ return null;
895
+ }
896
+
897
+ await writeOpenCodeDesktopState(filePath, state);
898
+ return filePath;
899
+ },
900
+ );
901
+
902
+ return changedFiles.filter((filePath): filePath is string => filePath !== null);
903
+ });
904
+ };
905
+
906
+ export const deleteOpenCodeSession = async (
907
+ dbPath: string,
908
+ sessionId: string,
909
+ ): Promise<DeleteOpenCodeSessionResult> => {
910
+ if (!(await pathExists(dbPath))) {
911
+ return { deletedSessionIds: [] };
912
+ }
913
+
914
+ const result = await runWithOpenCodeDbLimit('delete-session', dbPath, () =>
915
+ withOpenCodeWritableDb(dbPath, (db) => {
916
+ const sessionIds = readOpenCodeSessionTreeIds(db, sessionId);
917
+ if (sessionIds.length === 0) {
918
+ return { deletedSessionIds: [], emptyWorktrees: [] };
919
+ }
920
+
921
+ const placeholders = sessionIds.map(() => '?').join(', ');
922
+ const projectRows = db
923
+ .query(
924
+ `SELECT DISTINCT pr.id AS projectId, pr.worktree
925
+ FROM session s
926
+ JOIN project pr ON pr.id = s.project_id
927
+ WHERE s.id IN (${placeholders})`,
928
+ )
929
+ .all(...sessionIds) as Array<{ projectId: string; worktree: string }>;
930
+ const emptyWorktrees = db.transaction(() =>
931
+ deleteOpenCodeSessionRows(db, sessionIds, placeholders, projectRows),
932
+ )();
933
+
934
+ return { deletedSessionIds: sessionIds, emptyWorktrees };
935
+ }),
936
+ );
937
+
938
+ await deleteOpenCodeDesktopSessionState(
939
+ result.deletedSessionIds.length > 0 ? result.deletedSessionIds : [sessionId],
940
+ undefined,
941
+ result.emptyWorktrees,
942
+ );
943
+ return { deletedSessionIds: result.deletedSessionIds };
405
944
  };
406
945
 
407
946
  const getMessageRole = (raw: Record<string, JsonValue>): string => asString(raw.role ?? null) ?? 'unknown';
@@ -470,12 +1009,13 @@ const parseTextOpenCodePart = (base: BaseOpenCodePart): OpenCodeTranscriptPart =
470
1009
 
471
1010
  const parseToolOpenCodePart = (base: BaseOpenCodePart): OpenCodeTranscriptPart => {
472
1011
  const state = asObject(base.raw.state ?? null);
1012
+ const metadata = asObject(state?.metadata ?? null);
473
1013
  return {
474
1014
  ...base,
475
1015
  argumentsText: formatJsonLike(state?.input),
476
1016
  callId: asString(base.raw.callID ?? null) ?? asString(base.raw.callId ?? null),
477
1017
  endTimeMs: parseToolTimeMs(base.raw, 'end'),
478
- outputText: formatJsonLike(state?.output),
1018
+ outputText: formatJsonLike(state?.output ?? metadata?.output ?? state?.error),
479
1019
  startTimeMs: parseToolTimeMs(base.raw, 'start'),
480
1020
  status: asString(state?.status ?? null),
481
1021
  title: asString(state?.title ?? null),
@@ -584,27 +1124,26 @@ export const readOpenCodeSessionTranscript = async (
584
1124
  return null;
585
1125
  }
586
1126
 
587
- const db = openOpenCodeReadonlyDb(dbPath);
588
- try {
589
- const session = readOpenCodeSessionSummary(db, sessionId);
590
- if (!session) {
591
- return null;
592
- }
593
-
594
- const messages = readMessages(db, sessionId);
595
- const partCount = messages.reduce((total, message) => total + message.parts.length, 0);
596
- const renderablePartCount = messages.reduce(
597
- (total, message) => total + message.parts.filter(isRenderablePart).length,
598
- 0,
599
- );
600
-
601
- return {
602
- messages,
603
- partCount,
604
- renderablePartCount,
605
- session,
606
- };
607
- } finally {
608
- db.close();
609
- }
1127
+ return runWithOpenCodeDbLimit('read-session', dbPath, () =>
1128
+ withOpenCodeReadonlyDb(dbPath, (db) => {
1129
+ const session = readOpenCodeSessionSummary(db, sessionId);
1130
+ if (!session) {
1131
+ return null;
1132
+ }
1133
+
1134
+ const messages = readMessages(db, sessionId);
1135
+ const partCount = messages.reduce((total, message) => total + message.parts.length, 0);
1136
+ const renderablePartCount = messages.reduce(
1137
+ (total, message) => total + message.parts.filter(isRenderablePart).length,
1138
+ 0,
1139
+ );
1140
+
1141
+ return {
1142
+ messages,
1143
+ partCount,
1144
+ renderablePartCount,
1145
+ session,
1146
+ };
1147
+ }),
1148
+ );
610
1149
  };