@cloudcli-ai/cloudcli 1.36.0 → 1.36.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 (217) hide show
  1. package/README.de.md +17 -18
  2. package/README.ja.md +16 -17
  3. package/README.ko.md +17 -18
  4. package/README.md +19 -20
  5. package/README.ru.md +17 -18
  6. package/README.tr.md +17 -18
  7. package/README.zh-CN.md +17 -18
  8. package/README.zh-TW.md +17 -18
  9. package/dist/api-docs.html +2 -3
  10. package/dist/assets/{index-S6cIy7hj.js → index-Bz9vXV2m.js} +264 -259
  11. package/dist/assets/index-DLmhQ15L.css +32 -0
  12. package/dist/assets/{vendor-xterm-CJZjLICi.js → vendor-xterm-CS4rQ5Mr.js} +12 -12
  13. package/dist/index.html +3 -3
  14. package/dist-server/server/claude-sdk.js +37 -90
  15. package/dist-server/server/claude-sdk.js.map +1 -1
  16. package/dist-server/server/cli.js +1 -4
  17. package/dist-server/server/cli.js.map +1 -1
  18. package/dist-server/server/cursor-cli.js +12 -11
  19. package/dist-server/server/cursor-cli.js.map +1 -1
  20. package/dist-server/server/index.js +10 -141
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/assets/assets.routes.js +89 -0
  23. package/dist-server/server/modules/assets/assets.routes.js.map +1 -0
  24. package/dist-server/server/modules/assets/index.js +4 -0
  25. package/dist-server/server/modules/assets/index.js.map +1 -0
  26. package/dist-server/server/modules/assets/services/image-assets.service.js +57 -0
  27. package/dist-server/server/modules/assets/services/image-assets.service.js.map +1 -0
  28. package/dist-server/server/modules/assets/tests/image-assets.service.test.js +36 -0
  29. package/dist-server/server/modules/assets/tests/image-assets.service.test.js.map +1 -0
  30. package/dist-server/server/modules/browser-use/browser-use.service.js +6 -3
  31. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
  32. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -2
  33. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  34. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  35. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
  36. package/dist-server/server/modules/projects/services/project-clone.service.js +2 -1
  37. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +28 -0
  39. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +54 -1
  41. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +36 -1
  43. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +3 -17
  45. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js +7 -1
  47. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  48. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +49 -13
  49. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +0 -1
  51. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +8 -14
  53. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  54. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +24 -5
  55. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  56. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +10 -21
  57. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  58. package/dist-server/server/modules/providers/provider.registry.js +0 -2
  59. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  60. package/dist-server/server/modules/providers/provider.routes.js +0 -1
  61. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  62. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +7 -14
  63. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  64. package/dist-server/server/modules/providers/services/provider-models.service.js +1 -1
  65. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  66. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +3 -77
  67. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +0 -1
  69. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +0 -13
  71. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +96 -0
  73. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -0
  74. package/dist-server/server/modules/providers/tests/mcp.test.js +3 -29
  75. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  76. package/dist-server/server/modules/providers/tests/opencode-models.test.js +7 -0
  77. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  78. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +117 -0
  79. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  80. package/dist-server/server/modules/providers/tests/provider-image-history.test.js +147 -0
  81. package/dist-server/server/modules/providers/tests/provider-image-history.test.js.map +1 -0
  82. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +8 -8
  83. package/dist-server/server/modules/providers/tests/skills.test.js +3 -26
  84. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  85. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +14 -0
  86. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  87. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +36 -2
  88. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +3 -12
  90. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js +33 -0
  92. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js.map +1 -0
  93. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +37 -4
  94. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  95. package/dist-server/server/openai-codex.js +8 -2
  96. package/dist-server/server/openai-codex.js.map +1 -1
  97. package/dist-server/server/opencode-cli.js +43 -6
  98. package/dist-server/server/opencode-cli.js.map +1 -1
  99. package/dist-server/server/opencode-cli.test.js +103 -3
  100. package/dist-server/server/opencode-cli.test.js.map +1 -1
  101. package/dist-server/server/routes/agent.js +9 -19
  102. package/dist-server/server/routes/agent.js.map +1 -1
  103. package/dist-server/server/routes/commands.js +1 -2
  104. package/dist-server/server/routes/commands.js.map +1 -1
  105. package/dist-server/server/routes/git.js +179 -53
  106. package/dist-server/server/routes/git.js.map +1 -1
  107. package/dist-server/server/routes/git.test.js +80 -0
  108. package/dist-server/server/routes/git.test.js.map +1 -0
  109. package/dist-server/server/routes/taskmaster.js +3 -1
  110. package/dist-server/server/routes/taskmaster.js.map +1 -1
  111. package/dist-server/server/routes/user.js +2 -1
  112. package/dist-server/server/routes/user.js.map +1 -1
  113. package/dist-server/server/shared/image-attachments.js +270 -0
  114. package/dist-server/server/shared/image-attachments.js.map +1 -0
  115. package/dist-server/server/shared/tests/image-attachments.test.js +220 -0
  116. package/dist-server/server/shared/tests/image-attachments.test.js.map +1 -0
  117. package/dist-server/server/shared/utils.js +43 -0
  118. package/dist-server/server/shared/utils.js.map +1 -1
  119. package/dist-server/server/utils/gitConfig.js +2 -1
  120. package/dist-server/server/utils/gitConfig.js.map +1 -1
  121. package/dist-server/server/utils/plugin-process-manager.js +2 -1
  122. package/dist-server/server/utils/plugin-process-manager.js.map +1 -1
  123. package/electron/launcher/launcher.js +1 -1
  124. package/package.json +3 -3
  125. package/public/api-docs.html +2 -3
  126. package/server/claude-sdk.js +37 -105
  127. package/server/cli.js +1 -4
  128. package/server/cursor-cli.js +13 -12
  129. package/server/index.js +11 -159
  130. package/server/modules/assets/assets.routes.ts +103 -0
  131. package/server/modules/assets/index.ts +3 -0
  132. package/server/modules/assets/services/image-assets.service.ts +82 -0
  133. package/server/modules/assets/tests/image-assets.service.test.ts +46 -0
  134. package/server/modules/browser-use/browser-use.service.ts +7 -3
  135. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -2
  136. package/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  137. package/server/modules/projects/services/project-clone.service.ts +3 -1
  138. package/server/modules/providers/README.md +1 -7
  139. package/server/modules/providers/list/claude/claude-sessions.provider.ts +30 -0
  140. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +60 -1
  141. package/server/modules/providers/list/codex/codex-sessions.provider.ts +41 -1
  142. package/server/modules/providers/list/cursor/cursor-models.provider.ts +3 -17
  143. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +7 -1
  144. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +56 -15
  145. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +0 -1
  146. package/server/modules/providers/list/opencode/opencode-models.provider.ts +15 -19
  147. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +25 -4
  148. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +10 -21
  149. package/server/modules/providers/provider.registry.ts +0 -2
  150. package/server/modules/providers/provider.routes.ts +0 -1
  151. package/server/modules/providers/services/provider-capabilities.service.ts +7 -14
  152. package/server/modules/providers/services/provider-models.service.ts +1 -1
  153. package/server/modules/providers/services/session-conversations-search.service.ts +3 -93
  154. package/server/modules/providers/services/session-synchronizer.service.ts +0 -1
  155. package/server/modules/providers/services/sessions-watcher.service.ts +0 -14
  156. package/server/modules/providers/tests/codex-sessions.test.ts +111 -0
  157. package/server/modules/providers/tests/mcp.test.ts +3 -34
  158. package/server/modules/providers/tests/opencode-models.test.ts +7 -0
  159. package/server/modules/providers/tests/opencode-sessions.test.ts +139 -0
  160. package/server/modules/providers/tests/provider-image-history.test.ts +180 -0
  161. package/server/modules/providers/tests/provider-models.service.test.ts +8 -8
  162. package/server/modules/providers/tests/skills.test.ts +3 -42
  163. package/server/modules/websocket/services/chat-run-registry.service.ts +16 -0
  164. package/server/modules/websocket/services/chat-websocket.service.ts +41 -2
  165. package/server/modules/websocket/services/shell-websocket.service.ts +1 -11
  166. package/server/modules/websocket/tests/chat-image-filter.test.ts +44 -0
  167. package/server/modules/websocket/tests/chat-run-registry.test.ts +42 -4
  168. package/server/openai-codex.js +9 -1
  169. package/server/opencode-cli.js +44 -6
  170. package/server/opencode-cli.test.js +112 -3
  171. package/server/routes/agent.js +9 -19
  172. package/server/routes/commands.js +1 -2
  173. package/server/routes/git.js +201 -60
  174. package/server/routes/git.test.js +106 -0
  175. package/server/routes/taskmaster.js +3 -1
  176. package/server/routes/user.js +2 -1
  177. package/server/shared/image-attachments.ts +341 -0
  178. package/server/shared/tests/image-attachments.test.ts +298 -0
  179. package/server/shared/types.ts +1 -1
  180. package/server/shared/utils.ts +45 -0
  181. package/server/utils/gitConfig.js +2 -1
  182. package/server/utils/plugin-process-manager.js +3 -1
  183. package/dist/assets/index-uLUE6Qt1.css +0 -32
  184. package/dist/icons/gemini-ai-icon.svg +0 -1
  185. package/dist-server/server/gemini-cli.js +0 -551
  186. package/dist-server/server/gemini-cli.js.map +0 -1
  187. package/dist-server/server/gemini-response-handler.js +0 -106
  188. package/dist-server/server/gemini-response-handler.js.map +0 -1
  189. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +0 -254
  190. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +0 -1
  191. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js +0 -82
  192. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js.map +0 -1
  193. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +0 -24
  194. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +0 -1
  195. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js +0 -312
  196. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js.map +0 -1
  197. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +0 -474
  198. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +0 -1
  199. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +0 -40
  200. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +0 -1
  201. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +0 -19
  202. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +0 -1
  203. package/dist-server/server/routes/gemini.js +0 -21
  204. package/dist-server/server/routes/gemini.js.map +0 -1
  205. package/dist-server/server/sessionManager.js +0 -194
  206. package/dist-server/server/sessionManager.js.map +0 -1
  207. package/server/gemini-cli.js +0 -638
  208. package/server/gemini-response-handler.js +0 -117
  209. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +0 -307
  210. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +0 -110
  211. package/server/modules/providers/list/gemini/gemini-models.provider.ts +0 -39
  212. package/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +0 -405
  213. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +0 -540
  214. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +0 -44
  215. package/server/modules/providers/list/gemini/gemini.provider.ts +0 -27
  216. package/server/routes/gemini.js +0 -25
  217. package/server/sessionManager.js +0 -226
@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
9
9
  import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
10
10
  import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
11
11
  import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
12
+ import { appendImagesInputTag } from '@/shared/image-attachments.js';
12
13
 
13
14
  const patchHomeDir = (nextHomeDir: string) => {
14
15
  const original = os.homedir;
@@ -321,6 +322,41 @@ test('OpenCode session synchronizer adopts the pending app session before watche
321
322
  }
322
323
  });
323
324
 
325
+ test('OpenCode sessions provider strips <images_input> from user turns and exposes attachments', { concurrency: false }, async () => {
326
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-images-'));
327
+ const workspacePath = path.join(tempRoot, 'workspace');
328
+ await mkdir(workspacePath, { recursive: true });
329
+ const restoreHomeDir = patchHomeDir(tempRoot);
330
+
331
+ try {
332
+ await createOpenCodeDatabase(tempRoot, workspacePath);
333
+
334
+ // Rewrite the user text part with the tagged prompt the runtime sends.
335
+ const taggedPrompt = appendImagesInputTag('Look at this screenshot.', [
336
+ { path: 'C:/Users/x/.cloudcli/assets/shot.png' },
337
+ ]);
338
+ const db = new Database(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db'));
339
+ try {
340
+ db.prepare('UPDATE part SET data = ? WHERE id = ?').run(
341
+ JSON.stringify({ type: 'text', text: taggedPrompt }),
342
+ 'part-user-text',
343
+ );
344
+ } finally {
345
+ db.close();
346
+ }
347
+
348
+ const provider = new OpenCodeSessionsProvider();
349
+ const history = await provider.fetchHistory('open-session-1');
350
+ const userMessage = history.messages.find((message) => message.kind === 'text' && message.role === 'user');
351
+
352
+ assert.equal(userMessage?.content, 'Look at this screenshot.');
353
+ assert.deepEqual(userMessage?.images, [{ path: 'C:/Users/x/.cloudcli/assets/shot.png' }]);
354
+ } finally {
355
+ restoreHomeDir();
356
+ await rm(tempRoot, { recursive: true, force: true });
357
+ }
358
+ });
359
+
324
360
  test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
325
361
  const provider = new OpenCodeSessionsProvider();
326
362
  const normalized = provider.normalizeMessage({
@@ -381,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
381
417
  await rm(tempRoot, { recursive: true, force: true });
382
418
  }
383
419
  });
420
+
421
+ /**
422
+ * Seeds a single OpenCode session with a controllable stored title and first
423
+ * user message. Uses a minimal schema (only the columns the synchronizer reads)
424
+ * with a plain-text user part so the derived name is unambiguous.
425
+ */
426
+ const seedOpenCodeSession = async (
427
+ homeDir: string,
428
+ workspacePath: string,
429
+ options: { sessionId: string; title: string | null; firstUserText: string },
430
+ ): Promise<void> => {
431
+ const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
432
+ await mkdir(dataDir, { recursive: true });
433
+
434
+ const db = new Database(path.join(dataDir, 'opencode.db'));
435
+ try {
436
+ db.exec(`
437
+ CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
438
+ CREATE TABLE session (
439
+ id TEXT PRIMARY KEY,
440
+ project_id TEXT,
441
+ directory TEXT,
442
+ title TEXT,
443
+ time_created INTEGER,
444
+ time_updated INTEGER,
445
+ time_archived INTEGER
446
+ );
447
+ CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
448
+ CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
449
+ `);
450
+
451
+ db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', workspacePath);
452
+ db.prepare(`
453
+ INSERT INTO session (id, project_id, directory, title, time_created, time_updated, time_archived)
454
+ VALUES (?, ?, ?, ?, ?, ?, NULL)
455
+ `).run(options.sessionId, 'project-1', workspacePath, options.title, 1_700_000_000_000, 1_700_000_001_000);
456
+ db.prepare('INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)')
457
+ .run('message-user', options.sessionId, 1_700_000_001_000, JSON.stringify({ role: 'user' }));
458
+ db.prepare('INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)')
459
+ .run(
460
+ 'part-user',
461
+ 'message-user',
462
+ options.sessionId,
463
+ 1_700_000_001_000,
464
+ // OpenCode persists the prompt as a JSON string literal inside the text
465
+ // field, so double-encode it here to exercise the unwrap on read.
466
+ JSON.stringify({ type: 'text', text: JSON.stringify(options.firstUserText) }),
467
+ );
468
+ } finally {
469
+ db.close();
470
+ }
471
+ };
472
+
473
+ test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
474
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-'));
475
+ const workspacePath = path.join(tempRoot, 'workspace');
476
+ await mkdir(workspacePath, { recursive: true });
477
+ const restoreHomeDir = patchHomeDir(tempRoot);
478
+
479
+ try {
480
+ // Stored title differs from the first message so we can prove the first
481
+ // message wins for sessions started from cloudcli.
482
+ await seedOpenCodeSession(tempRoot, workspacePath, {
483
+ sessionId: 'oc-app-1',
484
+ title: 'OpenCode generated title',
485
+ firstUserText: 'Fix the checkout crash',
486
+ });
487
+ await withIsolatedDatabase(async () => {
488
+ sessionsDb.createAppSession('app-1', 'opencode', workspacePath);
489
+ sessionsDb.assignProviderSessionId('app-1', 'oc-app-1');
490
+
491
+ await new OpenCodeSessionSynchronizer().synchronize();
492
+
493
+ assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the checkout crash');
494
+ });
495
+ } finally {
496
+ restoreHomeDir();
497
+ await rm(tempRoot, { recursive: true, force: true });
498
+ }
499
+ });
500
+
501
+ test('OpenCode synchronizer keeps the stored title for indexed sessions', { concurrency: false }, async () => {
502
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-indexed-'));
503
+ const workspacePath = path.join(tempRoot, 'workspace');
504
+ await mkdir(workspacePath, { recursive: true });
505
+ const restoreHomeDir = patchHomeDir(tempRoot);
506
+
507
+ try {
508
+ await seedOpenCodeSession(tempRoot, workspacePath, {
509
+ sessionId: 'oc-indexed-1',
510
+ title: 'OpenCode generated title',
511
+ firstUserText: 'This prompt should be ignored',
512
+ });
513
+ await withIsolatedDatabase(async () => {
514
+ await new OpenCodeSessionSynchronizer().synchronize();
515
+
516
+ assert.equal(sessionsDb.getSessionById('oc-indexed-1')?.custom_name, 'OpenCode generated title');
517
+ });
518
+ } finally {
519
+ restoreHomeDir();
520
+ await rm(tempRoot, { recursive: true, force: true });
521
+ }
522
+ });
@@ -0,0 +1,180 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
5
+ import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js';
6
+ import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
7
+ import { appendImagesInputTag } from '@/shared/image-attachments.js';
8
+
9
+ const SESSION_ID = 'session-1';
10
+
11
+ // ---------------------------------------------------------------- Claude
12
+
13
+ test('claude history: base64 image blocks surface as user message images', () => {
14
+ const provider = new ClaudeSessionsProvider();
15
+ const entry = {
16
+ uuid: 'u1',
17
+ timestamp: '2026-07-03T10:00:00.000Z',
18
+ message: {
19
+ role: 'user',
20
+ content: [
21
+ { type: 'text', text: 'What is in this screenshot?' },
22
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
23
+ { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'REVG' } },
24
+ ],
25
+ },
26
+ };
27
+
28
+ const messages = provider.normalizeMessage(entry, SESSION_ID);
29
+
30
+ assert.equal(messages.length, 1);
31
+ assert.equal(messages[0].kind, 'text');
32
+ assert.equal(messages[0].role, 'user');
33
+ assert.equal(messages[0].content, 'What is in this screenshot?');
34
+ assert.deepEqual(messages[0].images, [
35
+ { data: 'data:image/png;base64,QUJD' },
36
+ { data: 'data:image/jpeg;base64,REVG' },
37
+ ]);
38
+ });
39
+
40
+ test('claude history: image-only user turns still produce a bubble', () => {
41
+ const provider = new ClaudeSessionsProvider();
42
+ const entry = {
43
+ uuid: 'u2',
44
+ timestamp: '2026-07-03T10:00:00.000Z',
45
+ message: {
46
+ role: 'user',
47
+ content: [
48
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
49
+ ],
50
+ },
51
+ };
52
+
53
+ const messages = provider.normalizeMessage(entry, SESSION_ID);
54
+
55
+ assert.equal(messages.length, 1);
56
+ assert.equal(messages[0].role, 'user');
57
+ assert.equal(messages[0].content, '');
58
+ assert.deepEqual(messages[0].images, [{ data: 'data:image/png;base64,QUJD' }]);
59
+ });
60
+
61
+ test('claude history: plain text user turns carry no images field', () => {
62
+ const provider = new ClaudeSessionsProvider();
63
+ const entry = {
64
+ uuid: 'u3',
65
+ timestamp: '2026-07-03T10:00:00.000Z',
66
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
67
+ };
68
+
69
+ const messages = provider.normalizeMessage(entry, SESSION_ID);
70
+ assert.equal(messages.length, 1);
71
+ assert.equal(messages[0].images, undefined);
72
+ });
73
+
74
+ // ---------------------------------------------------------------- Codex
75
+
76
+ test('codex history: user_message payload images become path attachments', () => {
77
+ // Real rollout shape: local_image input items land in `local_images`,
78
+ // while `images` stays an empty array.
79
+ assert.deepEqual(
80
+ extractCodexUserImages({
81
+ type: 'user_message',
82
+ message: 'can u see attached image?',
83
+ images: [],
84
+ local_images: ['C:\\proj\\.cloudcli\\assets\\a.png'],
85
+ }),
86
+ [{ path: 'C:/proj/.cloudcli/assets/a.png' }],
87
+ );
88
+ assert.deepEqual(
89
+ extractCodexUserImages({ type: 'user_message', message: 'hi', images: ['/proj/b.jpg'] }),
90
+ [{ path: '/proj/b.jpg' }],
91
+ );
92
+ assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi' }), undefined);
93
+ assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi', images: [], local_images: [] }), undefined);
94
+ });
95
+
96
+ test('codex history: base64 data URLs pass through as inline data attachments', () => {
97
+ const dataUrl = 'data:image/png;base64,QUJD';
98
+ assert.deepEqual(
99
+ extractCodexUserImages({
100
+ type: 'user_message',
101
+ message: 'look',
102
+ images: [dataUrl],
103
+ local_images: ['C:\\proj\\a.png'],
104
+ }),
105
+ [{ path: 'C:/proj/a.png' }, { data: dataUrl }],
106
+ );
107
+ });
108
+
109
+ test('codex history: normalized user entries keep their images', () => {
110
+ const provider = new CodexSessionsProvider();
111
+ const messages = provider.normalizeMessage(
112
+ {
113
+ timestamp: '2026-07-03T10:00:00.000Z',
114
+ message: { role: 'user', content: 'Look at this' },
115
+ images: [{ path: '.cloudcli/assets/a.png' }],
116
+ },
117
+ SESSION_ID,
118
+ );
119
+
120
+ assert.equal(messages.length, 1);
121
+ assert.equal(messages[0].role, 'user');
122
+ assert.equal(messages[0].content, 'Look at this');
123
+ assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]);
124
+ });
125
+
126
+ // ---------------------------------------------------------------- Cursor
127
+
128
+ test('cursor history: <images_input> inside user_query is stripped and attached', () => {
129
+ const provider = new CursorSessionsProvider();
130
+ const taggedPrompt = appendImagesInputTag('Fix the layout bug', [{ path: '.cloudcli/assets/shot.png' }]);
131
+ const blobs = [
132
+ {
133
+ id: 'blob1',
134
+ sequence: 1,
135
+ rowid: 1,
136
+ content: {
137
+ role: 'user',
138
+ content: `<timestamp>2026-07-03</timestamp>\n<user_query>${taggedPrompt}</user_query>`,
139
+ },
140
+ },
141
+ {
142
+ id: 'blob2',
143
+ sequence: 2,
144
+ rowid: 2,
145
+ content: {
146
+ role: 'assistant',
147
+ content: [{ type: 'text', text: 'Done — the flex container was wrong.' }],
148
+ },
149
+ },
150
+ ];
151
+
152
+ const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
153
+
154
+ assert.equal(messages.length, 2);
155
+ assert.equal(messages[0].role, 'user');
156
+ assert.equal(messages[0].content, 'Fix the layout bug');
157
+ assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/shot.png' }]);
158
+ assert.equal(messages[1].role, 'assistant');
159
+ assert.equal(messages[1].images, undefined);
160
+ });
161
+
162
+ test('cursor history: user text without a tag keeps existing behavior', () => {
163
+ const provider = new CursorSessionsProvider();
164
+ const blobs = [
165
+ {
166
+ id: 'blob1',
167
+ sequence: 1,
168
+ rowid: 1,
169
+ content: {
170
+ role: 'user',
171
+ content: '<timestamp>2026-07-03</timestamp>\n<user_query>plain question</user_query>',
172
+ },
173
+ },
174
+ ];
175
+
176
+ const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
177
+ assert.equal(messages.length, 1);
178
+ assert.equal(messages[0].content, 'plain question');
179
+ assert.equal(messages[0].images, undefined);
180
+ });
@@ -170,13 +170,13 @@ test('provider model cache is persisted across service instances', async () => {
170
170
  cachePath,
171
171
  resolveProvider: () => ({
172
172
  models: {
173
- getSupportedModels: async () => createModels('gemini-cached'),
174
- getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
175
- changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
173
+ getSupportedModels: async () => createModels('cursor-cached'),
174
+ getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
175
+ changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
176
176
  },
177
177
  }),
178
178
  });
179
- await writer.getProviderModels('gemini');
179
+ await writer.getProviderModels('cursor');
180
180
 
181
181
  const reader = createProviderModelsService({
182
182
  cachePath,
@@ -185,13 +185,13 @@ test('provider model cache is persisted across service instances', async () => {
185
185
  getSupportedModels: async () => {
186
186
  throw new Error('loader should not be called for persisted cache hits');
187
187
  },
188
- getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
189
- changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
188
+ getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
189
+ changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
190
190
  },
191
191
  }),
192
192
  });
193
- const models = await reader.getProviderModels('gemini');
194
- assert.equal(models.models.DEFAULT, 'gemini-cached');
193
+ const models = await reader.getProviderModels('cursor');
194
+ assert.equal(models.models.DEFAULT, 'cursor-cached');
195
195
  assert.equal(models.cache.source, 'disk');
196
196
  } finally {
197
197
  await rm(tempRoot, { recursive: true, force: true });
@@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills
444
444
  });
445
445
 
446
446
  /**
447
- * This test covers Gemini and Cursor skill directory rules, including shared
447
+ * This test covers Cursor skill directory rules, including shared
448
448
  * `.agents/skills` project support.
449
449
  */
450
- test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
450
+ test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => {
451
451
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
452
452
  const workspacePath = path.join(tempRoot, 'workspace');
453
453
  await fs.mkdir(workspacePath, { recursive: true });
454
454
 
455
455
  const restoreHomeDir = patchHomeDir(tempRoot);
456
456
  try {
457
- await writeSkill(
458
- path.join(tempRoot, '.gemini', 'skills'),
459
- 'gemini-user-dir',
460
- 'gemini-user',
461
- 'Gemini user skill',
462
- );
463
457
  await writeSkill(
464
458
  path.join(tempRoot, '.agents', 'skills'),
465
459
  'agents-user-dir',
466
460
  'agents-user',
467
461
  'Agents user skill',
468
462
  );
469
- await writeSkill(
470
- path.join(workspacePath, '.gemini', 'skills'),
471
- 'gemini-project-dir',
472
- 'gemini-project',
473
- 'Gemini project skill',
474
- );
475
463
  await writeSkill(
476
464
  path.join(workspacePath, '.agents', 'skills'),
477
465
  'agents-project-dir',
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
491
479
  'Cursor user skill',
492
480
  );
493
481
 
494
- const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
495
- const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
496
- assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
497
- assert.equal(geminiByName.get('agents-user')?.scope, 'user');
498
- assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
499
- assert.equal(geminiByName.get('agents-project')?.scope, 'project');
500
- assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
501
-
502
482
  const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
503
483
  const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
504
484
  assert.equal(cursorByName.get('agents-project')?.scope, 'project');
@@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured
515
495
  * This test covers managed global skill creation for providers that own a
516
496
  * writable user skill directory.
517
497
  */
518
- test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
498
+ test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => {
519
499
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
520
500
  const restoreHomeDir = patchHomeDir(tempRoot);
521
501
 
@@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
618
598
  );
619
599
  await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
620
600
 
621
- const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
622
- entries: [
623
- {
624
- directoryName: 'gemini-global-dir',
625
- content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
626
- },
627
- ],
628
- });
629
- const createdGeminiSkill = createdGeminiSkills[0];
630
- assert.ok(createdGeminiSkill);
631
- assert.equal(createdGeminiSkill.command, '/gemini-global');
632
- assert.equal(
633
- createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
634
- true,
635
- );
636
-
637
601
  const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
638
602
  entries: [
639
603
  {
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
656
620
  const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
657
621
  assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
658
622
 
659
- const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
660
- assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
661
-
662
623
  const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
663
624
  assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
664
625
 
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
318
318
  run.writer.sendComplete(opts);
319
319
  },
320
320
 
321
+ /**
322
+ * Safety-net variant of `completeRun` scoped to one specific run: a no-op
323
+ * unless `run` is still the session's current, running run. A runtime
324
+ * promise can resolve after its own `complete` already streamed AND a new
325
+ * run has replaced it in the registry (a queued message sends within
326
+ * milliseconds of the previous turn ending) — the session-keyed
327
+ * `completeRun` would terminate that newer run.
328
+ */
329
+ completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
330
+ if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
331
+ return;
332
+ }
333
+
334
+ run.writer.sendComplete(opts);
335
+ },
336
+
321
337
  /**
322
338
  * Test-only escape hatch: clears every tracked run.
323
339
  */
@@ -1,8 +1,11 @@
1
+ import path from 'node:path';
2
+
1
3
  import type { WebSocket } from 'ws';
2
4
 
3
5
  import { sessionsDb } from '@/modules/database/index.js';
4
6
  import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
5
7
  import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
8
+ import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js';
6
9
  import type {
7
10
  AnyRecord,
8
11
  AuthenticatedWebSocketRequest,
@@ -10,6 +13,37 @@ import type {
10
13
  } from '@/shared/types.js';
11
14
  import { parseIncomingJsonObject } from '@/shared/utils.js';
12
15
 
16
+ /**
17
+ * Trust boundary for client-supplied image attachments: chat.send options come
18
+ * straight from the browser, and the provider runtimes read the referenced
19
+ * files off disk (Claude base64-encodes them into the prompt). Only images
20
+ * that live directly inside the global upload store (`~/.cloudcli/assets`,
21
+ * where POST /api/assets/images puts them) are allowed through — anything
22
+ * else (absolute paths elsewhere, traversal, subdirectories) is dropped.
23
+ *
24
+ * Exported for tests; `assetsRootOverride` exists only for them.
25
+ */
26
+ export function filterImagesToUploadStore(images: unknown, assetsRootOverride?: string): AnyRecord[] {
27
+ const assetsRoot = path.resolve(assetsRootOverride ?? getGlobalImageAssetsDir());
28
+
29
+ return normalizeImageDescriptors(images).filter((descriptor) => {
30
+ // Relative paths are anchored in the store; absolute ones must already be in it.
31
+ const resolved = path.resolve(assetsRoot, descriptor.path);
32
+ const relative = path.relative(assetsRoot, resolved);
33
+ const isDirectChild =
34
+ relative.length > 0 &&
35
+ !relative.startsWith('..') &&
36
+ !path.isAbsolute(relative) &&
37
+ !relative.includes(path.sep) &&
38
+ !relative.includes('/');
39
+
40
+ if (!isDirectChild) {
41
+ console.warn(`[Chat] Dropping image outside the upload store: ${descriptor.path}`);
42
+ }
43
+ return isDirectChild;
44
+ });
45
+ }
46
+
13
47
  /**
14
48
  * One provider runtime entry point. All five runtimes share this signature,
15
49
  * which lets the chat handler dispatch through a provider-keyed map instead
@@ -161,6 +195,9 @@ async function handleChatSend(
161
195
  // gateway writer captures and maps back to the app session id.
162
196
  const runtimeOptions: AnyRecord = {
163
197
  ...clientOptions,
198
+ // Image attachments are re-validated server-side: only files inside the
199
+ // global upload store may reach the provider runtimes' file reads.
200
+ images: filterImagesToUploadStore(clientOptions.images),
164
201
  sessionId: session.provider_session_id ?? undefined,
165
202
  resume: Boolean(session.provider_session_id),
166
203
  cwd: clientOptions.cwd ?? session.project_path ?? undefined,
@@ -175,8 +212,10 @@ async function handleChatSend(
175
212
  } finally {
176
213
  // Safety net: a runtime that crashed (or resolved) without emitting its
177
214
  // terminal `complete` would otherwise leave the session stuck in
178
- // "processing" forever on every connected client.
179
- chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
215
+ // "processing" forever on every connected client. Scoped to THIS run —
216
+ // a queued message can start the session's next run before this promise
217
+ // settles, and the session-keyed completeRun would kill that new run.
218
+ chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
180
219
  }
181
220
  }
182
221
 
@@ -146,14 +146,6 @@ function buildShellCommand(
146
146
  return 'codex';
147
147
  }
148
148
 
149
- if (provider === 'gemini') {
150
- const command = initialCommand || 'gemini';
151
- if (resumeSessionId) {
152
- return `${command} --resume "${resumeSessionId}"`;
153
- }
154
- return command;
155
- }
156
-
157
149
  if (provider === 'opencode') {
158
150
  if (resumeSessionId) {
159
151
  return `opencode --session "${resumeSessionId}"`;
@@ -477,9 +469,7 @@ export function handleShellConnection(
477
469
  ? 'Cursor'
478
470
  : provider === 'codex'
479
471
  ? 'Codex'
480
- : provider === 'gemini'
481
- ? 'Gemini'
482
- : provider === 'opencode'
472
+ : provider === 'opencode'
483
473
  ? 'OpenCode'
484
474
  : 'Claude';
485
475
  welcomeMsg = hasSession && resumeSessionId
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ import { filterImagesToUploadStore } from '@/modules/websocket/services/chat-websocket.service.js';
7
+
8
+ const STORE = path.join(os.tmpdir(), 'cloudcli-assets-store');
9
+
10
+ test('images inside the upload store pass through', () => {
11
+ const inside = path.join(STORE, 'shot.png');
12
+ const result = filterImagesToUploadStore(
13
+ [{ path: inside, name: 'shot.png', mimeType: 'image/png' }],
14
+ STORE,
15
+ );
16
+ assert.equal(result.length, 1);
17
+ assert.equal(result[0].path, inside);
18
+ });
19
+
20
+ test('bare filenames are anchored inside the store', () => {
21
+ const result = filterImagesToUploadStore(['shot.png'], STORE);
22
+ assert.equal(result.length, 1);
23
+ });
24
+
25
+ test('paths outside the store, traversal, and subdirs are dropped', () => {
26
+ const result = filterImagesToUploadStore(
27
+ [
28
+ { path: 'C:/Users/victim/.ssh/id_rsa' },
29
+ { path: '/etc/passwd' },
30
+ { path: '../outside.png' },
31
+ { path: path.join(STORE, '..', 'escaped.png') },
32
+ { path: path.join(STORE, 'nested', 'deep.png') },
33
+ { path: STORE }, // the store folder itself is not a file
34
+ ],
35
+ STORE,
36
+ );
37
+ assert.deepEqual(result, []);
38
+ });
39
+
40
+ test('malformed payloads yield no images', () => {
41
+ assert.deepEqual(filterImagesToUploadStore(undefined, STORE), []);
42
+ assert.deepEqual(filterImagesToUploadStore('nope', STORE), []);
43
+ assert.deepEqual(filterImagesToUploadStore([{ name: 'no-path' }, 42], STORE), []);
44
+ });