@cloudcli-ai/cloudcli 1.34.0 → 1.35.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 (247) hide show
  1. package/README.de.md +9 -1
  2. package/README.ja.md +8 -0
  3. package/README.ko.md +9 -1
  4. package/README.md +23 -5
  5. package/README.ru.md +9 -1
  6. package/README.tr.md +8 -1
  7. package/README.zh-CN.md +9 -1
  8. package/README.zh-TW.md +9 -1
  9. package/dist/api-docs.html +36 -18
  10. package/dist/assets/index-BvX30NW5.css +32 -0
  11. package/dist/assets/index-D1IdCTT6.js +1418 -0
  12. package/dist/index.html +12 -4
  13. package/dist-server/server/browser-use-mcp.js +350 -0
  14. package/dist-server/server/browser-use-mcp.js.map +1 -0
  15. package/dist-server/server/claude-sdk.js +25 -5
  16. package/dist-server/server/claude-sdk.js.map +1 -1
  17. package/dist-server/server/cli.js +14 -6
  18. package/dist-server/server/cli.js.map +1 -1
  19. package/dist-server/server/cursor-cli.js +27 -11
  20. package/dist-server/server/cursor-cli.js.map +1 -1
  21. package/dist-server/server/gemini-cli.js +17 -2
  22. package/dist-server/server/gemini-cli.js.map +1 -1
  23. package/dist-server/server/index.js +136 -40
  24. package/dist-server/server/index.js.map +1 -1
  25. package/dist-server/server/load-env.js +1 -1
  26. package/dist-server/server/load-env.js.map +1 -1
  27. package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js +107 -0
  28. package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js.map +1 -0
  29. package/dist-server/server/modules/browser-use/browser-use.routes.js +93 -0
  30. package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -0
  31. package/dist-server/server/modules/browser-use/browser-use.service.js +688 -0
  32. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -0
  33. package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js +8 -0
  34. package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js.map +1 -0
  35. package/dist-server/server/modules/database/index.js +1 -0
  36. package/dist-server/server/modules/database/index.js.map +1 -1
  37. package/dist-server/server/modules/database/migrations.js +23 -1
  38. package/dist-server/server/modules/database/migrations.js.map +1 -1
  39. package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js +100 -0
  40. package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js.map +1 -0
  41. package/dist-server/server/modules/database/repositories/notification-preferences.js +8 -0
  42. package/dist-server/server/modules/database/repositories/notification-preferences.js.map +1 -1
  43. package/dist-server/server/modules/database/repositories/sessions.db.js +173 -17
  44. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  45. package/dist-server/server/modules/database/schema.js +26 -0
  46. package/dist-server/server/modules/database/schema.js.map +1 -1
  47. package/dist-server/server/modules/database/tests/projects.db.integration.test.js.map +1 -0
  48. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +78 -0
  49. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -0
  50. package/dist-server/server/modules/database/{repositories → tests}/sessions.db.integration.test.js +10 -0
  51. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -0
  52. package/dist-server/server/modules/notifications/index.js +4 -0
  53. package/dist-server/server/modules/notifications/index.js.map +1 -0
  54. package/dist-server/server/modules/notifications/notifications.routes.js +115 -0
  55. package/dist-server/server/modules/notifications/notifications.routes.js.map +1 -0
  56. package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js +97 -0
  57. package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js.map +1 -0
  58. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +229 -0
  59. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -0
  60. package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js +79 -0
  61. package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js.map +1 -0
  62. package/dist-server/server/modules/projects/services/project-management.service.js +0 -4
  63. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  64. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +10 -38
  65. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  66. package/dist-server/server/modules/providers/index.js +1 -0
  67. package/dist-server/server/modules/providers/index.js.map +1 -1
  68. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +24 -1
  69. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
  70. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +9 -12
  71. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  72. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +7 -0
  73. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
  74. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +7 -3
  75. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  76. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +3 -9
  77. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  78. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -0
  79. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  80. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +13 -22
  81. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  82. package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js +7 -0
  83. package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js.map +1 -1
  84. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +5 -8
  85. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -1
  86. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +6 -6
  87. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  88. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +7 -0
  89. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +1 -1
  90. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +17 -3
  91. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  92. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +9 -10
  93. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  94. package/dist-server/server/modules/providers/provider.routes.js +123 -1
  95. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  96. package/dist-server/server/modules/providers/services/mcp.service.js +23 -0
  97. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  98. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +65 -0
  99. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -0
  100. package/dist-server/server/modules/providers/services/provider-models.service.js +1 -1
  101. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  102. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +62 -23
  103. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  104. package/dist-server/server/modules/providers/services/sessions.service.js +62 -4
  105. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  106. package/dist-server/server/modules/providers/services/skills.service.js +11 -0
  107. package/dist-server/server/modules/providers/services/skills.service.js.map +1 -1
  108. package/dist-server/server/modules/providers/shared/skills/skills.provider.js +163 -1
  109. package/dist-server/server/modules/providers/shared/skills/skills.provider.js.map +1 -1
  110. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +45 -0
  111. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  112. package/dist-server/server/modules/providers/tests/skills.test.js +160 -0
  113. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  114. package/dist-server/server/modules/websocket/index.js +1 -0
  115. package/dist-server/server/modules/websocket/index.js.map +1 -1
  116. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +252 -0
  117. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -0
  118. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +105 -0
  119. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -0
  120. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +218 -156
  121. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  122. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +83 -31
  123. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  124. package/dist-server/server/modules/websocket/services/websocket-server.service.js +5 -0
  125. package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
  126. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +216 -0
  127. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -0
  128. package/dist-server/server/openai-codex.js +24 -14
  129. package/dist-server/server/openai-codex.js.map +1 -1
  130. package/dist-server/server/opencode-cli.js +21 -8
  131. package/dist-server/server/opencode-cli.js.map +1 -1
  132. package/dist-server/server/opencode-cli.test.js +19 -1
  133. package/dist-server/server/opencode-cli.test.js.map +1 -1
  134. package/dist-server/server/routes/settings.js +1 -1
  135. package/dist-server/server/routes/settings.js.map +1 -1
  136. package/dist-server/server/services/notification-orchestrator.js +1 -176
  137. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  138. package/dist-server/server/services/tests/notification-orchestrator.test.js +64 -0
  139. package/dist-server/server/services/tests/notification-orchestrator.test.js.map +1 -0
  140. package/dist-server/server/shared/{claude-cli-path.test.js → tests/claude-cli-path.test.js} +1 -1
  141. package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -0
  142. package/dist-server/server/shared/tests/slice-tail-page.test.js +35 -0
  143. package/dist-server/server/shared/tests/slice-tail-page.test.js.map +1 -0
  144. package/dist-server/server/shared/utils.js +73 -1
  145. package/dist-server/server/shared/utils.js.map +1 -1
  146. package/dist-server/server/voice-proxy.js +226 -0
  147. package/dist-server/server/voice-proxy.js.map +1 -0
  148. package/electron/assets/logo-macos.icns +0 -0
  149. package/electron/assets/logo-macos.png +0 -0
  150. package/electron/assets/logo-windows.ico +0 -0
  151. package/electron/cloud.js +260 -0
  152. package/electron/desktopNotifications.js +378 -0
  153. package/electron/desktopWindow.js +766 -0
  154. package/electron/launcher/index.html +14 -0
  155. package/electron/launcher/launcher.css +801 -0
  156. package/electron/launcher/launcher.js +687 -0
  157. package/electron/localServer.js +550 -0
  158. package/electron/main.js +944 -0
  159. package/electron/preload.cjs +60 -0
  160. package/electron/scripts/generate-macos-icon.js +62 -0
  161. package/electron/serverInstaller.js +277 -0
  162. package/electron/tabs.js +87 -0
  163. package/electron/viewHost.js +331 -0
  164. package/package.json +79 -3
  165. package/public/api-docs.html +36 -18
  166. package/scripts/release/build-server-bundle.js +176 -0
  167. package/scripts/release/prepare-desktop-app.js +152 -0
  168. package/server/browser-use-mcp.ts +384 -0
  169. package/server/claude-sdk.js +27 -5
  170. package/server/cli.js +15 -6
  171. package/server/cursor-cli.js +27 -11
  172. package/server/gemini-cli.js +17 -2
  173. package/server/index.js +137 -47
  174. package/server/load-env.js +1 -1
  175. package/server/modules/browser-use/browser-use-mcp.routes.ts +120 -0
  176. package/server/modules/browser-use/browser-use.routes.ts +96 -0
  177. package/server/modules/browser-use/browser-use.service.ts +836 -0
  178. package/server/modules/browser-use/tests/browser-use.service.test.ts +10 -0
  179. package/server/modules/database/index.ts +1 -0
  180. package/server/modules/database/migrations.ts +25 -0
  181. package/server/modules/database/repositories/notification-channel-endpoints.ts +153 -0
  182. package/server/modules/database/repositories/notification-preferences.ts +12 -1
  183. package/server/modules/database/repositories/sessions.db.ts +227 -23
  184. package/server/modules/database/schema.ts +27 -0
  185. package/server/modules/database/tests/sessions-provider-mapping.test.ts +108 -0
  186. package/server/modules/database/{repositories → tests}/sessions.db.integration.test.ts +12 -0
  187. package/server/modules/notifications/index.ts +13 -0
  188. package/server/modules/notifications/notifications.routes.ts +127 -0
  189. package/server/modules/notifications/services/desktop-notification-clients.service.ts +124 -0
  190. package/server/modules/notifications/services/notification-orchestrator.service.js +288 -0
  191. package/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts +109 -0
  192. package/server/modules/projects/services/project-management.service.ts +0 -8
  193. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +12 -53
  194. package/server/modules/providers/README.md +1 -1
  195. package/server/modules/providers/index.ts +1 -0
  196. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +26 -1
  197. package/server/modules/providers/list/claude/claude-sessions.provider.ts +9 -14
  198. package/server/modules/providers/list/claude/claude-skills.provider.ts +8 -0
  199. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +7 -3
  200. package/server/modules/providers/list/codex/codex-sessions.provider.ts +3 -12
  201. package/server/modules/providers/list/codex/codex-skills.provider.ts +8 -0
  202. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +13 -22
  203. package/server/modules/providers/list/cursor/cursor-skills.provider.ts +8 -0
  204. package/server/modules/providers/list/gemini/gemini-models.provider.ts +5 -8
  205. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +6 -6
  206. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +8 -0
  207. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +18 -4
  208. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +9 -12
  209. package/server/modules/providers/provider.routes.ts +167 -1
  210. package/server/modules/providers/services/mcp.service.ts +26 -0
  211. package/server/modules/providers/services/provider-capabilities.service.ts +91 -0
  212. package/server/modules/providers/services/provider-models.service.ts +1 -1
  213. package/server/modules/providers/services/sessions-watcher.service.ts +71 -25
  214. package/server/modules/providers/services/sessions.service.ts +79 -4
  215. package/server/modules/providers/services/skills.service.ts +25 -1
  216. package/server/modules/providers/shared/skills/skills.provider.ts +223 -0
  217. package/server/modules/providers/tests/opencode-sessions.test.ts +49 -0
  218. package/server/modules/providers/tests/skills.test.ts +212 -0
  219. package/server/modules/websocket/README.md +33 -27
  220. package/server/modules/websocket/index.ts +1 -0
  221. package/server/modules/websocket/services/chat-run-registry.service.ts +327 -0
  222. package/server/modules/websocket/services/chat-session-writer.service.ts +145 -0
  223. package/server/modules/websocket/services/chat-websocket.service.ts +292 -207
  224. package/server/modules/websocket/services/shell-websocket.service.ts +108 -32
  225. package/server/modules/websocket/services/websocket-server.service.ts +6 -0
  226. package/server/modules/websocket/tests/chat-run-registry.test.ts +244 -0
  227. package/server/openai-codex.js +24 -14
  228. package/server/opencode-cli.js +21 -8
  229. package/server/opencode-cli.test.js +20 -1
  230. package/server/routes/settings.js +6 -1
  231. package/server/services/notification-orchestrator.js +3 -224
  232. package/server/services/tests/notification-orchestrator.test.js +80 -0
  233. package/server/shared/interfaces.ts +15 -0
  234. package/server/shared/tests/slice-tail-page.test.ts +42 -0
  235. package/server/shared/types.ts +84 -1
  236. package/server/shared/utils.ts +95 -1
  237. package/server/voice-proxy.js +224 -0
  238. package/dist/assets/index-73Xw3i9v.css +0 -32
  239. package/dist/assets/index-BIHaviaA.js +0 -1361
  240. package/dist/modelConstants.js +0 -854
  241. package/dist-server/server/modules/database/repositories/projects.db.integration.test.js.map +0 -1
  242. package/dist-server/server/modules/database/repositories/sessions.db.integration.test.js.map +0 -1
  243. package/dist-server/server/shared/claude-cli-path.test.js.map +0 -1
  244. package/public/modelConstants.js +0 -854
  245. /package/dist-server/server/modules/database/{repositories → tests}/projects.db.integration.test.js +0 -0
  246. /package/server/modules/database/{repositories → tests}/projects.db.integration.test.ts +0 -0
  247. /package/server/shared/{claude-cli-path.test.ts → tests/claude-cli-path.test.ts} +0 -0
@@ -0,0 +1,10 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
5
+
6
+ test('browser monitor list starts empty without agent sessions', async () => {
7
+ const sessions = await browserUseService.listSessions();
8
+
9
+ assert.deepEqual(sessions, []);
10
+ });
@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
4
4
  export { appConfigDb } from '@/modules/database/repositories/app-config.js';
5
5
  export { credentialsDb } from '@/modules/database/repositories/credentials.js';
6
6
  export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
7
+ export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
7
8
  export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
8
9
  export { projectsDb } from '@/modules/database/repositories/projects.db.js';
9
10
  export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
3
3
  import {
4
4
  APP_CONFIG_TABLE_SCHEMA_SQL,
5
5
  LAST_SCANNED_AT_SQL,
6
+ NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
6
7
  PROJECTS_TABLE_SCHEMA_SQL,
7
8
  PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
8
9
  SESSIONS_TABLE_SCHEMA_SQL,
@@ -382,6 +383,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
382
383
  }
383
384
  };
384
385
 
386
+ /**
387
+ * Adds the `provider_session_id` mapping column used by the session gateway.
388
+ *
389
+ * Rows that existed before this migration were always keyed directly by the
390
+ * provider-native session id, so backfilling `provider_session_id` with
391
+ * `session_id` keeps every legacy row resolvable through the new mapping.
392
+ */
393
+ const addProviderSessionIdMapping = (db: Database): void => {
394
+ const sessionsTableInfo = getTableInfo(db, 'sessions');
395
+ const columnNames = sessionsTableInfo.map((column) => column.name);
396
+
397
+ addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
398
+ db.exec(`
399
+ UPDATE sessions
400
+ SET provider_session_id = session_id
401
+ WHERE provider_session_id IS NULL
402
+ `);
403
+ };
404
+
385
405
  const ensureProjectsForSessionPaths = (db: Database): void => {
386
406
  if (!tableExists(db, 'sessions')) {
387
407
  return;
@@ -421,6 +441,9 @@ export const runMigrations = (db: Database) => {
421
441
  db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
422
442
  db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
423
443
  db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
444
+ db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
445
+ db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
446
+ db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
424
447
 
425
448
  db.exec(PROJECTS_TABLE_SCHEMA_SQL);
426
449
  rebuildProjectsTableWithPrimaryKeySchema(db);
@@ -428,9 +451,11 @@ export const runMigrations = (db: Database) => {
428
451
  migrateLegacyWorkspaceTableIntoProjects(db);
429
452
  rebuildSessionsTableWithProjectSchema(db);
430
453
  migrateLegacySessionNames(db);
454
+ addProviderSessionIdMapping(db);
431
455
  ensureProjectsForSessionPaths(db);
432
456
 
433
457
  db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
458
+ db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
434
459
  db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
435
460
  db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
436
461
  db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
@@ -0,0 +1,153 @@
1
+ import { getConnection } from '@/modules/database/connection.js';
2
+
3
+ type NotificationChannelEndpointRow = {
4
+ id: number;
5
+ user_id: number;
6
+ channel: string;
7
+ endpoint_id: string;
8
+ label: string | null;
9
+ metadata_json: string | null;
10
+ enabled: number;
11
+ last_seen_at: string;
12
+ created_at: string;
13
+ updated_at: string;
14
+ };
15
+
16
+ type UpsertNotificationChannelEndpointInput = {
17
+ userId: number;
18
+ channel: string;
19
+ endpointId: string;
20
+ label?: string | null;
21
+ metadata?: Record<string, unknown> | null;
22
+ enabled?: boolean;
23
+ };
24
+
25
+ function normalizeRequiredText(value: unknown): string {
26
+ if (typeof value !== 'string') return '';
27
+ return value.trim();
28
+ }
29
+
30
+ function normalizeNullableText(value: unknown): string | null {
31
+ if (typeof value !== 'string') return null;
32
+ const normalized = value.trim();
33
+ return normalized || null;
34
+ }
35
+
36
+ function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
37
+ if (!metadata || typeof metadata !== 'object') return null;
38
+ return JSON.stringify(metadata);
39
+ }
40
+
41
+ function parseMetadata(metadataJson: string | null): Record<string, unknown> {
42
+ if (!metadataJson) return {};
43
+ try {
44
+ const parsed = JSON.parse(metadataJson);
45
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ export const notificationChannelEndpointsDb = {
52
+ upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
53
+ const channel = normalizeRequiredText(input.channel);
54
+ const endpointId = normalizeRequiredText(input.endpointId);
55
+ if (!channel) throw new Error('channel is required');
56
+ if (!endpointId) throw new Error('endpointId is required');
57
+
58
+ const enabled = input.enabled === false ? 0 : 1;
59
+ const db = getConnection();
60
+ db.prepare(
61
+ `INSERT INTO notification_channel_endpoints (
62
+ user_id,
63
+ channel,
64
+ endpoint_id,
65
+ label,
66
+ metadata_json,
67
+ enabled,
68
+ last_seen_at,
69
+ updated_at
70
+ )
71
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
72
+ ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
73
+ label = excluded.label,
74
+ metadata_json = excluded.metadata_json,
75
+ enabled = excluded.enabled,
76
+ last_seen_at = CURRENT_TIMESTAMP,
77
+ updated_at = CURRENT_TIMESTAMP`
78
+ ).run(
79
+ input.userId,
80
+ channel,
81
+ endpointId,
82
+ normalizeNullableText(input.label),
83
+ serializeMetadata(input.metadata),
84
+ enabled
85
+ );
86
+
87
+ return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
88
+ },
89
+
90
+ getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
91
+ const db = getConnection();
92
+ const row = db.prepare(
93
+ `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
94
+ FROM notification_channel_endpoints
95
+ WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
96
+ ).get(
97
+ userId,
98
+ normalizeRequiredText(channel),
99
+ normalizeRequiredText(endpointId)
100
+ ) as NotificationChannelEndpointRow | undefined;
101
+ return row || null;
102
+ },
103
+
104
+ getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
105
+ const db = getConnection();
106
+ return db.prepare(
107
+ `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
108
+ FROM notification_channel_endpoints
109
+ WHERE user_id = ? AND channel = ?
110
+ ORDER BY last_seen_at DESC`
111
+ ).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
112
+ },
113
+
114
+ getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
115
+ const db = getConnection();
116
+ return db.prepare(
117
+ `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
118
+ FROM notification_channel_endpoints
119
+ WHERE user_id = ? AND channel = ? AND enabled = 1
120
+ ORDER BY last_seen_at DESC`
121
+ ).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
122
+ },
123
+
124
+ setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
125
+ const db = getConnection();
126
+ const result = db.prepare(
127
+ `UPDATE notification_channel_endpoints
128
+ SET enabled = ?, updated_at = CURRENT_TIMESTAMP
129
+ WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
130
+ ).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
131
+ return result.changes > 0;
132
+ },
133
+
134
+ touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
135
+ const db = getConnection();
136
+ const result = db.prepare(
137
+ `UPDATE notification_channel_endpoints
138
+ SET last_seen_at = CURRENT_TIMESTAMP
139
+ WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
140
+ ).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
141
+ return result.changes > 0;
142
+ },
143
+
144
+ removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
145
+ const db = getConnection();
146
+ const result = db.prepare(
147
+ 'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
148
+ ).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
149
+ return result.changes > 0;
150
+ },
151
+
152
+ parseMetadata,
153
+ };
@@ -10,7 +10,9 @@ type NotificationPreferences = {
10
10
  channels: {
11
11
  inApp: boolean;
12
12
  webPush: boolean;
13
+ desktop: boolean;
13
14
  sound: boolean;
15
+ [key: string]: boolean;
14
16
  };
15
17
  events: {
16
18
  actionRequired: boolean;
@@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
23
25
  channels: {
24
26
  inApp: false,
25
27
  webPush: false,
28
+ desktop: false,
26
29
  sound: true,
27
30
  },
28
31
  events: {
@@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
34
37
 
35
38
  function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
36
39
  const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
40
+ const sourceChannels = source.channels && typeof source.channels === 'object'
41
+ ? source.channels as Record<string, unknown>
42
+ : {};
43
+ const extraChannels = Object.fromEntries(
44
+ Object.entries(sourceChannels)
45
+ .filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
46
+ ) as Record<string, boolean>;
37
47
 
38
48
  return {
39
49
  channels: {
50
+ ...extraChannels,
40
51
  inApp: source.channels?.inApp === true,
41
52
  webPush: source.channels?.webPush === true,
53
+ desktop: source.channels?.desktop === true,
42
54
  sound: source.channels?.sound !== false,
43
55
  },
44
56
  events: {
@@ -103,4 +115,3 @@ export const notificationPreferencesDb = {
103
115
  return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
104
116
  },
105
117
  };
106
-
@@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js';
5
5
  type SessionRow = {
6
6
  session_id: string;
7
7
  provider: string;
8
+ provider_session_id: string | null;
8
9
  project_path: string | null;
9
10
  jsonl_path: string | null;
10
11
  custom_name: string | null;
@@ -13,15 +14,22 @@ type SessionRow = {
13
14
  updated_at: string;
14
15
  };
15
16
 
16
- type SessionMetadataLookupRow = Pick<
17
- SessionRow,
18
- 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
19
- >;
17
+ const SESSION_ROW_COLUMNS =
18
+ 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
19
+
20
+ const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
20
21
 
21
22
  function normalizeTimestamp(value?: string): string | null {
22
23
  if (!value) return null;
23
24
 
24
- const parsed = new Date(value);
25
+ // SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
26
+ // Normalize it here so every session reader returns canonical ISO strings
27
+ // and the sidebar never interprets fresh rows as local-time "hours old".
28
+ const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
29
+ ? `${value.replace(' ', 'T')}Z`
30
+ : value;
31
+
32
+ const parsed = new Date(normalizedValue);
25
33
  if (Number.isNaN(parsed.getTime())) {
26
34
  return null;
27
35
  }
@@ -29,14 +37,38 @@ function normalizeTimestamp(value?: string): string | null {
29
37
  return parsed.toISOString();
30
38
  }
31
39
 
40
+ function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
41
+ if (!row) {
42
+ return row;
43
+ }
44
+
45
+ return {
46
+ ...row,
47
+ created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
48
+ updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
49
+ };
50
+ }
51
+
52
+ function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
53
+ return rows.map((row) => normalizeSessionRow(row) as SessionRow);
54
+ }
55
+
32
56
  function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
33
57
  void provider;
34
58
  return normalizeProjectPath(projectPath);
35
59
  }
36
60
 
37
61
  export const sessionsDb = {
62
+ /**
63
+ * Upserts one session row discovered on disk by a provider synchronizer.
64
+ *
65
+ * The given id is the provider-native session id. Rows are keyed by
66
+ * `provider_session_id` so a session that was first created by the app
67
+ * (with an app-allocated `session_id`) is updated in place once its
68
+ * transcript shows up on disk, instead of producing a duplicate row.
69
+ */
38
70
  createSession(
39
- sessionId: string,
71
+ providerSessionId: string,
40
72
  provider: string,
41
73
  projectPath: string,
42
74
  customName?: string,
@@ -53,19 +85,54 @@ export const sessionsDb = {
53
85
  // since it's a foreign key in the sessions table.
54
86
  projectsDb.createProjectPath(normalizedProjectPath);
55
87
 
88
+ const existing = db
89
+ .prepare(
90
+ `SELECT session_id FROM sessions
91
+ WHERE provider_session_id = ? AND provider = ?
92
+ LIMIT 1`
93
+ )
94
+ .get(providerSessionId, provider) as { session_id: string } | undefined;
95
+
96
+ if (existing) {
97
+ db.prepare(
98
+ `UPDATE sessions SET
99
+ provider = ?,
100
+ updated_at = COALESCE(?, CURRENT_TIMESTAMP),
101
+ project_path = ?,
102
+ jsonl_path = ?,
103
+ isArchived = 0,
104
+ custom_name = COALESCE(?, custom_name)
105
+ WHERE session_id = ?`
106
+ ).run(
107
+ provider,
108
+ updatedAtValue,
109
+ normalizedProjectPath,
110
+ jsonlPath ?? null,
111
+ customName ?? null,
112
+ existing.session_id
113
+ );
114
+
115
+ return existing.session_id;
116
+ }
117
+
118
+ // Sessions created outside the app (directly via the provider CLI) are
119
+ // keyed by the provider-native id for both columns. The ON CONFLICT path
120
+ // covers legacy rows that predate the provider_session_id mapping.
56
121
  db.prepare(
57
- `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
58
- VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
122
+ `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
123
+ VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
59
124
  ON CONFLICT(session_id) DO UPDATE SET
60
125
  provider = excluded.provider,
126
+ provider_session_id = excluded.provider_session_id,
61
127
  updated_at = excluded.updated_at,
62
128
  project_path = excluded.project_path,
63
129
  jsonl_path = excluded.jsonl_path,
64
130
  isArchived = 0,
65
131
  custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
66
132
  ).run(
67
- sessionId,
133
+ providerSessionId,
68
134
  provider,
135
+ providerSessionId,
69
136
  customName ?? null,
70
137
  normalizedProjectPath,
71
138
  jsonlPath ?? null,
@@ -73,9 +140,77 @@ export const sessionsDb = {
73
140
  updatedAtValue
74
141
  );
75
142
 
143
+ return providerSessionId;
144
+ },
145
+
146
+ /**
147
+ * Inserts one app-allocated session row before any provider run happens.
148
+ *
149
+ * The session gateway uses this when the frontend starts a brand-new chat:
150
+ * `session_id` is the stable app-facing id, while `provider_session_id`
151
+ * stays NULL until the provider runtime announces its own id and
152
+ * `assignProviderSessionId` records the mapping.
153
+ */
154
+ createAppSession(sessionId: string, provider: string, projectPath: string): string {
155
+ const db = getConnection();
156
+ const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
157
+
158
+ projectsDb.createProjectPath(normalizedProjectPath);
159
+
160
+ db.prepare(
161
+ `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
162
+ VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
163
+ ).run(sessionId, provider, normalizedProjectPath);
164
+
76
165
  return sessionId;
77
166
  },
78
167
 
168
+ /**
169
+ * Records the provider-native session id for one app-allocated session.
170
+ *
171
+ * If the filesystem watcher indexed the provider transcript before this
172
+ * mapping was recorded (a duplicate row keyed by the provider id exists),
173
+ * the duplicate is merged into the app row: its transcript path and name
174
+ * are adopted and the duplicate row is removed. Runs in a transaction so
175
+ * the sidebar can never observe both rows at once.
176
+ */
177
+ assignProviderSessionId(sessionId: string, providerSessionId: string): void {
178
+ const db = getConnection();
179
+
180
+ const merge = db.transaction(() => {
181
+ const duplicate = db
182
+ .prepare(
183
+ `SELECT ${SESSION_ROW_COLUMNS} FROM sessions
184
+ WHERE (session_id = ? OR provider_session_id = ?)
185
+ AND session_id <> ?
186
+ LIMIT 1`
187
+ )
188
+ .get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
189
+
190
+ if (duplicate) {
191
+ db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
192
+ db.prepare(
193
+ `UPDATE sessions SET
194
+ provider_session_id = ?,
195
+ jsonl_path = COALESCE(jsonl_path, ?),
196
+ custom_name = COALESCE(custom_name, ?),
197
+ updated_at = CURRENT_TIMESTAMP
198
+ WHERE session_id = ?`
199
+ ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
200
+ return;
201
+ }
202
+
203
+ db.prepare(
204
+ `UPDATE sessions SET
205
+ provider_session_id = ?,
206
+ updated_at = CURRENT_TIMESTAMP
207
+ WHERE session_id = ?`
208
+ ).run(providerSessionId, sessionId);
209
+ });
210
+
211
+ merge();
212
+ },
213
+
79
214
  updateSessionCustomName(sessionId: string, customName: string): void {
80
215
  const db = getConnection();
81
216
  db.prepare(
@@ -85,30 +220,91 @@ export const sessionsDb = {
85
220
  ).run(customName, sessionId);
86
221
  },
87
222
 
88
- getSessionById(sessionId: string): SessionMetadataLookupRow | null {
223
+ getSessionById(sessionId: string): SessionRow | null {
89
224
  const db = getConnection();
90
225
  const row = db
91
226
  .prepare(
92
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
227
+ `SELECT ${SESSION_ROW_COLUMNS}
93
228
  FROM sessions
94
229
  WHERE session_id = ?
95
230
  ORDER BY updated_at DESC
96
231
  LIMIT 1`
97
232
  )
98
- .get(sessionId) as SessionMetadataLookupRow | undefined;
233
+ .get(sessionId) as SessionRow | undefined;
99
234
 
100
- return row ?? null;
235
+ return normalizeSessionRow(row) ?? null;
236
+ },
237
+
238
+ /**
239
+ * Resolves one session row through the provider-native id.
240
+ *
241
+ * The filesystem watcher only knows provider ids (they come from transcript
242
+ * file names), so it uses this lookup to translate disk artifacts back to
243
+ * the app-facing session row before broadcasting sidebar updates.
244
+ */
245
+ getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
246
+ const db = getConnection();
247
+ const row = db
248
+ .prepare(
249
+ `SELECT ${SESSION_ROW_COLUMNS}
250
+ FROM sessions
251
+ WHERE provider_session_id = ?
252
+ ORDER BY updated_at DESC
253
+ LIMIT 1`
254
+ )
255
+ .get(providerSessionId) as SessionRow | undefined;
256
+
257
+ return normalizeSessionRow(row) ?? null;
258
+ },
259
+
260
+ /**
261
+ * Finds the newest app-created session for a project that is still waiting
262
+ * for its provider-native id to be recorded.
263
+ *
264
+ * Primary intention: OpenCode can expose a new session in its shared
265
+ * `opencode.db` before the websocket runtime reports that same provider id
266
+ * back to our app. At that moment the sidebar already has an optimistic
267
+ * app-owned session row, but the watcher only knows the provider-native id.
268
+ *
269
+ * Without this lookup, the synchronizer would insert a second row keyed by
270
+ * the provider id, then `assignProviderSessionId()` would merge it a moment
271
+ * later. That eventually self-heals, but on slow networks the user can still
272
+ * briefly see two sidebar sessions for the same conversation.
273
+ *
274
+ * This helper lets the synchronizer claim the pending app row first, so the
275
+ * provider id is attached before any watcher-created row exists. The result
276
+ * is simpler than frontend dedupe and keeps the race resolved at the source.
277
+ */
278
+ findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
279
+ const db = getConnection();
280
+ const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
281
+ const row = db
282
+ .prepare(
283
+ `SELECT ${SESSION_ROW_COLUMNS}
284
+ FROM sessions
285
+ WHERE provider = ?
286
+ AND project_path = ?
287
+ AND provider_session_id IS NULL
288
+ AND isArchived = 0
289
+ ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
290
+ LIMIT 1`
291
+ )
292
+ .get(provider, normalizedProjectPath) as SessionRow | undefined;
293
+
294
+ return normalizeSessionRow(row) ?? null;
101
295
  },
102
296
 
103
297
  getAllSessions(): SessionRow[] {
104
298
  const db = getConnection();
105
- return db
299
+ const rows = db
106
300
  .prepare(
107
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
301
+ `SELECT ${SESSION_ROW_COLUMNS}
108
302
  FROM sessions
109
303
  WHERE isArchived = 0`
110
304
  )
111
305
  .all() as SessionRow[];
306
+
307
+ return normalizeSessionRows(rows);
112
308
  },
113
309
 
114
310
  /**
@@ -117,27 +313,31 @@ export const sessionsDb = {
117
313
  */
118
314
  getArchivedSessions(): SessionRow[] {
119
315
  const db = getConnection();
120
- return db
316
+ const rows = db
121
317
  .prepare(
122
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
318
+ `SELECT ${SESSION_ROW_COLUMNS}
123
319
  FROM sessions
124
320
  WHERE isArchived = 1
125
321
  ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
126
322
  )
127
323
  .all() as SessionRow[];
324
+
325
+ return normalizeSessionRows(rows);
128
326
  },
129
327
 
130
328
  getSessionsByProjectPath(projectPath: string): SessionRow[] {
131
329
  const db = getConnection();
132
330
  const normalizedProjectPath = normalizeProjectPath(projectPath);
133
- return db
331
+ const rows = db
134
332
  .prepare(
135
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
333
+ `SELECT ${SESSION_ROW_COLUMNS}
136
334
  FROM sessions
137
335
  WHERE project_path = ?
138
336
  AND isArchived = 0`
139
337
  )
140
338
  .all(normalizedProjectPath) as SessionRow[];
339
+
340
+ return normalizeSessionRows(rows);
141
341
  },
142
342
 
143
343
  /**
@@ -147,21 +347,23 @@ export const sessionsDb = {
147
347
  getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
148
348
  const db = getConnection();
149
349
  const normalizedProjectPath = normalizeProjectPath(projectPath);
150
- return db
350
+ const rows = db
151
351
  .prepare(
152
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
352
+ `SELECT ${SESSION_ROW_COLUMNS}
153
353
  FROM sessions
154
354
  WHERE project_path = ?`
155
355
  )
156
356
  .all(normalizedProjectPath) as SessionRow[];
357
+
358
+ return normalizeSessionRows(rows);
157
359
  },
158
360
 
159
361
  getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
160
362
  const db = getConnection();
161
363
  const normalizedProjectPath = normalizeProjectPath(projectPath);
162
- return db
364
+ const rows = db
163
365
  .prepare(
164
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
366
+ `SELECT ${SESSION_ROW_COLUMNS}
165
367
  FROM sessions
166
368
  WHERE project_path = ?
167
369
  AND isArchived = 0
@@ -169,6 +371,8 @@ export const sessionsDb = {
169
371
  LIMIT ? OFFSET ?`
170
372
  )
171
373
  .all(normalizedProjectPath, limit, offset) as SessionRow[];
374
+
375
+ return normalizeSessionRows(rows);
172
376
  },
173
377
 
174
378
  countSessionsByProjectPath(projectPath: string): number {