@cloudcli-ai/cloudcli 1.37.0 → 1.37.1

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 (112) hide show
  1. package/dist/assets/index-D3jfzuKN.css +32 -0
  2. package/dist/assets/{index-eGqPeIEu.js → index-DTMWjJj8.js} +324 -305
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/modules/commands/commands.routes.js +3 -3
  5. package/dist-server/server/modules/commands/commands.routes.js.map +1 -1
  6. package/dist-server/server/modules/commands/tests/commands.test.js +2 -7
  7. package/dist-server/server/modules/commands/tests/commands.test.js.map +1 -1
  8. package/dist-server/server/modules/database/index.js +2 -0
  9. package/dist-server/server/modules/database/index.js.map +1 -1
  10. package/dist-server/server/modules/database/migrations.js +18 -1
  11. package/dist-server/server/modules/database/migrations.js.map +1 -1
  12. package/dist-server/server/modules/database/repositories/provider-models.js +112 -0
  13. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -0
  14. package/dist-server/server/modules/database/repositories/sessions.db.js +63 -7
  15. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  16. package/dist-server/server/modules/database/schema.js +28 -3
  17. package/dist-server/server/modules/database/schema.js.map +1 -1
  18. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +102 -0
  19. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -0
  20. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -1
  21. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  22. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +22 -0
  23. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  24. package/dist-server/server/modules/file-tree/file-tree.module.js +5 -1
  25. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  26. package/dist-server/server/modules/file-tree/file-tree.service.js +50 -18
  27. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  28. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +83 -7
  29. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +46 -14
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +8 -15
  33. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +58 -71
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +1 -54
  37. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +7 -7
  39. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +32 -669
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +105 -328
  43. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +0 -12
  45. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/provider.routes.js +139 -31
  47. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  48. package/dist-server/server/modules/providers/services/provider-models.service.js +167 -199
  49. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  50. package/dist-server/server/modules/providers/services/provider-runtime.service.js +2 -2
  51. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  52. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +12 -4
  53. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  54. package/dist-server/server/modules/providers/services/sessions.service.js +62 -3
  55. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  56. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +53 -17
  57. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  58. package/dist-server/server/modules/providers/tests/opencode-models.test.js +15 -126
  59. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  60. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +5 -5
  61. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  62. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +175 -292
  63. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  64. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js +2 -6
  65. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js.map +1 -1
  66. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +1 -0
  67. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  68. package/dist-server/server/modules/providers/tests/provider.routes.test.js +126 -0
  69. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -0
  70. package/dist-server/server/modules/providers/tests/sessions.service.test.js +87 -0
  71. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -0
  72. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +5 -1
  73. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  74. package/package.json +3 -3
  75. package/server/modules/commands/commands.routes.ts +3 -3
  76. package/server/modules/commands/tests/commands.test.ts +2 -7
  77. package/server/modules/database/index.ts +2 -0
  78. package/server/modules/database/migrations.ts +20 -0
  79. package/server/modules/database/repositories/provider-models.ts +168 -0
  80. package/server/modules/database/repositories/sessions.db.ts +85 -8
  81. package/server/modules/database/schema.ts +29 -3
  82. package/server/modules/database/tests/provider-models.db.integration.test.ts +125 -0
  83. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -1
  84. package/server/modules/database/tests/sessions.db.integration.test.ts +32 -0
  85. package/server/modules/file-tree/file-tree.module.ts +5 -1
  86. package/server/modules/file-tree/file-tree.service.ts +68 -20
  87. package/server/modules/file-tree/tests/file-tree.service.test.ts +104 -7
  88. package/server/modules/providers/list/claude/claude-models.provider.ts +46 -14
  89. package/server/modules/providers/list/claude/claude-runtime.provider.js +8 -16
  90. package/server/modules/providers/list/codex/codex-models.provider.ts +58 -99
  91. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +1 -60
  92. package/server/modules/providers/list/codex/codex-sessions.provider.ts +7 -7
  93. package/server/modules/providers/list/cursor/cursor-models.provider.ts +32 -710
  94. package/server/modules/providers/list/opencode/opencode-models.provider.ts +105 -413
  95. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +0 -13
  96. package/server/modules/providers/provider.routes.ts +185 -33
  97. package/server/modules/providers/services/provider-models.service.ts +223 -288
  98. package/server/modules/providers/services/provider-runtime.service.ts +2 -3
  99. package/server/modules/providers/services/session-conversations-search.service.ts +12 -4
  100. package/server/modules/providers/services/sessions.service.ts +87 -3
  101. package/server/modules/providers/tests/codex-sessions.test.ts +57 -18
  102. package/server/modules/providers/tests/opencode-models.test.ts +34 -135
  103. package/server/modules/providers/tests/opencode-sessions.test.ts +5 -5
  104. package/server/modules/providers/tests/provider-models.service.test.ts +353 -450
  105. package/server/modules/providers/tests/provider-runtime.service.test.ts +2 -6
  106. package/server/modules/providers/tests/provider-token-usage.service.test.ts +1 -0
  107. package/server/modules/providers/tests/provider.routes.test.ts +183 -0
  108. package/server/modules/providers/tests/sessions.service.test.ts +127 -0
  109. package/server/modules/websocket/services/chat-websocket.service.ts +5 -1
  110. package/server/shared/interfaces.ts +4 -6
  111. package/server/shared/types.ts +29 -17
  112. package/dist/assets/index-DSlds633.css +0 -32
@@ -0,0 +1,168 @@
1
+ import { getConnection } from '@/modules/database/connection.js';
2
+ import type {
3
+ CustomProviderModelInput,
4
+ CustomProviderModelRecord,
5
+ LLMProvider,
6
+ } from '@/shared/types.js';
7
+
8
+ type CustomProviderModelRow = {
9
+ id: number;
10
+ provider: LLMProvider;
11
+ model_id: string;
12
+ model_name: string;
13
+ sort_order: number;
14
+ };
15
+
16
+ const toCustomProviderModelRecord = (
17
+ row: CustomProviderModelRow,
18
+ ): CustomProviderModelRecord => ({
19
+ recordId: row.id,
20
+ provider: row.provider,
21
+ modelId: row.model_id,
22
+ model: row.model_name,
23
+ sortOrder: row.sort_order,
24
+ });
25
+
26
+ const readCustomProviderModelRow = (
27
+ provider: LLMProvider,
28
+ recordId: number,
29
+ ): CustomProviderModelRow | null => {
30
+ const row = getConnection().prepare(`
31
+ SELECT id, provider, model_id, model_name, sort_order
32
+ FROM provider_models
33
+ WHERE provider = ? AND id = ?
34
+ `).get(provider, recordId) as CustomProviderModelRow | undefined;
35
+
36
+ return row ?? null;
37
+ };
38
+
39
+ /**
40
+ * Custom provider-model persistence API consumed by the Providers module.
41
+ *
42
+ * Every row in this repository is user-created. Predefined models are owned by
43
+ * provider adapters and never enter this table. Model-id changes are propagated
44
+ * to stored sessions, while deletion replaces affected session selections with
45
+ * the predefined default supplied by the Providers service.
46
+ */
47
+ export const providerModelsDb = {
48
+ listCustomProviderModels(provider: LLMProvider): CustomProviderModelRecord[] {
49
+ const rows = getConnection().prepare(`
50
+ SELECT id, provider, model_id, model_name, sort_order
51
+ FROM provider_models
52
+ WHERE provider = ?
53
+ ORDER BY sort_order ASC, lower(model_name) ASC, id ASC
54
+ `).all(provider) as CustomProviderModelRow[];
55
+
56
+ return rows.map(toCustomProviderModelRecord);
57
+ },
58
+
59
+ getCustomProviderModel(
60
+ provider: LLMProvider,
61
+ recordId: number,
62
+ ): CustomProviderModelRecord | null {
63
+ const row = readCustomProviderModelRow(provider, recordId);
64
+ return row ? toCustomProviderModelRecord(row) : null;
65
+ },
66
+
67
+ findCustomProviderModelByModelId(
68
+ provider: LLMProvider,
69
+ modelId: string,
70
+ ): CustomProviderModelRecord | null {
71
+ const row = getConnection().prepare(`
72
+ SELECT id, provider, model_id, model_name, sort_order
73
+ FROM provider_models
74
+ WHERE provider = ? AND model_id = ?
75
+ `).get(provider, modelId) as CustomProviderModelRow | undefined;
76
+
77
+ return row ? toCustomProviderModelRecord(row) : null;
78
+ },
79
+
80
+ createCustomProviderModel(
81
+ provider: LLMProvider,
82
+ input: CustomProviderModelInput,
83
+ ): CustomProviderModelRecord {
84
+ const db = getConnection();
85
+ const nextOrder = db.prepare(`
86
+ SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order
87
+ FROM provider_models
88
+ WHERE provider = ?
89
+ `).get(provider) as { next_order: number };
90
+
91
+ const result = db.prepare(`
92
+ INSERT INTO provider_models (provider, model_id, model_name, sort_order)
93
+ VALUES (?, ?, ?, ?)
94
+ `).run(provider, input.id, input.model, nextOrder.next_order);
95
+
96
+ const row = readCustomProviderModelRow(provider, Number(result.lastInsertRowid));
97
+ if (!row) {
98
+ throw new Error('Created provider model could not be read back.');
99
+ }
100
+
101
+ return toCustomProviderModelRecord(row);
102
+ },
103
+
104
+ updateCustomProviderModel(
105
+ provider: LLMProvider,
106
+ recordId: number,
107
+ input: CustomProviderModelInput,
108
+ ): CustomProviderModelRecord | null {
109
+ const db = getConnection();
110
+ const update = db.transaction(() => {
111
+ const previous = readCustomProviderModelRow(provider, recordId);
112
+ if (!previous) {
113
+ return null;
114
+ }
115
+
116
+ db.prepare(`
117
+ UPDATE provider_models
118
+ SET model_id = ?, model_name = ?, updated_at = CURRENT_TIMESTAMP
119
+ WHERE provider = ? AND id = ?
120
+ `).run(input.id, input.model, provider, recordId);
121
+
122
+ if (previous.model_id !== input.id) {
123
+ db.prepare(`
124
+ UPDATE sessions
125
+ SET model = ?, updated_at = CURRENT_TIMESTAMP
126
+ WHERE provider = ? AND model = ?
127
+ `).run(input.id, provider, previous.model_id);
128
+ }
129
+
130
+ return readCustomProviderModelRow(provider, recordId);
131
+ });
132
+
133
+ const row = update();
134
+ return row ? toCustomProviderModelRecord(row) : null;
135
+ },
136
+
137
+ deleteCustomProviderModel(
138
+ provider: LLMProvider,
139
+ recordId: number,
140
+ fallbackModelId: string,
141
+ ): CustomProviderModelRecord | null {
142
+ const db = getConnection();
143
+ const remove = db.transaction(() => {
144
+ const row = readCustomProviderModelRow(provider, recordId);
145
+ if (!row) {
146
+ return null;
147
+ }
148
+
149
+ // The effort is cleared alongside the model because it was chosen for the
150
+ // deleted model; replaced sessions resume on the fallback model's default.
151
+ db.prepare(`
152
+ UPDATE sessions
153
+ SET model = ?, effort = NULL, updated_at = CURRENT_TIMESTAMP
154
+ WHERE provider = ? AND model = ?
155
+ `).run(fallbackModelId, provider, row.model_id);
156
+
157
+ db.prepare(`
158
+ DELETE FROM provider_models
159
+ WHERE provider = ? AND id = ?
160
+ `).run(provider, recordId);
161
+
162
+ return row;
163
+ });
164
+
165
+ const row = remove();
166
+ return row ? toCustomProviderModelRecord(row) : null;
167
+ },
168
+ };
@@ -11,13 +11,20 @@ type SessionRow = {
11
11
  custom_name: string | null;
12
12
  /** Model this session runs with; NULL until the app records one for it. */
13
13
  model: string | null;
14
+ /** Reasoning effort this session runs with; NULL until the app records one. */
15
+ effort: string | null;
14
16
  isArchived: number;
15
17
  created_at: string;
16
18
  updated_at: string;
17
19
  };
18
20
 
21
+ type RecentSessionsPage = {
22
+ sessions: SessionRow[];
23
+ total: number;
24
+ };
25
+
19
26
  const SESSION_ROW_COLUMNS =
20
- 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, model, isArchived, created_at, updated_at';
27
+ 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, model, effort, isArchived, created_at, updated_at';
21
28
 
22
29
  const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
23
30
 
@@ -67,7 +74,9 @@ export const sessionsDb = {
67
74
  * The given id is the provider-native session id. Rows are keyed by
68
75
  * `provider_session_id` so a session that was first created by the app
69
76
  * (with an app-allocated `session_id`) is updated in place once its
70
- * transcript shows up on disk, instead of producing a duplicate row.
77
+ * transcript shows up on disk, instead of producing a duplicate row. An
78
+ * app-created row keeps its existing name; synchronizer names only update
79
+ * rows that were themselves created by indexing provider storage.
71
80
  */
72
81
  createSession(
73
82
  providerSessionId: string,
@@ -103,7 +112,10 @@ export const sessionsDb = {
103
112
  project_path = ?,
104
113
  jsonl_path = ?,
105
114
  isArchived = 0,
106
- custom_name = COALESCE(?, custom_name)
115
+ custom_name = CASE
116
+ WHEN session_id <> provider_session_id AND custom_name IS NOT NULL THEN custom_name
117
+ ELSE COALESCE(?, custom_name)
118
+ END
107
119
  WHERE session_id = ?`
108
120
  ).run(
109
121
  provider,
@@ -130,7 +142,11 @@ export const sessionsDb = {
130
142
  project_path = excluded.project_path,
131
143
  jsonl_path = excluded.jsonl_path,
132
144
  isArchived = 0,
133
- custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
145
+ custom_name = CASE
146
+ WHEN sessions.session_id <> sessions.provider_session_id AND sessions.custom_name IS NOT NULL
147
+ THEN sessions.custom_name
148
+ ELSE COALESCE(excluded.custom_name, sessions.custom_name)
149
+ END`
134
150
  ).run(
135
151
  providerSessionId,
136
152
  provider,
@@ -151,9 +167,15 @@ export const sessionsDb = {
151
167
  * The session gateway uses this when the frontend starts a brand-new chat:
152
168
  * `session_id` is the stable app-facing id, while `provider_session_id`
153
169
  * stays NULL until the provider runtime announces its own id and
154
- * `assignProviderSessionId` records the mapping.
170
+ * `assignProviderSessionId` records the mapping. `customName` is derived
171
+ * from the first visible CloudCLI message by the sessions service.
155
172
  */
156
- createAppSession(sessionId: string, provider: string, projectPath: string): string {
173
+ createAppSession(
174
+ sessionId: string,
175
+ provider: string,
176
+ projectPath: string,
177
+ customName?: string,
178
+ ): string {
157
179
  const db = getConnection();
158
180
  const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
159
181
 
@@ -161,8 +183,8 @@ export const sessionsDb = {
161
183
 
162
184
  db.prepare(
163
185
  `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
164
- VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
165
- ).run(sessionId, provider, normalizedProjectPath);
186
+ VALUES (?, ?, NULL, ?, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
187
+ ).run(sessionId, provider, customName ?? null, normalizedProjectPath);
166
188
 
167
189
  return sessionId;
168
190
  },
@@ -229,6 +251,21 @@ export const sessionsDb = {
229
251
  ).run(model, sessionId);
230
252
  },
231
253
 
254
+ /**
255
+ * Records the reasoning effort one session runs with.
256
+ *
257
+ * `default` is stored as an explicit choice rather than NULL so reopening
258
+ * the session does not inherit a later per-provider effort preference.
259
+ */
260
+ setSessionEffort(sessionId: string, effort: string): void {
261
+ const db = getConnection();
262
+ db.prepare(
263
+ `UPDATE sessions
264
+ SET effort = ?
265
+ WHERE session_id = ?`
266
+ ).run(effort, sessionId);
267
+ },
268
+
232
269
  updateSessionCustomName(sessionId: string, customName: string): void {
233
270
  const db = getConnection();
234
271
  db.prepare(
@@ -325,6 +362,46 @@ export const sessionsDb = {
325
362
  return normalizeSessionRows(rows);
326
363
  },
327
364
 
365
+ /**
366
+ * Returns one globally ordered page of visible conversations.
367
+ *
368
+ * Pagination happens after archived sessions and sessions belonging to an
369
+ * archived project have been excluded. This keeps the sidebar feed complete
370
+ * and correctly ordered across projects instead of flattening only the
371
+ * per-project slices already loaded by the client.
372
+ */
373
+ getRecentSessionsPage(limit: number, offset: number): RecentSessionsPage {
374
+ const db = getConnection();
375
+ const visibilityClause = `
376
+ sessions.isArchived = 0
377
+ AND (projects.isArchived IS NULL OR projects.isArchived = 0)
378
+ `;
379
+ const rows = db
380
+ .prepare(
381
+ `SELECT sessions.*
382
+ FROM sessions
383
+ LEFT JOIN projects ON projects.project_path = sessions.project_path
384
+ WHERE ${visibilityClause}
385
+ ORDER BY julianday(COALESCE(sessions.updated_at, sessions.created_at)) DESC,
386
+ sessions.session_id DESC
387
+ LIMIT ? OFFSET ?`
388
+ )
389
+ .all(limit, offset) as SessionRow[];
390
+ const countRow = db
391
+ .prepare(
392
+ `SELECT COUNT(*) AS count
393
+ FROM sessions
394
+ LEFT JOIN projects ON projects.project_path = sessions.project_path
395
+ WHERE ${visibilityClause}`
396
+ )
397
+ .get() as { count: number } | undefined;
398
+
399
+ return {
400
+ sessions: normalizeSessionRows(rows),
401
+ total: Number(countRow?.count ?? 0),
402
+ };
403
+ },
404
+
328
405
  /**
329
406
  * Archived rows are intentionally queried separately so the caller can render
330
407
  * them in a dedicated view without reintroducing them into active session lists.
@@ -109,10 +109,11 @@ CREATE TABLE IF NOT EXISTS sessions (
109
109
  custom_name TEXT,
110
110
  project_path TEXT,
111
111
  jsonl_path TEXT,
112
- -- Model this session runs with. Written when the user picks a model for the
113
- -- session and on every send, so reopening a session restores the model it
114
- -- was last used with instead of falling back to the catalog default.
112
+ -- Model and reasoning effort this session runs with. Written when the user
113
+ -- changes either selection and on every send, so reopening a session
114
+ -- restores its exact runtime configuration instead of provider defaults.
115
115
  model TEXT,
116
+ effort TEXT,
116
117
  isArchived BOOLEAN DEFAULT 0,
117
118
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
118
119
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -138,6 +139,27 @@ CREATE TABLE IF NOT EXISTS app_config (
138
139
  );
139
140
  `;
140
141
 
142
+ /**
143
+ * Persistent custom-model library used by the Providers module.
144
+ *
145
+ * Only user-created models are stored here. Predefined models remain source-
146
+ * controlled in each provider's `-models.provider.ts` adapter so they can be
147
+ * updated without migrating application data. `model_id` is unique only within
148
+ * a provider because different CLIs can accept the same identifier.
149
+ */
150
+ export const PROVIDER_MODELS_TABLE_SCHEMA_SQL = `
151
+ CREATE TABLE IF NOT EXISTS provider_models (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ provider TEXT NOT NULL CHECK (provider IN ('claude', 'cursor', 'codex', 'opencode')),
154
+ model_id TEXT NOT NULL,
155
+ model_name TEXT NOT NULL,
156
+ sort_order INTEGER NOT NULL DEFAULT 0,
157
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
158
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
159
+ UNIQUE(provider, model_id)
160
+ );
161
+ `;
162
+
141
163
  export const INIT_SCHEMA_SQL = `
142
164
  -- Initialize authentication database
143
165
  PRAGMA foreign_keys = ON;
@@ -181,4 +203,8 @@ CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
181
203
  ${LAST_SCANNED_AT_SQL}
182
204
 
183
205
  ${APP_CONFIG_TABLE_SCHEMA_SQL}
206
+
207
+ ${PROVIDER_MODELS_TABLE_SCHEMA_SQL}
208
+ CREATE INDEX IF NOT EXISTS idx_provider_models_provider_order
209
+ ON provider_models(provider, sort_order, id);
184
210
  `;
@@ -0,0 +1,125 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ closeConnection,
9
+ getConnection,
10
+ initializeDatabase,
11
+ providerModelsDb,
12
+ sessionsDb,
13
+ } from '@/modules/database/index.js';
14
+ import { runMigrations } from '@/modules/database/migrations.js';
15
+
16
+ test('provider model repository stores custom rows only and maintains session references', async () => {
17
+ const previousDatabasePath = process.env.DATABASE_PATH;
18
+ const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'provider-model-db-'));
19
+ const databasePath = path.join(tempDirectory, 'auth.db');
20
+
21
+ closeConnection();
22
+ process.env.DATABASE_PATH = databasePath;
23
+ await writeFile(databasePath, '');
24
+ await initializeDatabase();
25
+
26
+ try {
27
+ const columns = getConnection().prepare('PRAGMA table_info(provider_models)').all() as Array<{
28
+ name: string;
29
+ }>;
30
+ assert.deepEqual(columns.map((column) => column.name), [
31
+ 'id',
32
+ 'provider',
33
+ 'model_id',
34
+ 'model_name',
35
+ 'sort_order',
36
+ 'created_at',
37
+ 'updated_at',
38
+ ]);
39
+ assert.deepEqual(providerModelsDb.listCustomProviderModels('codex'), []);
40
+
41
+ const custom = providerModelsDb.createCustomProviderModel('codex', {
42
+ model: 'Private Gateway Model',
43
+ id: 'gateway/model-v1',
44
+ });
45
+ assert.equal(custom.modelId, 'gateway/model-v1');
46
+ assert.equal(
47
+ providerModelsDb.findCustomProviderModelByModelId('codex', 'gateway/model-v1')?.recordId,
48
+ custom.recordId,
49
+ );
50
+
51
+ const db = getConnection();
52
+ db.prepare(`
53
+ INSERT INTO projects (project_id, project_path)
54
+ VALUES ('project-1', '/tmp/project-1')
55
+ `).run();
56
+ db.prepare(`
57
+ INSERT INTO sessions (session_id, provider, project_path, model, effort)
58
+ VALUES ('session-1', 'codex', '/tmp/project-1', 'gateway/model-v1', 'high')
59
+ `).run();
60
+
61
+ const updated = providerModelsDb.updateCustomProviderModel('codex', custom.recordId, {
62
+ model: 'Private Gateway Model 2',
63
+ id: 'gateway/model-v2',
64
+ });
65
+ assert.equal(updated?.modelId, 'gateway/model-v2');
66
+ assert.equal(sessionsDb.getSessionById('session-1')?.model, 'gateway/model-v2');
67
+ // A rename keeps the same underlying model, so its effort stays applicable.
68
+ assert.equal(sessionsDb.getSessionById('session-1')?.effort, 'high');
69
+
70
+ const removed = providerModelsDb.deleteCustomProviderModel(
71
+ 'codex',
72
+ custom.recordId,
73
+ 'gpt-default',
74
+ );
75
+ assert.equal(removed?.modelId, 'gateway/model-v2');
76
+ assert.equal(sessionsDb.getSessionById('session-1')?.model, 'gpt-default');
77
+ // The effort belonged to the deleted model and must not survive onto the
78
+ // fallback, which has its own default.
79
+ assert.equal(sessionsDb.getSessionById('session-1')?.effort, null);
80
+ assert.equal(providerModelsDb.getCustomProviderModel('codex', custom.recordId), null);
81
+ } finally {
82
+ closeConnection();
83
+ if (previousDatabasePath === undefined) {
84
+ delete process.env.DATABASE_PATH;
85
+ } else {
86
+ process.env.DATABASE_PATH = previousDatabasePath;
87
+ }
88
+ await rm(tempDirectory, { recursive: true, force: true });
89
+ }
90
+ });
91
+
92
+ test('migrations create the provider model index on an install that lacks it', async () => {
93
+ const previousDatabasePath = process.env.DATABASE_PATH;
94
+ const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'provider-model-index-'));
95
+ const databasePath = path.join(tempDirectory, 'auth.db');
96
+
97
+ closeConnection();
98
+ process.env.DATABASE_PATH = databasePath;
99
+ await writeFile(databasePath, '');
100
+ await initializeDatabase();
101
+
102
+ try {
103
+ const db = getConnection();
104
+ // Upgraded installs reach runMigrations with provider_models present but no
105
+ // index, so the CREATE INDEX statement is compiled against the real table
106
+ // instead of short-circuiting on the existing index name.
107
+ db.exec('DROP INDEX IF EXISTS idx_provider_models_provider_order');
108
+
109
+ runMigrations(db);
110
+
111
+ const indexedColumns = (db
112
+ .prepare('PRAGMA index_info(idx_provider_models_provider_order)')
113
+ .all() as Array<{ name: string }>)
114
+ .map((column) => column.name);
115
+ assert.deepEqual(indexedColumns, ['provider', 'sort_order', 'id']);
116
+ } finally {
117
+ closeConnection();
118
+ if (previousDatabasePath === undefined) {
119
+ delete process.env.DATABASE_PATH;
120
+ } else {
121
+ process.env.DATABASE_PATH = previousDatabasePath;
122
+ }
123
+ await rm(tempDirectory, { recursive: true, force: true });
124
+ }
125
+ });
@@ -45,7 +45,7 @@ test('disk-discovered sessions are keyed by the provider id for both columns', a
45
45
 
46
46
  test('app sessions get the provider id assigned without creating a duplicate row', async () => {
47
47
  await withIsolatedDatabase(() => {
48
- sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
48
+ sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo', 'Initial CloudCLI message');
49
49
  sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
50
50
 
51
51
  // A later synchronizer pass that discovers the transcript on disk must
@@ -66,6 +66,7 @@ test('app sessions get the provider id assigned without creating a duplicate row
66
66
  const row = sessionsDb.getSessionById('app-id-1');
67
67
  assert.equal(row?.provider_session_id, 'provider-xyz');
68
68
  assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
69
+ assert.equal(row?.custom_name, 'Initial CloudCLI message');
69
70
  });
70
71
  });
71
72
 
@@ -6,6 +6,7 @@ import test from 'node:test';
6
6
 
7
7
  import { closeConnection } from '@/modules/database/connection.js';
8
8
  import { initializeDatabase } from '@/modules/database/init-db.js';
9
+ import { projectsDb } from '@/modules/database/repositories/projects.db.js';
9
10
  import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
10
11
 
11
12
  async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
@@ -82,3 +83,34 @@ test('repository reads normalize SQLite UTC timestamps to ISO strings', async ()
82
83
  assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
83
84
  });
84
85
  });
86
+
87
+ test('recent sessions are globally ordered, paginated, and limited to visible conversations', async () => {
88
+ await withIsolatedDatabase(() => {
89
+ const fixtures: Array<Parameters<typeof sessionsDb.createSession>> = [
90
+ ['session-oldest', 'claude', '/workspace/project-a', 'Oldest', '2026-07-18T09:00:00.000Z', '2026-07-18T10:00:00.000Z'],
91
+ ['session-newest', 'codex', '/workspace/project-b', 'Newest', '2026-07-18T11:00:00.000Z', '2026-07-18T12:00:00.900Z'],
92
+ ['session-same-second', 'claude', '/workspace/project-a', 'Same second, slightly older', '2026-07-18T12:00:00.000Z', '2026-07-18T12:00:00.100Z'],
93
+ ['session-middle', 'claude', '/workspace/project-a', 'Middle', '2026-07-18T10:00:00.000Z', '2026-07-18T11:00:00.000Z'],
94
+ ['session-archived', 'claude', '/workspace/project-a', 'Archived session', '2026-07-18T13:00:00.000Z', '2026-07-18T13:00:00.000Z'],
95
+ ['session-hidden-project', 'claude', '/workspace/project-hidden', 'Archived project session', '2026-07-18T14:00:00.000Z', '2026-07-18T14:00:00.000Z'],
96
+ ];
97
+ fixtures.forEach((fixture) => sessionsDb.createSession(...fixture));
98
+
99
+ sessionsDb.updateSessionIsArchived('session-archived', true);
100
+ projectsDb.updateProjectIsArchived('/workspace/project-hidden', true);
101
+
102
+ const firstPage = sessionsDb.getRecentSessionsPage(2, 0);
103
+ const secondPage = sessionsDb.getRecentSessionsPage(2, 2);
104
+
105
+ assert.equal(firstPage.total, 4);
106
+ assert.deepEqual(
107
+ firstPage.sessions.map((session) => session.session_id),
108
+ ['session-newest', 'session-same-second'],
109
+ );
110
+ assert.equal(secondPage.total, 4);
111
+ assert.deepEqual(
112
+ secondPage.sessions.map((session) => session.session_id),
113
+ ['session-middle', 'session-oldest'],
114
+ );
115
+ });
116
+ });
@@ -36,7 +36,11 @@ const fileTreeFileSystem: FileTreeFileSystem = {
36
36
  access: (candidatePath) => fsPromises.access(candidatePath),
37
37
  stat: (candidatePath) => fsPromises.stat(candidatePath),
38
38
  lstat: (candidatePath) => fsPromises.lstat(candidatePath),
39
- readdir: (directoryPath) => fsPromises.readdir(directoryPath, { withFileTypes: true }),
39
+ // `opendir` streams entries in batches and the handle is closed by the
40
+ // iterator protocol, including when the caller stops early at the entry cap.
41
+ openDirectory: async function* (directoryPath) {
42
+ yield* await fsPromises.opendir(directoryPath);
43
+ },
40
44
  realpath: (candidatePath) => fsPromises.realpath(candidatePath),
41
45
  readTextFile: (filePath) => fsPromises.readFile(filePath, 'utf8'),
42
46
  writeTextFile: (filePath, content) => fsPromises.writeFile(filePath, content, 'utf8'),