@glwhappen/web-code 1.32.9 → 1.32.11

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 (167) 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-D_7CSvqO.js → index-CBo8yakG.js} +276 -261
  9. package/dist/assets/index-Dl5QP21C.css +32 -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-models.provider.js +143 -0
  29. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
  30. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  31. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
  33. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
  34. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
  35. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  37. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
  39. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
  40. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
  41. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  43. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
  45. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
  46. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
  47. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  48. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  49. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
  51. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
  52. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
  53. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
  54. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
  55. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
  56. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
  57. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
  58. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
  59. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
  60. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
  61. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
  63. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
  64. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  65. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  66. package/dist-server/server/modules/providers/provider.routes.js +42 -1
  67. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
  69. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/provider-models.service.js +199 -0
  71. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
  72. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
  73. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  74. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
  75. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  77. package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
  78. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  79. package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
  80. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
  81. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +264 -0
  82. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
  83. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +270 -0
  84. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
  85. package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
  86. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  87. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +18 -1
  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 +9 -1
  90. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  91. package/dist-server/server/openai-codex.js +32 -4
  92. package/dist-server/server/openai-codex.js.map +1 -1
  93. package/dist-server/server/opencode-cli.js +287 -0
  94. package/dist-server/server/opencode-cli.js.map +1 -0
  95. package/dist-server/server/opencode-cli.test.js +84 -0
  96. package/dist-server/server/opencode-cli.test.js.map +1 -0
  97. package/dist-server/server/routes/agent.js +21 -8
  98. package/dist-server/server/routes/agent.js.map +1 -1
  99. package/dist-server/server/routes/commands.js +202 -209
  100. package/dist-server/server/routes/commands.js.map +1 -1
  101. package/dist-server/server/routes/cursor.js +2 -2
  102. package/dist-server/server/routes/cursor.js.map +1 -1
  103. package/dist-server/server/routes/settings.js +0 -10
  104. package/dist-server/server/routes/settings.js.map +1 -1
  105. package/dist-server/server/routes/tests/commands.test.js +76 -0
  106. package/dist-server/server/routes/tests/commands.test.js.map +1 -0
  107. package/dist-server/server/shared/utils.js +286 -0
  108. package/dist-server/server/shared/utils.js.map +1 -1
  109. package/package.json +3 -1
  110. package/public/api-docs.html +878 -0
  111. package/public/modelConstants.js +841 -0
  112. package/server/claude-sdk.js +64 -35
  113. package/server/cursor-cli.js +6 -3
  114. package/server/gemini-cli.js +7 -1
  115. package/server/gemini-response-handler.js +38 -0
  116. package/server/index.js +150 -19
  117. package/server/modules/database/index.ts +1 -0
  118. package/server/modules/projects/services/project-management.service.ts +2 -0
  119. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
  120. package/server/modules/providers/README.md +11 -3
  121. package/server/modules/providers/list/claude/claude-models.provider.ts +193 -0
  122. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  123. package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
  124. package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
  125. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  126. package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
  127. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
  128. package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
  129. package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
  130. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
  131. package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
  132. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
  133. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
  134. package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
  135. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +158 -0
  136. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
  137. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
  138. package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
  139. package/server/modules/providers/provider.registry.ts +2 -0
  140. package/server/modules/providers/provider.routes.ts +62 -2
  141. package/server/modules/providers/services/mcp.service.ts +1 -12
  142. package/server/modules/providers/services/provider-models.service.ts +325 -0
  143. package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
  144. package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
  145. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  146. package/server/modules/providers/tests/mcp.test.ts +93 -6
  147. package/server/modules/providers/tests/opencode-models.test.ts +73 -0
  148. package/server/modules/providers/tests/opencode-sessions.test.ts +336 -0
  149. package/server/modules/providers/tests/provider-models.service.test.ts +318 -0
  150. package/server/modules/providers/tests/skills.test.ts +66 -0
  151. package/server/modules/websocket/services/chat-websocket.service.ts +21 -1
  152. package/server/modules/websocket/services/shell-websocket.service.ts +9 -0
  153. package/server/openai-codex.js +40 -4
  154. package/server/opencode-cli.js +336 -0
  155. package/server/opencode-cli.test.js +95 -0
  156. package/server/routes/agent.js +22 -8
  157. package/server/routes/commands.js +254 -233
  158. package/server/routes/cursor.js +2 -2
  159. package/server/routes/settings.js +1 -10
  160. package/server/routes/tests/commands.test.js +82 -0
  161. package/server/shared/interfaces.ts +45 -0
  162. package/server/shared/types.ts +88 -1
  163. package/server/shared/utils.ts +384 -0
  164. package/dist/assets/index-DdxLnCfK.css +0 -32
  165. package/dist-server/shared/modelConstants.js +0 -99
  166. package/dist-server/shared/modelConstants.js.map +0 -1
  167. 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
  /**
@@ -66,7 +66,94 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
66
66
  * Use this as the source of truth whenever a function or payload needs to identify
67
67
  * a specific LLM integration.
68
68
  */
69
- export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
69
+ export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
70
+
71
+ /**
72
+ * One selectable model row (matches the documentation `public/modelConstants.js` option shape).
73
+ */
74
+ export type ProviderModelOption = {
75
+ value: string;
76
+ label: string;
77
+ description?: string;
78
+ };
79
+
80
+ /**
81
+ * Provider model catalog returned by `GET /api/providers/:provider/models`.
82
+ */
83
+ export type ProviderModelsDefinition = {
84
+ OPTIONS: ProviderModelOption[];
85
+ DEFAULT: string;
86
+ };
87
+
88
+ /**
89
+ * Cache metadata returned alongside one provider model catalog.
90
+ *
91
+ * `updatedAt` is when the current cached snapshot was last refreshed from the
92
+ * provider itself. `expiresAt` is the backend cache expiry timestamp, and
93
+ * `source` tells callers whether the current response came from in-memory cache,
94
+ * persisted disk cache, or a fresh provider fetch.
95
+ */
96
+ export type ProviderModelsCacheInfo = {
97
+ updatedAt: string;
98
+ expiresAt: string;
99
+ source: 'memory' | 'disk' | 'fresh';
100
+ };
101
+
102
+ /**
103
+ * Full provider model lookup result returned by the backend service layer.
104
+ *
105
+ * Use this shape when a caller needs both the selectable model catalog and the
106
+ * cache metadata that explains how current the catalog is.
107
+ */
108
+ export type ProviderModelsResult = {
109
+ models: ProviderModelsDefinition;
110
+ cache: ProviderModelsCacheInfo;
111
+ };
112
+
113
+ // ---------------------------
114
+ //----------------- PROVIDER ACTIVE MODEL TYPES ------------
115
+ /**
116
+ * Provider-neutral result for the model that is actively driving a session or
117
+ * provider runtime at the time of lookup.
118
+ *
119
+ * `model` must always be populated. Provider adapters should use the
120
+ * provider-specific lookup method requested by the caller, and only fall back
121
+ * to the provider catalog `DEFAULT` value when the active model cannot be read.
122
+ */
123
+ export type ProviderCurrentActiveModel = {
124
+ model: string;
125
+ };
126
+
127
+ /**
128
+ * Input payload used when one session needs to use a different model on its
129
+ * next resumed turn.
130
+ *
131
+ * This is a backend-owned session override, not a claim that the provider has
132
+ * already switched the currently running session in-place. Provider adapters
133
+ * persist this request so the next CLI/SDK resume can inject the chosen model
134
+ * using the provider-specific mechanism supported by that runtime.
135
+ */
136
+ export type ProviderChangeActiveModelInput = {
137
+ sessionId: string;
138
+ model: string;
139
+ };
140
+
141
+ /**
142
+ * Provider-neutral session model-change state.
143
+ *
144
+ * `supported` indicates whether the provider adapter supports the app's
145
+ * session-scoped resume override flow. `changed` is the persisted boolean the
146
+ * resume layer checks before forcing a model on the next resumed turn. When
147
+ * `changed` is `false`, `model` is `null` and the runtime should use the
148
+ * normal request/default model selection path.
149
+ */
150
+ export type ProviderSessionActiveModelChange = {
151
+ provider: LLMProvider;
152
+ sessionId: string;
153
+ supported: boolean;
154
+ changed: boolean;
155
+ model: string | null;
156
+ };
70
157
 
71
158
  /**
72
159
  * 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
 
@@ -474,6 +480,231 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
474
480
  return Object.keys(normalized).length > 0 ? normalized : undefined;
475
481
  };
476
482
 
483
+ // ---------------------------
484
+ //----------------- PROVIDER MODEL LOOKUP UTILITIES ------------
485
+ /**
486
+ * Builds the standard "default current model" result used when a provider
487
+ * cannot resolve a session-backed active model.
488
+ *
489
+ * Provider model adapters should call this after loading their supported model
490
+ * catalog so the fallback stays aligned with the provider's current `DEFAULT`
491
+ * selection instead of drifting to a hard-coded duplicate.
492
+ */
493
+ export function buildDefaultProviderCurrentActiveModel(
494
+ models: ProviderModelsDefinition,
495
+ ): ProviderCurrentActiveModel {
496
+ return {
497
+ model: models.DEFAULT,
498
+ };
499
+ }
500
+
501
+ // ---------------------------
502
+ //----------------- PROVIDER SESSION MODEL CHANGE UTILITIES ------------
503
+ type ProviderSessionActiveModelChangeCacheEntry = ProviderSessionActiveModelChange & {
504
+ updatedAt: string;
505
+ };
506
+
507
+ type ProviderSessionActiveModelChangeCacheFile = {
508
+ version: number;
509
+ entries: Record<string, ProviderSessionActiveModelChangeCacheEntry>;
510
+ };
511
+
512
+ const PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION = 1;
513
+
514
+ /**
515
+ * Resolves the backend-owned cache file used for session-scoped resume model
516
+ * overrides.
517
+ *
518
+ * The file lives under `~/.cloudcli` because these overrides are an application
519
+ * concern rather than a provider-native config file. Providers, routes, and
520
+ * runtime command launchers should all use this helper instead of re-creating
521
+ * the path so the storage location stays consistent.
522
+ */
523
+ export function getProviderSessionActiveModelChangesPath(): string {
524
+ return path.join(os.homedir(), '.cloudcli', 'provider-session-active-model-changes.json');
525
+ }
526
+
527
+ const buildProviderSessionActiveModelChangeKey = (
528
+ provider: LLMProvider,
529
+ sessionId: string,
530
+ ): string => `${provider}:${sessionId}`;
531
+
532
+ const isProviderSessionActiveModelChangeCacheEntry = (
533
+ value: unknown,
534
+ ): value is ProviderSessionActiveModelChangeCacheEntry => {
535
+ const record = readObjectRecord(value);
536
+ return Boolean(
537
+ record
538
+ && typeof record.provider === 'string'
539
+ && typeof record.sessionId === 'string'
540
+ && typeof record.supported === 'boolean'
541
+ && typeof record.changed === 'boolean'
542
+ && (typeof record.model === 'string' || record.model === null)
543
+ && typeof record.updatedAt === 'string',
544
+ );
545
+ };
546
+
547
+ const readProviderSessionActiveModelChangeCacheFile = async (
548
+ filePath: string,
549
+ ): Promise<ProviderSessionActiveModelChangeCacheFile> => {
550
+ try {
551
+ const raw = await readFile(filePath, 'utf8');
552
+ const parsed = readObjectRecord(JSON.parse(raw));
553
+ if (
554
+ !parsed
555
+ || parsed.version !== PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION
556
+ || !readObjectRecord(parsed.entries)
557
+ ) {
558
+ return {
559
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
560
+ entries: {},
561
+ };
562
+ }
563
+
564
+ const entries = Object.fromEntries(
565
+ Object.entries(parsed.entries).filter((entry): entry is [string, ProviderSessionActiveModelChangeCacheEntry] =>
566
+ isProviderSessionActiveModelChangeCacheEntry(entry[1]),
567
+ ),
568
+ );
569
+
570
+ return {
571
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
572
+ entries,
573
+ };
574
+ } catch {
575
+ return {
576
+ version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
577
+ entries: {},
578
+ };
579
+ }
580
+ };
581
+
582
+ const writeProviderSessionActiveModelChangeCacheFile = async (
583
+ filePath: string,
584
+ payload: ProviderSessionActiveModelChangeCacheFile,
585
+ ): Promise<void> => {
586
+ await mkdir(path.dirname(filePath), { recursive: true });
587
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
588
+ };
589
+
590
+ const buildUnsupportedProviderSessionActiveModelChange = (
591
+ provider: LLMProvider,
592
+ sessionId: string,
593
+ ): ProviderSessionActiveModelChange => ({
594
+ provider,
595
+ sessionId,
596
+ supported: false,
597
+ changed: false,
598
+ model: null,
599
+ });
600
+
601
+ /**
602
+ * Reads the persisted session model-change state for one provider session.
603
+ *
604
+ * Runtime resume paths use this to decide whether they should inject a
605
+ * provider-specific model argument/thread option for the next resumed turn.
606
+ * Missing cache entries are normalized to `{ changed: false }` so callers can
607
+ * treat absence as "use the ordinary model selection flow".
608
+ */
609
+ export async function readProviderSessionActiveModelChange(
610
+ provider: LLMProvider,
611
+ sessionId: string,
612
+ options: {
613
+ filePath?: string;
614
+ supported?: boolean;
615
+ } = {},
616
+ ): Promise<ProviderSessionActiveModelChange> {
617
+ const normalizedSessionId = sessionId.trim();
618
+ if (!normalizedSessionId) {
619
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
620
+ }
621
+
622
+ const supported = options.supported ?? true;
623
+ if (!supported) {
624
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
625
+ }
626
+
627
+ const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
628
+ const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
629
+ const cacheEntry = cacheFile.entries[
630
+ buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)
631
+ ];
632
+
633
+ if (!cacheEntry || !cacheEntry.changed || !cacheEntry.model?.trim()) {
634
+ return {
635
+ provider,
636
+ sessionId: normalizedSessionId,
637
+ supported: true,
638
+ changed: false,
639
+ model: null,
640
+ };
641
+ }
642
+
643
+ return {
644
+ provider,
645
+ sessionId: normalizedSessionId,
646
+ supported: true,
647
+ changed: true,
648
+ model: cacheEntry.model.trim(),
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Persists a session model-change request for one provider.
654
+ *
655
+ * Provider adapters call this when the frontend explicitly selects a different
656
+ * model for an existing session. The stored `changed: true` flag is the single
657
+ * source of truth used later by resume paths to decide whether they should add
658
+ * a provider-native model override on the next invocation.
659
+ */
660
+ export async function writeProviderSessionActiveModelChange(
661
+ provider: LLMProvider,
662
+ input: ProviderChangeActiveModelInput,
663
+ options: {
664
+ filePath?: string;
665
+ supported?: boolean;
666
+ } = {},
667
+ ): Promise<ProviderSessionActiveModelChange> {
668
+ const normalizedSessionId = input.sessionId.trim();
669
+ const normalizedModel = input.model.trim();
670
+ const supported = options.supported ?? true;
671
+
672
+ if (!supported) {
673
+ return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
674
+ }
675
+
676
+ if (!normalizedSessionId || !normalizedModel) {
677
+ return {
678
+ provider,
679
+ sessionId: normalizedSessionId,
680
+ supported: true,
681
+ changed: false,
682
+ model: null,
683
+ };
684
+ }
685
+
686
+ const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
687
+ const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
688
+ cacheFile.entries[buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)] = {
689
+ provider,
690
+ sessionId: normalizedSessionId,
691
+ supported: true,
692
+ changed: true,
693
+ model: normalizedModel,
694
+ updatedAt: new Date().toISOString(),
695
+ };
696
+
697
+ await writeProviderSessionActiveModelChangeCacheFile(filePath, cacheFile);
698
+
699
+ return {
700
+ provider,
701
+ sessionId: normalizedSessionId,
702
+ supported: true,
703
+ changed: true,
704
+ model: normalizedModel,
705
+ };
706
+ }
707
+
477
708
  // ---------------------------
478
709
  //----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
479
710
  /**
@@ -567,6 +798,67 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
567
798
 
568
799
  // ---------------------------
569
800
  //----------------- PROVIDER SKILL FILE UTILITIES ------------
801
+ async function hasGitMarker(dirPath: string): Promise<boolean> {
802
+ try {
803
+ const gitMarkerStats = await stat(path.join(dirPath, '.git'));
804
+ return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Finds the highest git worktree root visible from a starting directory.
812
+ *
813
+ * Provider skill systems such as Codex and OpenCode walk upward through parent
814
+ * folders when resolving repository/project skills. Use this helper when a
815
+ * provider needs the topmost `.git` marker instead of only the nearest one, so
816
+ * monorepos and nested package folders discover shared root-level skills once.
817
+ */
818
+ export async function findTopmostGitRoot(startPath: string): Promise<string | null> {
819
+ let currentPath = path.resolve(startPath);
820
+ let topmostGitRoot: string | null = null;
821
+
822
+ while (true) {
823
+ if (await hasGitMarker(currentPath)) {
824
+ topmostGitRoot = currentPath;
825
+ }
826
+
827
+ const parentPath = path.dirname(currentPath);
828
+ if (parentPath === currentPath) {
829
+ break;
830
+ }
831
+
832
+ currentPath = parentPath;
833
+ }
834
+
835
+ return topmostGitRoot;
836
+ }
837
+
838
+ /**
839
+ * Adds one provider skill source after normalizing and de-duplicating its root.
840
+ *
841
+ * Provider skill lookup rules often point at overlapping folders (for example a
842
+ * workspace folder can also be the git root). Use this helper while building a
843
+ * provider's `ProviderSkillSource[]` so the shared skills scanner reads each
844
+ * physical root once and still preserves provider-specific scope/command data.
845
+ */
846
+ export function addUniqueProviderSkillSource(
847
+ sources: ProviderSkillSource[],
848
+ seenRootDirs: Set<string>,
849
+ source: ProviderSkillSource,
850
+ ): void {
851
+ const normalizedRootDir = path.resolve(source.rootDir);
852
+ if (seenRootDirs.has(normalizedRootDir)) {
853
+ return;
854
+ }
855
+
856
+ seenRootDirs.add(normalizedRootDir);
857
+ sources.push({ ...source, rootDir: normalizedRootDir });
858
+ }
859
+
860
+ // ---------------------------
861
+ //----------------- PROVIDER SKILL MARKDOWN UTILITIES ------------
570
862
  /**
571
863
  * Finds direct child skill markdown files under a provider skill root.
572
864
  *
@@ -677,6 +969,98 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
677
969
  return normalized.slice(0, 120);
678
970
  }
679
971
 
972
+ // ---------------------------
973
+ //----------------- PROVIDER SESSION VALUE NORMALIZATION UTILITIES ------------
974
+ /**
975
+ * Converts provider-native timestamps into ISO strings.
976
+ *
977
+ * Provider CLIs commonly persist epoch timestamps as milliseconds, seconds, or
978
+ * already-formatted date strings. Use this helper when normalizing session
979
+ * metadata or transcript events so every provider writes the same ISO timestamp
980
+ * shape to API responses and database rows.
981
+ */
982
+ export function normalizeProviderTimestamp(value: unknown): string {
983
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
984
+ const millis = value < 1_000_000_000_000 ? value * 1000 : value;
985
+ return new Date(millis).toISOString();
986
+ }
987
+
988
+ if (typeof value === 'string' && value.trim()) {
989
+ const parsed = Number(value);
990
+ if (Number.isFinite(parsed)) {
991
+ return normalizeProviderTimestamp(parsed);
992
+ }
993
+
994
+ const date = new Date(value);
995
+ if (!Number.isNaN(date.getTime())) {
996
+ return date.toISOString();
997
+ }
998
+ }
999
+
1000
+ return new Date().toISOString();
1001
+ }
1002
+
1003
+ /**
1004
+ * Parses a JSON string or narrows an existing object into a plain record.
1005
+ *
1006
+ * Use this when provider databases store structured JSON inside text columns.
1007
+ * Invalid JSON, arrays, and primitive values return `null` so callers can skip
1008
+ * malformed optional metadata without hiding the rest of a session transcript.
1009
+ */
1010
+ export function readJsonRecord(value: unknown): AnyRecord | null {
1011
+ if (typeof value !== 'string') {
1012
+ return readObjectRecord(value);
1013
+ }
1014
+
1015
+ try {
1016
+ return readObjectRecord(JSON.parse(value));
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ }
1021
+
1022
+ // ---------------------------
1023
+ //----------------- OPENCODE SESSION STORAGE UTILITIES ------------
1024
+ /**
1025
+ * Resolves the OpenCode SQLite session database path.
1026
+ *
1027
+ * OpenCode stores session, message, part, and project metadata in one shared
1028
+ * `opencode.db` file under its XDG data directory. Provider readers and
1029
+ * synchronizers should use this path for read-only access and should never store
1030
+ * it as a deletable transcript path for an individual app session row.
1031
+ */
1032
+ export function getOpenCodeDatabasePath(): string {
1033
+ return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
1034
+ }
1035
+
1036
+ // ---------------------------
1037
+ //----------------- SAFE DIRECTORY NAME UTILITIES ------------
1038
+ /**
1039
+ * Validates that a user or provider supplied identifier can safely be treated
1040
+ * as one leaf directory name under an existing root folder.
1041
+ *
1042
+ * Use this before composing paths like `<root>/<session-id>/file.db>` to block
1043
+ * path traversal and accidental nested paths. The returned string is trimmed but
1044
+ * otherwise unchanged so callers can still match the provider's on-disk naming.
1045
+ */
1046
+ export function sanitizeLeafDirectoryName(inputName: string, label = 'directory name'): string {
1047
+ const normalized = inputName.trim();
1048
+ if (!normalized) {
1049
+ throw new Error(`${label} is required.`);
1050
+ }
1051
+
1052
+ if (
1053
+ normalized.includes('..')
1054
+ || normalized.includes(path.posix.sep)
1055
+ || normalized.includes(path.win32.sep)
1056
+ || normalized !== path.basename(normalized)
1057
+ ) {
1058
+ throw new Error(`Invalid ${label} "${inputName}".`);
1059
+ }
1060
+
1061
+ return normalized;
1062
+ }
1063
+
680
1064
  // ---------------------------
681
1065
  //----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
682
1066
  /**