@cloudcli-ai/cloudcli 1.32.0 → 1.33.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 (174) hide show
  1. package/README.de.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.tr.md +1 -1
  6. package/README.zh-CN.md +1 -1
  7. package/dist/api-docs.html +6 -7
  8. package/dist/assets/index-MOIvpy53.css +32 -0
  9. package/dist/assets/index-skXSjBje.js +1351 -0
  10. package/dist/index.html +2 -2
  11. package/dist/modelConstants.js +841 -0
  12. package/dist-server/server/claude-sdk.js +57 -34
  13. package/dist-server/server/claude-sdk.js.map +1 -1
  14. package/dist-server/server/cursor-cli.js +6 -3
  15. package/dist-server/server/cursor-cli.js.map +1 -1
  16. package/dist-server/server/gemini-cli.js +3 -1
  17. package/dist-server/server/gemini-cli.js.map +1 -1
  18. package/dist-server/server/gemini-response-handler.js +34 -0
  19. package/dist-server/server/gemini-response-handler.js.map +1 -1
  20. package/dist-server/server/index.js +131 -19
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/database/index.js +1 -0
  23. package/dist-server/server/modules/database/index.js.map +1 -1
  24. package/dist-server/server/modules/projects/services/project-management.service.js +1 -0
  25. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  26. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +4 -0
  27. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  28. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +25 -6
  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-models.provider.js +141 -0
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
  32. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  33. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
  36. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
  37. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  39. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
  42. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
  43. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  45. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
  47. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
  48. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
  49. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  51. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
  53. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
  54. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
  55. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
  56. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
  57. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
  58. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
  59. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
  60. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
  61. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
  63. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
  64. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
  65. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
  66. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  67. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  68. package/dist-server/server/modules/providers/provider.routes.js +42 -1
  69. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
  71. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/services/provider-models.service.js +199 -0
  73. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
  74. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
  75. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
  77. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  78. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  79. package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
  80. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  81. package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
  82. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
  83. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +262 -0
  84. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
  85. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +270 -0
  86. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
  87. package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
  88. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +12 -1
  90. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js +4 -4
  92. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js.map +1 -1
  93. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +9 -1
  94. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  95. package/dist-server/server/openai-codex.js +32 -4
  96. package/dist-server/server/openai-codex.js.map +1 -1
  97. package/dist-server/server/opencode-cli.js +287 -0
  98. package/dist-server/server/opencode-cli.js.map +1 -0
  99. package/dist-server/server/opencode-cli.test.js +84 -0
  100. package/dist-server/server/opencode-cli.test.js.map +1 -0
  101. package/dist-server/server/routes/agent.js +21 -8
  102. package/dist-server/server/routes/agent.js.map +1 -1
  103. package/dist-server/server/routes/commands.js +202 -209
  104. package/dist-server/server/routes/commands.js.map +1 -1
  105. package/dist-server/server/routes/cursor.js +2 -2
  106. package/dist-server/server/routes/cursor.js.map +1 -1
  107. package/dist-server/server/routes/settings.js +0 -10
  108. package/dist-server/server/routes/settings.js.map +1 -1
  109. package/dist-server/server/routes/tests/commands.test.js +76 -0
  110. package/dist-server/server/routes/tests/commands.test.js.map +1 -0
  111. package/dist-server/server/shared/utils.js +286 -0
  112. package/dist-server/server/shared/utils.js.map +1 -1
  113. package/package.json +3 -1
  114. package/public/api-docs.html +878 -0
  115. package/public/modelConstants.js +841 -0
  116. package/server/claude-sdk.js +64 -35
  117. package/server/cursor-cli.js +6 -3
  118. package/server/gemini-cli.js +7 -1
  119. package/server/gemini-response-handler.js +38 -0
  120. package/server/index.js +144 -19
  121. package/server/modules/database/index.ts +1 -0
  122. package/server/modules/projects/services/project-management.service.ts +2 -0
  123. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
  124. package/server/modules/providers/README.md +11 -3
  125. package/server/modules/providers/list/claude/claude-auth.provider.ts +30 -6
  126. package/server/modules/providers/list/claude/claude-models.provider.ts +191 -0
  127. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  128. package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
  129. package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
  130. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  131. package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
  132. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
  133. package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
  134. package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
  135. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
  136. package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
  137. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
  138. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
  139. package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
  140. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +157 -0
  141. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
  142. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
  143. package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
  144. package/server/modules/providers/provider.registry.ts +2 -0
  145. package/server/modules/providers/provider.routes.ts +62 -2
  146. package/server/modules/providers/services/mcp.service.ts +1 -12
  147. package/server/modules/providers/services/provider-models.service.ts +325 -0
  148. package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
  149. package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
  150. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  151. package/server/modules/providers/tests/mcp.test.ts +93 -6
  152. package/server/modules/providers/tests/opencode-models.test.ts +73 -0
  153. package/server/modules/providers/tests/opencode-sessions.test.ts +334 -0
  154. package/server/modules/providers/tests/provider-models.service.test.ts +318 -0
  155. package/server/modules/providers/tests/skills.test.ts +66 -0
  156. package/server/modules/websocket/services/chat-websocket.service.ts +15 -1
  157. package/server/modules/websocket/services/plugin-websocket-proxy.service.ts +4 -4
  158. package/server/modules/websocket/services/shell-websocket.service.ts +9 -0
  159. package/server/openai-codex.js +41 -4
  160. package/server/opencode-cli.js +336 -0
  161. package/server/opencode-cli.test.js +95 -0
  162. package/server/routes/agent.js +22 -8
  163. package/server/routes/commands.js +254 -233
  164. package/server/routes/cursor.js +2 -2
  165. package/server/routes/settings.js +1 -10
  166. package/server/routes/tests/commands.test.js +82 -0
  167. package/server/shared/interfaces.ts +45 -0
  168. package/server/shared/types.ts +88 -1
  169. package/server/shared/utils.ts +384 -0
  170. package/dist/assets/index-AaSE7-3t.js +0 -1336
  171. package/dist/assets/index-CKaku72N.css +0 -32
  172. package/dist-server/shared/modelConstants.js +0 -99
  173. package/dist-server/shared/modelConstants.js.map +0 -1
  174. package/shared/modelConstants.js +0 -107
@@ -2,7 +2,7 @@ import express from 'express';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
- import { CURSOR_MODELS } from '../../shared/modelConstants.js';
5
+ import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js';
6
6
 
7
7
  const router = express.Router();
8
8
 
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
29
29
  config: {
30
30
  version: 1,
31
31
  model: {
32
- modelId: CURSOR_MODELS.DEFAULT,
32
+ modelId: CURSOR_FALLBACK_MODELS.DEFAULT,
33
33
  displayName: 'GPT-5',
34
34
  },
35
35
  permissions: {
@@ -1,4 +1,5 @@
1
1
  import express from 'express';
2
+
2
3
  import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
3
4
  import { getPublicKey } from '../services/vapid-keys.js';
4
5
  import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
@@ -273,14 +274,4 @@ router.post('/push/unsubscribe', async (req, res) => {
273
274
  }
274
275
  });
275
276
 
276
- // Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
277
- router.get('/server-env', async (req, res) => {
278
- try {
279
- res.json({ platform: process.platform });
280
- } catch (error) {
281
- console.error('Error reading server environment:', error);
282
- res.status(500).json({ error: 'Failed to read server environment' });
283
- }
284
- });
285
-
286
277
  export default router;
@@ -0,0 +1,82 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { executeModelsCommand } from '../commands.js';
5
+ import { providerModelsService } from '../../modules/providers/services/provider-models.service.js';
6
+
7
+ test('models command returns available models only for the active provider', async () => {
8
+ const originalGetProviderModels = providerModelsService.getProviderModels;
9
+ const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
10
+ let getCurrentActiveModelCalls = 0;
11
+
12
+ providerModelsService.getProviderModels = async () => ({
13
+ models: {
14
+ OPTIONS: [{ value: 'gpt-5.4', label: 'gpt-5.4' }],
15
+ DEFAULT: 'gpt-5.4',
16
+ },
17
+ cache: {
18
+ updatedAt: '2026-01-01T00:00:00.000Z',
19
+ expiresAt: '2026-01-04T00:00:00.000Z',
20
+ source: 'fresh',
21
+ },
22
+ });
23
+ providerModelsService.getCurrentActiveModel = async () => {
24
+ getCurrentActiveModelCalls += 1;
25
+ return {
26
+ model: 'gpt-5.3-codex',
27
+ };
28
+ };
29
+
30
+ try {
31
+ const result = await executeModelsCommand([], {
32
+ provider: 'codex',
33
+ model: 'gpt-5.4',
34
+ });
35
+
36
+ assert.equal(result.type, 'builtin');
37
+ assert.equal(result.action, 'models');
38
+ assert.equal(result.data.current.provider, 'codex');
39
+ assert.equal(result.data.current.model, 'gpt-5.4');
40
+ assert.deepEqual(Object.keys(result.data.available), ['codex']);
41
+ assert.deepEqual(result.data.available.codex, result.data.availableModels);
42
+ assert.ok(result.data.availableModels.includes('gpt-5.4'));
43
+ assert.equal(result.data.available.claude, undefined);
44
+ assert.equal(result.data.available.cursor, undefined);
45
+ assert.equal(getCurrentActiveModelCalls, 0);
46
+ } finally {
47
+ providerModelsService.getProviderModels = originalGetProviderModels;
48
+ providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
49
+ }
50
+ });
51
+
52
+ test('models command falls back to claude for unsupported providers', async () => {
53
+ const originalGetProviderModels = providerModelsService.getProviderModels;
54
+ const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
55
+
56
+ providerModelsService.getProviderModels = async () => ({
57
+ models: {
58
+ OPTIONS: [{ value: 'default', label: 'Default (recommended)' }],
59
+ DEFAULT: 'default',
60
+ },
61
+ cache: {
62
+ updatedAt: '2026-01-01T00:00:00.000Z',
63
+ expiresAt: '2026-01-04T00:00:00.000Z',
64
+ source: 'fresh',
65
+ },
66
+ });
67
+ providerModelsService.getCurrentActiveModel = async () => ({
68
+ model: 'default',
69
+ });
70
+
71
+ try {
72
+ const result = await executeModelsCommand([], {
73
+ provider: 'unknown-provider',
74
+ });
75
+
76
+ assert.equal(result.data.current.provider, 'claude');
77
+ assert.deepEqual(Object.keys(result.data.available), ['claude']);
78
+ } finally {
79
+ providerModelsService.getProviderModels = originalGetProviderModels;
80
+ providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
81
+ }
82
+ });
@@ -7,7 +7,11 @@ import type {
7
7
  ProviderSkill,
8
8
  ProviderSkillListOptions,
9
9
  ProviderAuthStatus,
10
+ ProviderChangeActiveModelInput,
11
+ ProviderCurrentActiveModel,
12
+ ProviderModelsDefinition,
10
13
  ProviderMcpServer,
14
+ ProviderSessionActiveModelChange,
11
15
  UpsertProviderMcpServerInput,
12
16
  } from '@/shared/types.js';
13
17
 
@@ -20,6 +24,7 @@ import type {
20
24
  */
21
25
  export interface IProvider {
22
26
  readonly id: LLMProvider;
27
+ readonly models: IProviderModels;
23
28
  readonly mcp: IProviderMcp;
24
29
  readonly auth: IProviderAuth;
25
30
  readonly skills: IProviderSkills;
@@ -27,6 +32,46 @@ export interface IProvider {
27
32
  readonly sessionSynchronizer: IProviderSessionSynchronizer;
28
33
  }
29
34
 
35
+ // ---------------------------
36
+ //----------------- PROVIDER MODEL INTERFACE ------------
37
+ /**
38
+ * Model catalog contract for one provider.
39
+ *
40
+ * Implementations are responsible for resolving the provider's currently
41
+ * supported models and converting them into the shared
42
+ * `ProviderModelsDefinition` shape used by backend routes and frontend model
43
+ * pickers. The `DEFAULT` field should be the most appropriate default selection
44
+ * for that provider at the time the catalog is read.
45
+ */
46
+ export interface IProviderModels {
47
+ /**
48
+ * Returns the provider's currently supported model catalog.
49
+ */
50
+ getSupportedModels(): Promise<ProviderModelsDefinition>;
51
+
52
+ /**
53
+ * Returns the currently active model for one session or provider runtime.
54
+ *
55
+ * Implementations must use the provider-specific lookup mechanism approved
56
+ * for that provider and fall back only to the provider catalog default when
57
+ * no active model can be resolved.
58
+ */
59
+ getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel>;
60
+
61
+ /**
62
+ * Persists a session-scoped model override that the next resumed turn should
63
+ * honor for this provider.
64
+ *
65
+ * This does not require the provider to mutate an already running remote
66
+ * session in-place. Instead, adapters store the user's explicit model choice
67
+ * so the backend resume path can add the correct provider-native model option
68
+ * on the next CLI/SDK invocation for the same session.
69
+ */
70
+ changeActiveModel(
71
+ input: ProviderChangeActiveModelInput,
72
+ ): Promise<ProviderSessionActiveModelChange>;
73
+ }
74
+
30
75
  // ---------------------------
31
76
  //----------------- PROVIDER AUTH INTERFACE ------------
32
77
  /**
@@ -65,7 +65,94 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
65
65
  * Use this as the source of truth whenever a function or payload needs to identify
66
66
  * a specific LLM integration.
67
67
  */
68
- export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
68
+ export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
69
+
70
+ /**
71
+ * One selectable model row (matches the documentation `public/modelConstants.js` option shape).
72
+ */
73
+ export type ProviderModelOption = {
74
+ value: string;
75
+ label: string;
76
+ description?: string;
77
+ };
78
+
79
+ /**
80
+ * Provider model catalog returned by `GET /api/providers/:provider/models`.
81
+ */
82
+ export type ProviderModelsDefinition = {
83
+ OPTIONS: ProviderModelOption[];
84
+ DEFAULT: string;
85
+ };
86
+
87
+ /**
88
+ * Cache metadata returned alongside one provider model catalog.
89
+ *
90
+ * `updatedAt` is when the current cached snapshot was last refreshed from the
91
+ * provider itself. `expiresAt` is the backend cache expiry timestamp, and
92
+ * `source` tells callers whether the current response came from in-memory cache,
93
+ * persisted disk cache, or a fresh provider fetch.
94
+ */
95
+ export type ProviderModelsCacheInfo = {
96
+ updatedAt: string;
97
+ expiresAt: string;
98
+ source: 'memory' | 'disk' | 'fresh';
99
+ };
100
+
101
+ /**
102
+ * Full provider model lookup result returned by the backend service layer.
103
+ *
104
+ * Use this shape when a caller needs both the selectable model catalog and the
105
+ * cache metadata that explains how current the catalog is.
106
+ */
107
+ export type ProviderModelsResult = {
108
+ models: ProviderModelsDefinition;
109
+ cache: ProviderModelsCacheInfo;
110
+ };
111
+
112
+ // ---------------------------
113
+ //----------------- PROVIDER ACTIVE MODEL TYPES ------------
114
+ /**
115
+ * Provider-neutral result for the model that is actively driving a session or
116
+ * provider runtime at the time of lookup.
117
+ *
118
+ * `model` must always be populated. Provider adapters should use the
119
+ * provider-specific lookup method requested by the caller, and only fall back
120
+ * to the provider catalog `DEFAULT` value when the active model cannot be read.
121
+ */
122
+ export type ProviderCurrentActiveModel = {
123
+ model: string;
124
+ };
125
+
126
+ /**
127
+ * Input payload used when one session needs to use a different model on its
128
+ * next resumed turn.
129
+ *
130
+ * This is a backend-owned session override, not a claim that the provider has
131
+ * already switched the currently running session in-place. Provider adapters
132
+ * persist this request so the next CLI/SDK resume can inject the chosen model
133
+ * using the provider-specific mechanism supported by that runtime.
134
+ */
135
+ export type ProviderChangeActiveModelInput = {
136
+ sessionId: string;
137
+ model: string;
138
+ };
139
+
140
+ /**
141
+ * Provider-neutral session model-change state.
142
+ *
143
+ * `supported` indicates whether the provider adapter supports the app's
144
+ * session-scoped resume override flow. `changed` is the persisted boolean the
145
+ * resume layer checks before forcing a model on the next resumed turn. When
146
+ * `changed` is `false`, `model` is `null` and the runtime should use the
147
+ * normal request/default model selection path.
148
+ */
149
+ export type ProviderSessionActiveModelChange = {
150
+ provider: LLMProvider;
151
+ sessionId: string;
152
+ supported: boolean;
153
+ changed: boolean;
154
+ model: string | null;
155
+ };
69
156
 
70
157
  /**
71
158
  * Message/event variants emitted by provider adapters and normalized transports.
@@ -22,7 +22,13 @@ import type {
22
22
  AnyRecord,
23
23
  ApiSuccessShape,
24
24
  AppErrorOptions,
25
+ LLMProvider,
25
26
  NormalizedMessage,
27
+ ProviderChangeActiveModelInput,
28
+ ProviderCurrentActiveModel,
29
+ ProviderModelsDefinition,
30
+ ProviderSessionActiveModelChange,
31
+ ProviderSkillSource,
26
32
  WorkspacePathValidationResult,
27
33
  } from '@/shared/types.js';
28
34
 
@@ -413,6 +419,231 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
413
419
  return Object.keys(normalized).length > 0 ? normalized : undefined;
414
420
  };
415
421
 
422
+ // ---------------------------
423
+ //----------------- PROVIDER MODEL LOOKUP UTILITIES ------------
424
+ /**
425
+ * Builds the standard "default current model" result used when a provider
426
+ * cannot resolve a session-backed active model.
427
+ *
428
+ * Provider model adapters should call this after loading their supported model
429
+ * catalog so the fallback stays aligned with the provider's current `DEFAULT`
430
+ * selection instead of drifting to a hard-coded duplicate.
431
+ */
432
+ export function buildDefaultProviderCurrentActiveModel(
433
+ models: ProviderModelsDefinition,
434
+ ): ProviderCurrentActiveModel {
435
+ return {
436
+ model: models.DEFAULT,
437
+ };
438
+ }
439
+
440
+ // ---------------------------
441
+ //----------------- PROVIDER SESSION MODEL CHANGE UTILITIES ------------
442
+ type ProviderSessionActiveModelChangeCacheEntry = ProviderSessionActiveModelChange & {
443
+ updatedAt: string;
444
+ };
445
+
446
+ type ProviderSessionActiveModelChangeCacheFile = {
447
+ version: number;
448
+ entries: Record<string, ProviderSessionActiveModelChangeCacheEntry>;
449
+ };
450
+
451
+ const PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION = 1;
452
+
453
+ /**
454
+ * Resolves the backend-owned cache file used for session-scoped resume model
455
+ * overrides.
456
+ *
457
+ * The file lives under `~/.cloudcli` because these overrides are an application
458
+ * concern rather than a provider-native config file. Providers, routes, and
459
+ * runtime command launchers should all use this helper instead of re-creating
460
+ * the path so the storage location stays consistent.
461
+ */
462
+ export function getProviderSessionActiveModelChangesPath(): string {
463
+ return path.join(os.homedir(), '.cloudcli', 'provider-session-active-model-changes.json');
464
+ }
465
+
466
+ const buildProviderSessionActiveModelChangeKey = (
467
+ provider: LLMProvider,
468
+ sessionId: string,
469
+ ): string => `${provider}:${sessionId}`;
470
+
471
+ const isProviderSessionActiveModelChangeCacheEntry = (
472
+ value: unknown,
473
+ ): value is ProviderSessionActiveModelChangeCacheEntry => {
474
+ const record = readObjectRecord(value);
475
+ return Boolean(
476
+ record
477
+ && typeof record.provider === 'string'
478
+ && typeof record.sessionId === 'string'
479
+ && typeof record.supported === 'boolean'
480
+ && typeof record.changed === 'boolean'
481
+ && (typeof record.model === 'string' || record.model === null)
482
+ && typeof record.updatedAt === 'string',
483
+ );
484
+ };
485
+
486
+ const readProviderSessionActiveModelChangeCacheFile = async (
487
+ filePath: string,
488
+ ): Promise<ProviderSessionActiveModelChangeCacheFile> => {
489
+ try {
490
+ const raw = await readFile(filePath, 'utf8');
491
+ const parsed = readObjectRecord(JSON.parse(raw));
492
+ if (
493
+ !parsed
494
+ || parsed.version !== PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION
495
+ || !readObjectRecord(parsed.entries)
496
+ ) {
497
+ return {
498
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
499
+ entries: {},
500
+ };
501
+ }
502
+
503
+ const entries = Object.fromEntries(
504
+ Object.entries(parsed.entries).filter((entry): entry is [string, ProviderSessionActiveModelChangeCacheEntry] =>
505
+ isProviderSessionActiveModelChangeCacheEntry(entry[1]),
506
+ ),
507
+ );
508
+
509
+ return {
510
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
511
+ entries,
512
+ };
513
+ } catch {
514
+ return {
515
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
516
+ entries: {},
517
+ };
518
+ }
519
+ };
520
+
521
+ const writeProviderSessionActiveModelChangeCacheFile = async (
522
+ filePath: string,
523
+ payload: ProviderSessionActiveModelChangeCacheFile,
524
+ ): Promise<void> => {
525
+ await mkdir(path.dirname(filePath), { recursive: true });
526
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
527
+ };
528
+
529
+ const buildUnsupportedProviderSessionActiveModelChange = (
530
+ provider: LLMProvider,
531
+ sessionId: string,
532
+ ): ProviderSessionActiveModelChange => ({
533
+ provider,
534
+ sessionId,
535
+ supported: false,
536
+ changed: false,
537
+ model: null,
538
+ });
539
+
540
+ /**
541
+ * Reads the persisted session model-change state for one provider session.
542
+ *
543
+ * Runtime resume paths use this to decide whether they should inject a
544
+ * provider-specific model argument/thread option for the next resumed turn.
545
+ * Missing cache entries are normalized to `{ changed: false }` so callers can
546
+ * treat absence as "use the ordinary model selection flow".
547
+ */
548
+ export async function readProviderSessionActiveModelChange(
549
+ provider: LLMProvider,
550
+ sessionId: string,
551
+ options: {
552
+ filePath?: string;
553
+ supported?: boolean;
554
+ } = {},
555
+ ): Promise<ProviderSessionActiveModelChange> {
556
+ const normalizedSessionId = sessionId.trim();
557
+ if (!normalizedSessionId) {
558
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
559
+ }
560
+
561
+ const supported = options.supported ?? true;
562
+ if (!supported) {
563
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
564
+ }
565
+
566
+ const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
567
+ const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
568
+ const cacheEntry = cacheFile.entries[
569
+ buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)
570
+ ];
571
+
572
+ if (!cacheEntry || !cacheEntry.changed || !cacheEntry.model?.trim()) {
573
+ return {
574
+ provider,
575
+ sessionId: normalizedSessionId,
576
+ supported: true,
577
+ changed: false,
578
+ model: null,
579
+ };
580
+ }
581
+
582
+ return {
583
+ provider,
584
+ sessionId: normalizedSessionId,
585
+ supported: true,
586
+ changed: true,
587
+ model: cacheEntry.model.trim(),
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Persists a session model-change request for one provider.
593
+ *
594
+ * Provider adapters call this when the frontend explicitly selects a different
595
+ * model for an existing session. The stored `changed: true` flag is the single
596
+ * source of truth used later by resume paths to decide whether they should add
597
+ * a provider-native model override on the next invocation.
598
+ */
599
+ export async function writeProviderSessionActiveModelChange(
600
+ provider: LLMProvider,
601
+ input: ProviderChangeActiveModelInput,
602
+ options: {
603
+ filePath?: string;
604
+ supported?: boolean;
605
+ } = {},
606
+ ): Promise<ProviderSessionActiveModelChange> {
607
+ const normalizedSessionId = input.sessionId.trim();
608
+ const normalizedModel = input.model.trim();
609
+ const supported = options.supported ?? true;
610
+
611
+ if (!supported) {
612
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
613
+ }
614
+
615
+ if (!normalizedSessionId || !normalizedModel) {
616
+ return {
617
+ provider,
618
+ sessionId: normalizedSessionId,
619
+ supported: true,
620
+ changed: false,
621
+ model: null,
622
+ };
623
+ }
624
+
625
+ const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
626
+ const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
627
+ cacheFile.entries[buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)] = {
628
+ provider,
629
+ sessionId: normalizedSessionId,
630
+ supported: true,
631
+ changed: true,
632
+ model: normalizedModel,
633
+ updatedAt: new Date().toISOString(),
634
+ };
635
+
636
+ await writeProviderSessionActiveModelChangeCacheFile(filePath, cacheFile);
637
+
638
+ return {
639
+ provider,
640
+ sessionId: normalizedSessionId,
641
+ supported: true,
642
+ changed: true,
643
+ model: normalizedModel,
644
+ };
645
+ }
646
+
416
647
  // ---------------------------
417
648
  //----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
418
649
  /**
@@ -506,6 +737,67 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
506
737
 
507
738
  // ---------------------------
508
739
  //----------------- PROVIDER SKILL FILE UTILITIES ------------
740
+ async function hasGitMarker(dirPath: string): Promise<boolean> {
741
+ try {
742
+ const gitMarkerStats = await stat(path.join(dirPath, '.git'));
743
+ return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
744
+ } catch {
745
+ return false;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Finds the highest git worktree root visible from a starting directory.
751
+ *
752
+ * Provider skill systems such as Codex and OpenCode walk upward through parent
753
+ * folders when resolving repository/project skills. Use this helper when a
754
+ * provider needs the topmost `.git` marker instead of only the nearest one, so
755
+ * monorepos and nested package folders discover shared root-level skills once.
756
+ */
757
+ export async function findTopmostGitRoot(startPath: string): Promise<string | null> {
758
+ let currentPath = path.resolve(startPath);
759
+ let topmostGitRoot: string | null = null;
760
+
761
+ while (true) {
762
+ if (await hasGitMarker(currentPath)) {
763
+ topmostGitRoot = currentPath;
764
+ }
765
+
766
+ const parentPath = path.dirname(currentPath);
767
+ if (parentPath === currentPath) {
768
+ break;
769
+ }
770
+
771
+ currentPath = parentPath;
772
+ }
773
+
774
+ return topmostGitRoot;
775
+ }
776
+
777
+ /**
778
+ * Adds one provider skill source after normalizing and de-duplicating its root.
779
+ *
780
+ * Provider skill lookup rules often point at overlapping folders (for example a
781
+ * workspace folder can also be the git root). Use this helper while building a
782
+ * provider's `ProviderSkillSource[]` so the shared skills scanner reads each
783
+ * physical root once and still preserves provider-specific scope/command data.
784
+ */
785
+ export function addUniqueProviderSkillSource(
786
+ sources: ProviderSkillSource[],
787
+ seenRootDirs: Set<string>,
788
+ source: ProviderSkillSource,
789
+ ): void {
790
+ const normalizedRootDir = path.resolve(source.rootDir);
791
+ if (seenRootDirs.has(normalizedRootDir)) {
792
+ return;
793
+ }
794
+
795
+ seenRootDirs.add(normalizedRootDir);
796
+ sources.push({ ...source, rootDir: normalizedRootDir });
797
+ }
798
+
799
+ // ---------------------------
800
+ //----------------- PROVIDER SKILL MARKDOWN UTILITIES ------------
509
801
  /**
510
802
  * Finds direct child skill markdown files under a provider skill root.
511
803
  *
@@ -616,6 +908,98 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
616
908
  return normalized.slice(0, 120);
617
909
  }
618
910
 
911
+ // ---------------------------
912
+ //----------------- PROVIDER SESSION VALUE NORMALIZATION UTILITIES ------------
913
+ /**
914
+ * Converts provider-native timestamps into ISO strings.
915
+ *
916
+ * Provider CLIs commonly persist epoch timestamps as milliseconds, seconds, or
917
+ * already-formatted date strings. Use this helper when normalizing session
918
+ * metadata or transcript events so every provider writes the same ISO timestamp
919
+ * shape to API responses and database rows.
920
+ */
921
+ export function normalizeProviderTimestamp(value: unknown): string {
922
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
923
+ const millis = value < 1_000_000_000_000 ? value * 1000 : value;
924
+ return new Date(millis).toISOString();
925
+ }
926
+
927
+ if (typeof value === 'string' && value.trim()) {
928
+ const parsed = Number(value);
929
+ if (Number.isFinite(parsed)) {
930
+ return normalizeProviderTimestamp(parsed);
931
+ }
932
+
933
+ const date = new Date(value);
934
+ if (!Number.isNaN(date.getTime())) {
935
+ return date.toISOString();
936
+ }
937
+ }
938
+
939
+ return new Date().toISOString();
940
+ }
941
+
942
+ /**
943
+ * Parses a JSON string or narrows an existing object into a plain record.
944
+ *
945
+ * Use this when provider databases store structured JSON inside text columns.
946
+ * Invalid JSON, arrays, and primitive values return `null` so callers can skip
947
+ * malformed optional metadata without hiding the rest of a session transcript.
948
+ */
949
+ export function readJsonRecord(value: unknown): AnyRecord | null {
950
+ if (typeof value !== 'string') {
951
+ return readObjectRecord(value);
952
+ }
953
+
954
+ try {
955
+ return readObjectRecord(JSON.parse(value));
956
+ } catch {
957
+ return null;
958
+ }
959
+ }
960
+
961
+ // ---------------------------
962
+ //----------------- OPENCODE SESSION STORAGE UTILITIES ------------
963
+ /**
964
+ * Resolves the OpenCode SQLite session database path.
965
+ *
966
+ * OpenCode stores session, message, part, and project metadata in one shared
967
+ * `opencode.db` file under its XDG data directory. Provider readers and
968
+ * synchronizers should use this path for read-only access and should never store
969
+ * it as a deletable transcript path for an individual app session row.
970
+ */
971
+ export function getOpenCodeDatabasePath(): string {
972
+ return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
973
+ }
974
+
975
+ // ---------------------------
976
+ //----------------- SAFE DIRECTORY NAME UTILITIES ------------
977
+ /**
978
+ * Validates that a user or provider supplied identifier can safely be treated
979
+ * as one leaf directory name under an existing root folder.
980
+ *
981
+ * Use this before composing paths like `<root>/<session-id>/file.db>` to block
982
+ * path traversal and accidental nested paths. The returned string is trimmed but
983
+ * otherwise unchanged so callers can still match the provider's on-disk naming.
984
+ */
985
+ export function sanitizeLeafDirectoryName(inputName: string, label = 'directory name'): string {
986
+ const normalized = inputName.trim();
987
+ if (!normalized) {
988
+ throw new Error(`${label} is required.`);
989
+ }
990
+
991
+ if (
992
+ normalized.includes('..')
993
+ || normalized.includes(path.posix.sep)
994
+ || normalized.includes(path.win32.sep)
995
+ || normalized !== path.basename(normalized)
996
+ ) {
997
+ throw new Error(`Invalid ${label} "${inputName}".`);
998
+ }
999
+
1000
+ return normalized;
1001
+ }
1002
+
619
1003
  // ---------------------------
620
1004
  //----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
621
1005
  /**