@cloudcli-ai/cloudcli 1.31.5 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/assets/index-AaSE7-3t.js +1336 -0
  2. package/dist/assets/index-CKaku72N.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/claude-sdk.js +10 -5
  5. package/dist-server/server/claude-sdk.js.map +1 -1
  6. package/dist-server/server/cursor-cli.js +0 -6
  7. package/dist-server/server/cursor-cli.js.map +1 -1
  8. package/dist-server/server/gemini-cli.js +160 -34
  9. package/dist-server/server/gemini-cli.js.map +1 -1
  10. package/dist-server/server/modules/database/migrations.js +11 -0
  11. package/dist-server/server/modules/database/migrations.js.map +1 -1
  12. package/dist-server/server/modules/database/repositories/projects.db.js +12 -0
  13. package/dist-server/server/modules/database/repositories/projects.db.js.map +1 -1
  14. package/dist-server/server/modules/database/repositories/sessions.db.integration.test.js +61 -0
  15. package/dist-server/server/modules/database/repositories/sessions.db.integration.test.js.map +1 -0
  16. package/dist-server/server/modules/database/repositories/sessions.db.js +49 -8
  17. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  18. package/dist-server/server/modules/database/schema.js +1 -0
  19. package/dist-server/server/modules/database/schema.js.map +1 -1
  20. package/dist-server/server/modules/projects/projects.routes.js +12 -3
  21. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  22. package/dist-server/server/modules/projects/services/project-delete.service.js +14 -1
  23. package/dist-server/server/modules/projects/services/project-delete.service.js.map +1 -1
  24. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +43 -0
  25. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  26. package/dist-server/server/modules/providers/index.js +1 -0
  27. package/dist-server/server/modules/providers/index.js.map +1 -1
  28. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +2 -1
  29. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +55 -2
  31. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +153 -14
  33. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +198 -0
  35. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -0
  36. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  37. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +54 -2
  39. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +21 -6
  41. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +82 -0
  43. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -0
  44. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  45. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js +8 -30
  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 +189 -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/cursor/cursor-skills.provider.js +28 -0
  51. package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js.map +1 -0
  52. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  53. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  54. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +125 -2
  55. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +1 -1
  56. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js +24 -8
  57. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js.map +1 -1
  58. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +7 -1
  59. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  60. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +33 -0
  61. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  63. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  64. package/dist-server/server/modules/providers/provider.routes.js +23 -2
  65. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  66. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +129 -13
  67. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -5
  69. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/sessions.service.js +81 -7
  71. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/services/skills.service.js +11 -0
  73. package/dist-server/server/modules/providers/services/skills.service.js.map +1 -0
  74. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  75. package/dist-server/server/modules/providers/shared/skills/skills.provider.js +45 -0
  76. package/dist-server/server/modules/providers/shared/skills/skills.provider.js.map +1 -0
  77. package/dist-server/server/modules/providers/tests/skills.test.js +226 -0
  78. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -0
  79. package/dist-server/server/openai-codex.js +56 -26
  80. package/dist-server/server/openai-codex.js.map +1 -1
  81. package/dist-server/server/routes/commands.js +5 -5
  82. package/dist-server/server/routes/commands.js.map +1 -1
  83. package/dist-server/server/shared/claude-cli-path.js +103 -0
  84. package/dist-server/server/shared/claude-cli-path.js.map +1 -0
  85. package/dist-server/server/shared/claude-cli-path.test.js +45 -0
  86. package/dist-server/server/shared/claude-cli-path.test.js.map +1 -0
  87. package/dist-server/server/{utils → shared}/frontmatter.js +3 -3
  88. package/dist-server/server/shared/frontmatter.js.map +1 -0
  89. package/dist-server/server/shared/utils.js +82 -0
  90. package/dist-server/server/shared/utils.js.map +1 -1
  91. package/dist-server/server/utils/commandParser.js +3 -3
  92. package/dist-server/server/utils/commandParser.js.map +1 -1
  93. package/dist-server/shared/modelConstants.js +1 -0
  94. package/dist-server/shared/modelConstants.js.map +1 -1
  95. package/package.json +1 -1
  96. package/server/claude-sdk.js +10 -5
  97. package/server/cursor-cli.js +0 -8
  98. package/server/gemini-cli.js +190 -42
  99. package/server/modules/database/migrations.ts +12 -0
  100. package/server/modules/database/repositories/projects.db.ts +13 -0
  101. package/server/modules/database/repositories/sessions.db.integration.test.ts +72 -0
  102. package/server/modules/database/repositories/sessions.db.ts +60 -9
  103. package/server/modules/database/schema.ts +1 -0
  104. package/server/modules/projects/projects.routes.ts +20 -3
  105. package/server/modules/projects/services/project-delete.service.ts +16 -1
  106. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +64 -0
  107. package/server/modules/providers/README.md +346 -0
  108. package/server/modules/providers/index.ts +2 -1
  109. package/server/modules/providers/list/claude/claude-auth.provider.ts +8 -7
  110. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +68 -2
  111. package/server/modules/providers/list/claude/claude-sessions.provider.ts +175 -14
  112. package/server/modules/providers/list/claude/claude-skills.provider.ts +257 -0
  113. package/server/modules/providers/list/claude/claude.provider.ts +8 -1
  114. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +62 -2
  115. package/server/modules/providers/list/codex/codex-sessions.provider.ts +25 -6
  116. package/server/modules/providers/list/codex/codex-skills.provider.ts +100 -0
  117. package/server/modules/providers/list/codex/codex.provider.ts +8 -1
  118. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +17 -40
  119. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +216 -13
  120. package/server/modules/providers/list/cursor/cursor-skills.provider.ts +31 -0
  121. package/server/modules/providers/list/cursor/cursor.provider.ts +8 -1
  122. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +158 -2
  123. package/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +24 -20
  124. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +7 -1
  125. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +36 -0
  126. package/server/modules/providers/list/gemini/gemini.provider.ts +8 -1
  127. package/server/modules/providers/provider.routes.ts +35 -2
  128. package/server/modules/providers/services/session-conversations-search.service.ts +162 -13
  129. package/server/modules/providers/services/sessions-watcher.service.ts +7 -5
  130. package/server/modules/providers/services/sessions.service.ts +112 -9
  131. package/server/modules/providers/services/skills.service.ts +15 -0
  132. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  133. package/server/modules/providers/shared/skills/skills.provider.ts +64 -0
  134. package/server/modules/providers/tests/skills.test.ts +446 -0
  135. package/server/openai-codex.js +59 -27
  136. package/server/routes/commands.js +7 -5
  137. package/server/shared/claude-cli-path.test.ts +61 -0
  138. package/server/shared/claude-cli-path.ts +139 -0
  139. package/server/{utils/frontmatter.js → shared/frontmatter.ts} +3 -3
  140. package/server/shared/interfaces.ts +19 -0
  141. package/server/shared/types.ts +78 -0
  142. package/server/shared/utils.ts +94 -0
  143. package/server/utils/commandParser.js +5 -3
  144. package/shared/modelConstants.js +1 -0
  145. package/dist/assets/index-6rYG_mRo.css +0 -32
  146. package/dist/assets/index-DqMVUeZS.js +0 -1331
  147. package/dist-server/server/utils/frontmatter.js.map +0 -1
@@ -1,19 +1,123 @@
1
1
  import { spawn } from 'child_process';
2
- import crossSpawn from 'cross-spawn';
3
-
4
- // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
5
- const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
6
2
  import { promises as fs } from 'fs';
7
- import path from 'path';
8
3
  import os from 'os';
4
+ import path from 'path';
5
+
6
+ import crossSpawn from 'cross-spawn';
7
+
9
8
  import sessionManager from './sessionManager.js';
10
9
  import GeminiResponseHandler from './gemini-response-handler.js';
11
10
  import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
12
11
  import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
13
12
  import { createNormalizedMessage } from './shared/utils.js';
14
13
 
14
+ // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
15
+ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
16
+
15
17
  let activeGeminiProcesses = new Map(); // Track active processes by session ID
16
18
 
19
+ function mapGeminiExitCodeToMessage(exitCode) {
20
+ switch (exitCode) {
21
+ case 42:
22
+ return 'Gemini rejected the request input (exit code 42).';
23
+ case 44:
24
+ return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.';
25
+ case 52:
26
+ return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.';
27
+ case 53:
28
+ return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.';
29
+ default:
30
+ return null;
31
+ }
32
+ }
33
+
34
+ const GEMINI_AUTH_ENV_KEYS = [
35
+ 'GEMINI_API_KEY',
36
+ 'GOOGLE_API_KEY',
37
+ 'GOOGLE_CLOUD_PROJECT',
38
+ 'GOOGLE_CLOUD_PROJECT_ID',
39
+ 'GOOGLE_CLOUD_LOCATION',
40
+ 'GOOGLE_APPLICATION_CREDENTIALS'
41
+ ];
42
+
43
+ function parseEnvFileContent(content) {
44
+ const parsed = {};
45
+
46
+ for (const rawLine of content.split(/\r?\n/)) {
47
+ const line = rawLine.trim();
48
+ if (!line || line.startsWith('#')) {
49
+ continue;
50
+ }
51
+
52
+ const exportPrefix = 'export ';
53
+ const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line;
54
+ const separatorIndex = normalizedLine.indexOf('=');
55
+
56
+ if (separatorIndex <= 0) {
57
+ continue;
58
+ }
59
+
60
+ const key = normalizedLine.slice(0, separatorIndex).trim();
61
+ if (!key) {
62
+ continue;
63
+ }
64
+
65
+ let value = normalizedLine.slice(separatorIndex + 1).trim();
66
+ const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"');
67
+ const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\'');
68
+
69
+ if (hasDoubleQuotes || hasSingleQuotes) {
70
+ value = value.slice(1, -1);
71
+ } else {
72
+ // Support inline comments in unquoted values: KEY=value # comment
73
+ value = value.replace(/\s+#.*$/, '').trim();
74
+ }
75
+
76
+ parsed[key] = value;
77
+ }
78
+
79
+ return parsed;
80
+ }
81
+
82
+ async function loadGeminiUserLevelEnv() {
83
+ const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir();
84
+ const envCandidates = [
85
+ path.join(geminiCliHome, '.gemini', '.env'),
86
+ path.join(geminiCliHome, '.env')
87
+ ];
88
+
89
+ for (const envPath of envCandidates) {
90
+ try {
91
+ await fs.access(envPath);
92
+ const content = await fs.readFile(envPath, 'utf8');
93
+ return parseEnvFileContent(content);
94
+ } catch {
95
+ // Keep scanning for the next candidate.
96
+ }
97
+ }
98
+
99
+ return {};
100
+ }
101
+
102
+ async function buildGeminiProcessEnv() {
103
+ const processEnv = { ...process.env };
104
+ if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) {
105
+ return processEnv;
106
+ }
107
+
108
+ // Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings.
109
+ // When the server process was launched without shell profile variables, we still
110
+ // want the spawned CLI process to inherit those user-level credentials.
111
+ const userEnv = await loadGeminiUserLevelEnv();
112
+ for (const key of GEMINI_AUTH_ENV_KEYS) {
113
+ if (!processEnv[key] && userEnv[key]) {
114
+ processEnv[key] = userEnv[key];
115
+ }
116
+ }
117
+
118
+ return processEnv;
119
+ }
120
+
17
121
  async function spawnGemini(command, options = {}, ws) {
18
122
  const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
19
123
  let capturedSessionId = sessionId; // Track session ID throughout the process
@@ -100,6 +204,11 @@ async function spawnGemini(command, options = {}, ws) {
100
204
  args.push('--debug');
101
205
  }
102
206
 
207
+ // This integration runs Gemini in headless mode and cannot answer trust prompts.
208
+ // Skip folder-trust interactivity so authenticated runs don't fail with
209
+ // FatalUntrustedWorkspaceError in previously unseen directories.
210
+ args.push('--skip-trust');
211
+
103
212
  // Add MCP config flag only if MCP servers are configured
104
213
  try {
105
214
  const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
@@ -154,9 +263,6 @@ async function spawnGemini(command, options = {}, ws) {
154
263
 
155
264
  // Try to find gemini in PATH first, then fall back to environment variable
156
265
  const geminiPath = process.env.GEMINI_PATH || 'gemini';
157
- console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
158
- console.log('Working directory:', workingDir);
159
-
160
266
  let spawnCmd = geminiPath;
161
267
  let spawnArgs = args;
162
268
 
@@ -168,11 +274,13 @@ async function spawnGemini(command, options = {}, ws) {
168
274
  spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
169
275
  }
170
276
 
277
+ const spawnEnv = await buildGeminiProcessEnv();
278
+
171
279
  return new Promise((resolve, reject) => {
172
280
  const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
173
281
  cwd: workingDir,
174
282
  stdio: ['pipe', 'pipe', 'pipe'],
175
- env: { ...process.env } // Inherit all environment variables
283
+ env: spawnEnv
176
284
  });
177
285
  let terminalNotificationSent = false;
178
286
  let terminalFailureReason = null;
@@ -276,13 +384,44 @@ async function spawnGemini(command, options = {}, ws) {
276
384
  }
277
385
  },
278
386
  onInit: (event) => {
279
- if (capturedSessionId) {
280
- const sess = sessionManager.getSession(capturedSessionId);
281
- if (sess && !sess.cliSessionId) {
282
- sess.cliSessionId = event.session_id;
283
- sessionManager.saveSession(capturedSessionId);
387
+ const discoveredSessionId = event?.session_id;
388
+ if (!discoveredSessionId) {
389
+ return;
390
+ }
391
+
392
+ // New Gemini sessions announce their canonical ID asynchronously via the
393
+ // initial `init` stream event. Avoid synthetic IDs and only register
394
+ // the session once that real ID is known (same model used by Claude/Codex).
395
+ if (!capturedSessionId) {
396
+ capturedSessionId = discoveredSessionId;
397
+
398
+ sessionManager.createSession(capturedSessionId, cwd || process.cwd());
399
+ if (command) {
400
+ sessionManager.addMessage(capturedSessionId, 'user', command);
401
+ }
402
+
403
+ if (processKey !== capturedSessionId) {
404
+ activeGeminiProcesses.delete(processKey);
405
+ activeGeminiProcesses.set(capturedSessionId, geminiProcess);
406
+ }
407
+
408
+ geminiProcess.sessionId = capturedSessionId;
409
+
410
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
411
+ ws.setSessionId(capturedSessionId);
412
+ }
413
+
414
+ if (!sessionId && !sessionCreatedSent) {
415
+ sessionCreatedSent = true;
416
+ ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
284
417
  }
285
418
  }
419
+
420
+ const sess = sessionManager.getSession(capturedSessionId);
421
+ if (sess && !sess.cliSessionId) {
422
+ sess.cliSessionId = discoveredSessionId;
423
+ sessionManager.saveSession(capturedSessionId);
424
+ }
286
425
  }
287
426
  });
288
427
  }
@@ -292,30 +431,6 @@ async function spawnGemini(command, options = {}, ws) {
292
431
  const rawOutput = data.toString();
293
432
  startTimeout(); // Re-arm the timeout
294
433
 
295
- // For new sessions, create a session ID FIRST
296
- if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
297
- capturedSessionId = `gemini_${Date.now()}`;
298
- sessionCreatedSent = true;
299
-
300
- // Create session in session manager
301
- sessionManager.createSession(capturedSessionId, cwd || process.cwd());
302
-
303
- // Save the user message now that we have a session ID
304
- if (command) {
305
- sessionManager.addMessage(capturedSessionId, 'user', command);
306
- }
307
-
308
- // Update process key with captured session ID
309
- if (processKey !== capturedSessionId) {
310
- activeGeminiProcesses.delete(processKey);
311
- activeGeminiProcesses.set(capturedSessionId, geminiProcess);
312
- }
313
-
314
- ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
315
-
316
- ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
317
- }
318
-
319
434
  if (responseHandler) {
320
435
  responseHandler.processData(rawOutput);
321
436
  } else if (rawOutput) {
@@ -381,12 +496,38 @@ async function spawnGemini(command, options = {}, ws) {
381
496
  notifyTerminalState({ code });
382
497
  resolve();
383
498
  } else {
384
- // code 127 = shell "command not found" check installation
499
+ const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
500
+
501
+ // code 127 = shell "command not found" - check installation
385
502
  if (code === 127) {
386
503
  const installed = await providerAuthService.isProviderInstalled('gemini');
387
504
  if (!installed) {
388
- const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
389
- ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
505
+ terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli';
506
+ ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
507
+ }
508
+ } else if (code === 41) {
509
+ // Gemini CLI documents exit code 41 as FatalAuthenticationError.
510
+ // Surface an actionable auth error instead of a generic exit-code message.
511
+ let authErrorSuffix = '';
512
+ try {
513
+ const authStatus = await providerAuthService.getProviderAuthStatus('gemini');
514
+ if (!authStatus?.authenticated && authStatus?.error) {
515
+ authErrorSuffix = ` Details: ${authStatus.error}`;
516
+ }
517
+ } catch {
518
+ // Keep base remediation text when auth status lookup fails.
519
+ }
520
+
521
+ terminalFailureReason =
522
+ 'Gemini authentication failed (exit code 41). '
523
+ + 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.'
524
+ + authErrorSuffix;
525
+ ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
526
+ } else {
527
+ const mappedError = mapGeminiExitCodeToMessage(code);
528
+ if (mappedError) {
529
+ terminalFailureReason = mappedError;
530
+ ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
390
531
  }
391
532
  }
392
533
 
@@ -394,7 +535,14 @@ async function spawnGemini(command, options = {}, ws) {
394
535
  code,
395
536
  error: code === null ? 'Gemini CLI process was terminated or timed out' : null
396
537
  });
397
- reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
538
+ reject(
539
+ new Error(
540
+ terminalFailureReason
541
+ || (code === null
542
+ ? 'Gemini CLI process was terminated or timed out'
543
+ : `Gemini CLI exited with code ${code}`)
544
+ )
545
+ );
398
546
  }
399
547
  });
400
548
 
@@ -257,8 +257,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
257
257
 
258
258
  if (!shouldRebuild) {
259
259
  addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
260
+ addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
260
261
  addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
261
262
  addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
263
+ db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)');
262
264
  db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
263
265
  db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
264
266
  return;
@@ -284,6 +286,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
284
286
  ? 'jsonl_path'
285
287
  : 'NULL';
286
288
 
289
+ const isArchivedExpression = columnNames.includes('isArchived')
290
+ ? 'COALESCE(isArchived, 0)'
291
+ : '0';
292
+
287
293
  const createdAtExpression = columnNames.includes('created_at')
288
294
  ? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
289
295
  : 'CURRENT_TIMESTAMP';
@@ -303,6 +309,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
303
309
  custom_name TEXT,
304
310
  project_path TEXT,
305
311
  jsonl_path TEXT,
312
+ isArchived BOOLEAN DEFAULT 0,
306
313
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
307
314
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
308
315
  PRIMARY KEY (session_id),
@@ -319,6 +326,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
319
326
  ${customNameExpression} AS custom_name,
320
327
  ${projectPathExpression} AS project_path,
321
328
  ${jsonlPathExpression} AS jsonl_path,
329
+ ${isArchivedExpression} AS isArchived,
322
330
  ${createdAtExpression} AS created_at,
323
331
  ${updatedAtExpression} AS updated_at,
324
332
  rowid AS source_rowid
@@ -332,6 +340,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
332
340
  custom_name,
333
341
  project_path,
334
342
  jsonl_path,
343
+ isArchived,
335
344
  created_at,
336
345
  updated_at,
337
346
  ROW_NUMBER() OVER (
@@ -346,6 +355,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
346
355
  custom_name,
347
356
  project_path,
348
357
  jsonl_path,
358
+ isArchived,
349
359
  created_at,
350
360
  updated_at
351
361
  )
@@ -355,6 +365,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
355
365
  custom_name,
356
366
  project_path,
357
367
  jsonl_path,
368
+ isArchived,
358
369
  created_at,
359
370
  updated_at
360
371
  FROM ranked_rows
@@ -421,6 +432,7 @@ export const runMigrations = (db: Database) => {
421
432
 
422
433
  db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
423
434
  db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
435
+ db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
424
436
  db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
425
437
  db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
426
438
 
@@ -95,6 +95,19 @@ export const projectsDb = {
95
95
  `).all() as ProjectRepositoryRow[];
96
96
  },
97
97
 
98
+ /**
99
+ * Archived rows are queried separately so archive-focused UIs can present
100
+ * hidden workspaces without reintroducing them into the active sidebar list.
101
+ */
102
+ getArchivedProjectPaths(): ProjectRepositoryRow[] {
103
+ const db = getConnection();
104
+ return db.prepare(`
105
+ SELECT project_id, project_path, custom_project_name, isStarred, isArchived
106
+ FROM projects
107
+ WHERE isArchived = 1
108
+ `).all() as ProjectRepositoryRow[];
109
+ },
110
+
98
111
  getCustomProjectName(projectPath: string): string | null {
99
112
  const db = getConnection();
100
113
  const normalizedProjectPath = normalizeProjectPath(projectPath);
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { closeConnection } from '@/modules/database/connection.js';
8
+ import { initializeDatabase } from '@/modules/database/init-db.js';
9
+ import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
10
+
11
+ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
12
+ const previousDatabasePath = process.env.DATABASE_PATH;
13
+ const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-db-'));
14
+ const databasePath = path.join(tempDirectory, 'auth.db');
15
+
16
+ closeConnection();
17
+ process.env.DATABASE_PATH = databasePath;
18
+ await initializeDatabase();
19
+
20
+ try {
21
+ await runTest();
22
+ } finally {
23
+ closeConnection();
24
+ if (previousDatabasePath === undefined) {
25
+ delete process.env.DATABASE_PATH;
26
+ } else {
27
+ process.env.DATABASE_PATH = previousDatabasePath;
28
+ }
29
+ await rm(tempDirectory, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ test('session archive queries hide archived rows from active project views', async () => {
34
+ await withIsolatedDatabase(() => {
35
+ sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session');
36
+ sessionsDb.createSession('session-archived', 'claude', '/workspace/demo-project', 'Archived Session');
37
+ sessionsDb.updateSessionIsArchived('session-archived', true);
38
+
39
+ const activeSessions = sessionsDb.getAllSessions();
40
+ const archivedSessions = sessionsDb.getArchivedSessions();
41
+ const activeProjectSessions = sessionsDb.getSessionsByProjectPath('/workspace/demo-project');
42
+ const allProjectSessions = sessionsDb.getSessionsByProjectPathIncludingArchived('/workspace/demo-project');
43
+
44
+ assert.deepEqual(activeSessions.map((session) => session.session_id), ['session-active']);
45
+ assert.deepEqual(archivedSessions.map((session) => session.session_id), ['session-archived']);
46
+ assert.deepEqual(activeProjectSessions.map((session) => session.session_id), ['session-active']);
47
+ assert.deepEqual(
48
+ allProjectSessions.map((session) => session.session_id).sort(),
49
+ ['session-active', 'session-archived'],
50
+ );
51
+ assert.equal(sessionsDb.countSessionsByProjectPath('/workspace/demo-project'), 1);
52
+ });
53
+ });
54
+
55
+ test('createSession reactivates archived rows when the session becomes active again', async () => {
56
+ await withIsolatedDatabase(() => {
57
+ sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'First Name');
58
+ sessionsDb.updateSessionIsArchived('session-reused', true);
59
+
60
+ sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'Updated Name');
61
+
62
+ const activeSessions = sessionsDb.getAllSessions();
63
+ const archivedSessions = sessionsDb.getArchivedSessions();
64
+ const restoredSession = sessionsDb.getSessionById('session-reused');
65
+
66
+ assert.equal(activeSessions.length, 1);
67
+ assert.equal(activeSessions[0]?.session_id, 'session-reused');
68
+ assert.equal(activeSessions[0]?.custom_name, 'Updated Name');
69
+ assert.equal(archivedSessions.length, 0);
70
+ assert.equal(restoredSession?.isArchived, 0);
71
+ });
72
+ });
@@ -8,13 +8,14 @@ type SessionRow = {
8
8
  project_path: string | null;
9
9
  jsonl_path: string | null;
10
10
  custom_name: string | null;
11
+ isArchived: number;
11
12
  created_at: string;
12
13
  updated_at: string;
13
14
  };
14
15
 
15
16
  type SessionMetadataLookupRow = Pick<
16
17
  SessionRow,
17
- 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
18
+ 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
18
19
  >;
19
20
 
20
21
  function normalizeTimestamp(value?: string): string | null {
@@ -53,13 +54,14 @@ export const sessionsDb = {
53
54
  projectsDb.createProjectPath(normalizedProjectPath);
54
55
 
55
56
  db.prepare(
56
- `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at)
57
- VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
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))
58
59
  ON CONFLICT(session_id) DO UPDATE SET
59
60
  provider = excluded.provider,
60
61
  updated_at = excluded.updated_at,
61
62
  project_path = excluded.project_path,
62
63
  jsonl_path = excluded.jsonl_path,
64
+ isArchived = 0,
63
65
  custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
64
66
  ).run(
65
67
  sessionId,
@@ -87,7 +89,7 @@ export const sessionsDb = {
87
89
  const db = getConnection();
88
90
  const row = db
89
91
  .prepare(
90
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
92
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
91
93
  FROM sessions
92
94
  WHERE session_id = ?
93
95
  ORDER BY updated_at DESC
@@ -102,8 +104,25 @@ export const sessionsDb = {
102
104
  const db = getConnection();
103
105
  return db
104
106
  .prepare(
105
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
106
- FROM sessions`
107
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
108
+ FROM sessions
109
+ WHERE isArchived = 0`
110
+ )
111
+ .all() as SessionRow[];
112
+ },
113
+
114
+ /**
115
+ * Archived rows are intentionally queried separately so the caller can render
116
+ * them in a dedicated view without reintroducing them into active session lists.
117
+ */
118
+ getArchivedSessions(): SessionRow[] {
119
+ const db = getConnection();
120
+ return db
121
+ .prepare(
122
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
123
+ FROM sessions
124
+ WHERE isArchived = 1
125
+ ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
107
126
  )
108
127
  .all() as SessionRow[];
109
128
  },
@@ -113,7 +132,24 @@ export const sessionsDb = {
113
132
  const normalizedProjectPath = normalizeProjectPath(projectPath);
114
133
  return db
115
134
  .prepare(
116
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
135
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
136
+ FROM sessions
137
+ WHERE project_path = ?
138
+ AND isArchived = 0`
139
+ )
140
+ .all(normalizedProjectPath) as SessionRow[];
141
+ },
142
+
143
+ /**
144
+ * Permanent project deletion must see every session row for the path,
145
+ * including archived ones, so their transcript files can be cleaned up.
146
+ */
147
+ getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
148
+ const db = getConnection();
149
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
150
+ return db
151
+ .prepare(
152
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
117
153
  FROM sessions
118
154
  WHERE project_path = ?`
119
155
  )
@@ -125,9 +161,10 @@ export const sessionsDb = {
125
161
  const normalizedProjectPath = normalizeProjectPath(projectPath);
126
162
  return db
127
163
  .prepare(
128
- `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
164
+ `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
129
165
  FROM sessions
130
166
  WHERE project_path = ?
167
+ AND isArchived = 0
131
168
  ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
132
169
  LIMIT ? OFFSET ?`
133
170
  )
@@ -141,7 +178,8 @@ export const sessionsDb = {
141
178
  .prepare(
142
179
  `SELECT COUNT(*) AS count
143
180
  FROM sessions
144
- WHERE project_path = ?`
181
+ WHERE project_path = ?
182
+ AND isArchived = 0`
145
183
  )
146
184
  .get(normalizedProjectPath) as { count: number } | undefined;
147
185
 
@@ -167,6 +205,19 @@ export const sessionsDb = {
167
205
  return row?.custom_name ?? null;
168
206
  },
169
207
 
208
+ /**
209
+ * Soft-delete and restore both use the same flag update so callers keep the
210
+ * row, metadata, and file path intact while toggling visibility.
211
+ */
212
+ updateSessionIsArchived(sessionId: string, isArchived: boolean): void {
213
+ const db = getConnection();
214
+ db.prepare(
215
+ `UPDATE sessions
216
+ SET isArchived = ?
217
+ WHERE session_id = ?`
218
+ ).run(isArchived ? 1 : 0, sessionId);
219
+ },
220
+
170
221
  deleteSessionById(sessionId: string): boolean {
171
222
  const db = getConnection();
172
223
  return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS sessions (
86
86
  custom_name TEXT,
87
87
  project_path TEXT,
88
88
  jsonl_path TEXT,
89
+ isArchived BOOLEAN DEFAULT 0,
89
90
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
90
91
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
92
  PRIMARY KEY (session_id),
@@ -3,9 +3,9 @@ import express from 'express';
3
3
  import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
4
4
  import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
5
5
  import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
6
- import { AppError, asyncHandler } from '@/shared/utils.js';
7
- import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
8
- import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js';
6
+ import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
7
+ import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
8
+ import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
9
9
  import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
10
10
 
11
11
  const router = express.Router();
@@ -73,6 +73,14 @@ router.get(
73
73
  }),
74
74
  );
75
75
 
76
+ router.get(
77
+ '/archived',
78
+ asyncHandler(async (_req, res) => {
79
+ const projects = await getArchivedProjectsWithSessions();
80
+ res.json(createApiSuccessResponse({ projects }));
81
+ }),
82
+ );
83
+
76
84
  router.get(
77
85
  '/:projectId/sessions',
78
86
  asyncHandler(async (req, res) => {
@@ -230,6 +238,15 @@ router.post(
230
238
  }),
231
239
  );
232
240
 
241
+ router.post(
242
+ '/:projectId/restore',
243
+ asyncHandler(async (req, res) => {
244
+ const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
245
+ restoreArchivedProject(projectId);
246
+ res.json(createApiSuccessResponse({ projectId, isArchived: false }));
247
+ }),
248
+ );
249
+
233
250
  /**
234
251
  * - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list).
235
252
  * - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir.