@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
@@ -377,6 +377,72 @@ test('providerSkillsService lists codex repository, user, and system skills', {
377
377
  }
378
378
  });
379
379
 
380
+ /**
381
+ * This test covers OpenCode skill lookup across cwd-to-git-root project folders
382
+ * plus the global OpenCode/Claude/Agents compatibility locations.
383
+ */
384
+ test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
385
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
386
+ const repoRoot = path.join(tempRoot, 'repo');
387
+ const workspacePath = path.join(repoRoot, 'packages', 'app');
388
+ await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
389
+ await fs.mkdir(workspacePath, { recursive: true });
390
+
391
+ const restoreHomeDir = patchHomeDir(tempRoot);
392
+ try {
393
+ await writeSkill(
394
+ path.join(workspacePath, '.opencode', 'skills'),
395
+ 'opencode-cwd-dir',
396
+ 'opencode-cwd',
397
+ 'OpenCode cwd skill',
398
+ );
399
+ await writeSkill(
400
+ path.join(repoRoot, 'packages', '.claude', 'skills'),
401
+ 'opencode-claude-parent-dir',
402
+ 'opencode-claude-parent',
403
+ 'OpenCode Claude parent skill',
404
+ );
405
+ await writeSkill(
406
+ path.join(repoRoot, '.agents', 'skills'),
407
+ 'opencode-agents-root-dir',
408
+ 'opencode-agents-root',
409
+ 'OpenCode Agents root skill',
410
+ );
411
+ await writeSkill(
412
+ path.join(tempRoot, '.config', 'opencode', 'skills'),
413
+ 'opencode-user-dir',
414
+ 'opencode-user',
415
+ 'OpenCode user skill',
416
+ );
417
+ await writeSkill(
418
+ path.join(tempRoot, '.claude', 'skills'),
419
+ 'opencode-claude-user-dir',
420
+ 'opencode-claude-user',
421
+ 'OpenCode Claude user skill',
422
+ );
423
+ await writeSkill(
424
+ path.join(tempRoot, '.agents', 'skills'),
425
+ 'opencode-agents-user-dir',
426
+ 'opencode-agents-user',
427
+ 'OpenCode Agents user skill',
428
+ );
429
+
430
+ const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
431
+ const byName = new Map(skills.map((skill) => [skill.name, skill]));
432
+
433
+ assert.equal(byName.get('opencode-cwd')?.scope, 'project');
434
+ assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
435
+ assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
436
+ assert.equal(byName.get('opencode-user')?.scope, 'user');
437
+ assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
438
+ assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
439
+ assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
440
+ } finally {
441
+ restoreHomeDir();
442
+ await fs.rm(tempRoot, { recursive: true, force: true });
443
+ }
444
+ });
445
+
380
446
  /**
381
447
  * This test covers Gemini and Cursor skill directory rules, including shared
382
448
  * `.agents/skills` project support.
@@ -29,10 +29,12 @@ type ChatWebSocketDependencies = {
29
29
  spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
30
30
  queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
31
31
  spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
32
+ spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
32
33
  abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
33
34
  abortCursorSession: (sessionId: string) => boolean;
34
35
  abortCodexSession: (sessionId: string) => boolean;
35
36
  abortGeminiSession: (sessionId: string) => boolean;
37
+ abortOpenCodeSession: (sessionId: string) => boolean;
36
38
  resolveToolApproval: (
37
39
  requestId: string,
38
40
  payload: {
@@ -46,19 +48,21 @@ type ChatWebSocketDependencies = {
46
48
  isCursorSessionActive: (sessionId: string) => boolean;
47
49
  isCodexSessionActive: (sessionId: string) => boolean;
48
50
  isGeminiSessionActive: (sessionId: string) => boolean;
51
+ isOpenCodeSessionActive: (sessionId: string) => boolean;
49
52
  reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
50
53
  getPendingApprovalsForSession: (sessionId: string) => unknown[];
51
54
  getActiveClaudeSDKSessions: () => unknown;
52
55
  getActiveCursorSessions: () => unknown;
53
56
  getActiveCodexSessions: () => unknown;
54
57
  getActiveGeminiSessions: () => unknown;
58
+ getActiveOpenCodeSessions: () => unknown;
55
59
  };
56
60
 
57
61
  /**
58
62
  * Normalizes potentially invalid provider names coming from websocket payloads.
59
63
  */
60
64
  function readProvider(value: unknown): LLMProvider {
61
- if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
65
+ if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
62
66
  return value;
63
67
  }
64
68
 
@@ -134,6 +138,11 @@ export function handleChatConnection(
134
138
  return;
135
139
  }
136
140
 
141
+ if (messageType === 'opencode-command') {
142
+ await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
143
+ return;
144
+ }
145
+
137
146
  if (messageType === 'cursor-resume') {
138
147
  await dependencies.spawnCursor(
139
148
  '',
@@ -158,6 +167,8 @@ export function handleChatConnection(
158
167
  success = dependencies.abortCodexSession(sessionId);
159
168
  } else if (provider === 'gemini') {
160
169
  success = dependencies.abortGeminiSession(sessionId);
170
+ } else if (provider === 'opencode') {
171
+ success = dependencies.abortOpenCodeSession(sessionId);
161
172
  } else {
162
173
  success = await dependencies.abortClaudeSDKSession(sessionId);
163
174
  }
@@ -214,6 +225,8 @@ export function handleChatConnection(
214
225
  isActive = dependencies.isCodexSessionActive(sessionId);
215
226
  } else if (provider === 'gemini') {
216
227
  isActive = dependencies.isGeminiSessionActive(sessionId);
228
+ } else if (provider === 'opencode') {
229
+ isActive = dependencies.isOpenCodeSessionActive(sessionId);
217
230
  } else {
218
231
  isActive = dependencies.isClaudeSDKSessionActive(sessionId);
219
232
  if (isActive) {
@@ -251,6 +264,7 @@ export function handleChatConnection(
251
264
  cursor: dependencies.getActiveCursorSessions(),
252
265
  codex: dependencies.getActiveCodexSessions(),
253
266
  gemini: dependencies.getActiveGeminiSessions(),
267
+ opencode: dependencies.getActiveOpenCodeSessions(),
254
268
  },
255
269
  });
256
270
  }
@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
26
26
  console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
27
27
  });
28
28
 
29
- upstream.on('message', (data) => {
29
+ upstream.on('message', (data, isBinary) => {
30
30
  if (clientWs.readyState === WebSocket.OPEN) {
31
- clientWs.send(data);
31
+ clientWs.send(data, { binary: isBinary });
32
32
  }
33
33
  });
34
34
 
35
- clientWs.on('message', (data) => {
35
+ clientWs.on('message', (data, isBinary) => {
36
36
  if (upstream.readyState === WebSocket.OPEN) {
37
- upstream.send(data);
37
+ upstream.send(data, { binary: isBinary });
38
38
  }
39
39
  });
40
40
 
@@ -136,6 +136,13 @@ function buildShellCommand(
136
136
  return command;
137
137
  }
138
138
 
139
+ if (provider === 'opencode') {
140
+ if (hasSession && sessionId) {
141
+ return `opencode --session "${sessionId}"`;
142
+ }
143
+ return initialCommand || 'opencode';
144
+ }
145
+
139
146
  const command = initialCommand || 'claude';
140
147
  if (hasSession && sessionId) {
141
148
  if (os.platform() === 'win32') {
@@ -389,6 +396,8 @@ export function handleShellConnection(
389
396
  ? 'Codex'
390
397
  : provider === 'gemini'
391
398
  ? 'Gemini'
399
+ : provider === 'opencode'
400
+ ? 'OpenCode'
392
401
  : 'Claude';
393
402
  welcomeMsg = hasSession
394
403
  ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
@@ -17,11 +17,40 @@ import { Codex } from '@openai/codex-sdk';
17
17
  import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
18
18
  import { sessionsService } from './modules/providers/services/sessions.service.js';
19
19
  import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
20
+ import { providerModelsService } from './modules/providers/services/provider-models.service.js';
20
21
  import { createNormalizedMessage } from './shared/utils.js';
21
22
 
22
23
  // Track active sessions
23
24
  const activeCodexSessions = new Map();
24
25
 
26
+ function readUsageNumber(value) {
27
+ const parsed = Number(value);
28
+ return Number.isFinite(parsed) ? parsed : 0;
29
+ }
30
+
31
+ function extractCodexTokenBudget(event) {
32
+ const info = event?.info || event?.payload?.info || event?.usage?.info;
33
+ const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
34
+ if (!usage || typeof usage !== 'object') {
35
+ return null;
36
+ }
37
+
38
+ const inputTokens = readUsageNumber(usage.input_tokens);
39
+ const outputTokens = readUsageNumber(usage.output_tokens);
40
+ const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
41
+
42
+ return {
43
+ used,
44
+ total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
45
+ inputTokens,
46
+ outputTokens,
47
+ breakdown: {
48
+ input: inputTokens,
49
+ output: outputTokens,
50
+ },
51
+ };
52
+ }
53
+
25
54
  /**
26
55
  * Transform Codex SDK event to WebSocket message format
27
56
  * @param {object} event - SDK event
@@ -202,6 +231,12 @@ export async function queryCodex(command, options = {}, ws) {
202
231
  permissionMode = 'default'
203
232
  } = options;
204
233
 
234
+ const resolvedModel = await providerModelsService.resolveResumeModel(
235
+ 'codex',
236
+ sessionId,
237
+ model,
238
+ );
239
+
205
240
  const workingDirectory = cwd || projectPath || process.cwd();
206
241
  const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
207
242
 
@@ -222,7 +257,7 @@ export async function queryCodex(command, options = {}, ws) {
222
257
  skipGitRepoCheck: true,
223
258
  sandboxMode,
224
259
  approvalPolicy,
225
- model
260
+ model: resolvedModel
226
261
  };
227
262
 
228
263
  // Start or resume thread
@@ -309,9 +344,11 @@ export async function queryCodex(command, options = {}, ws) {
309
344
  }
310
345
 
311
346
  // Extract and send token usage if available (normalized to match Claude format)
312
- if (event.type === 'turn.completed' && event.usage) {
313
- const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
314
- sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
347
+ if (event.type === 'turn.completed') {
348
+ const tokenBudget = extractCodexTokenBudget(event);
349
+ if (tokenBudget) {
350
+ sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
351
+ }
315
352
  }
316
353
  }
317
354
 
@@ -0,0 +1,336 @@
1
+ import { spawn } from 'child_process';
2
+ import fsSync from 'node:fs';
3
+
4
+ import crossSpawn from 'cross-spawn';
5
+ import Database from 'better-sqlite3';
6
+
7
+ import { sessionsService } from './modules/providers/services/sessions.service.js';
8
+ import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
9
+ import { providerModelsService } from './modules/providers/services/provider-models.service.js';
10
+ import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
11
+ import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
12
+
13
+ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
14
+
15
+ const activeOpenCodeProcesses = new Map();
16
+
17
+ function readOpenCodeSessionId(event) {
18
+ if (!event || typeof event !== 'object') {
19
+ return null;
20
+ }
21
+
22
+ return event.sessionID || event.sessionId || null;
23
+ }
24
+
25
+ function readOpenCodeTokenUsage(sessionId) {
26
+ const dbPath = getOpenCodeDatabasePath();
27
+ if (!sessionId || !fsSync.existsSync(dbPath)) {
28
+ return null;
29
+ }
30
+
31
+ let db = null;
32
+ try {
33
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
34
+ const columns = db.prepare('PRAGMA table_info(session)').all();
35
+ const columnNames = new Set(columns.map((column) => column.name));
36
+ const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
37
+ if (!requiredColumns.every((column) => columnNames.has(column))) {
38
+ return null;
39
+ }
40
+
41
+ const row = db.prepare(`
42
+ SELECT
43
+ tokens_input AS inputTokens,
44
+ tokens_output AS outputTokens,
45
+ tokens_reasoning AS reasoningTokens,
46
+ tokens_cache_read AS cacheReadTokens,
47
+ tokens_cache_write AS cacheWriteTokens
48
+ FROM session
49
+ WHERE id = ?
50
+ `).get(sessionId);
51
+
52
+ if (!row) {
53
+ return null;
54
+ }
55
+
56
+ const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
57
+ const outputTokens = Number(row.outputTokens || 0);
58
+ const used = Number(row.inputTokens || 0)
59
+ + outputTokens
60
+ + Number(row.reasoningTokens || 0)
61
+ + Number(row.cacheReadTokens || 0)
62
+ + Number(row.cacheWriteTokens || 0);
63
+ if (used <= 0) {
64
+ return null;
65
+ }
66
+
67
+ return {
68
+ used,
69
+ inputTokens,
70
+ outputTokens,
71
+ breakdown: {
72
+ input: inputTokens,
73
+ output: outputTokens,
74
+ },
75
+ };
76
+ } catch {
77
+ return null;
78
+ } finally {
79
+ if (db) {
80
+ db.close();
81
+ }
82
+ }
83
+ }
84
+
85
+ async function spawnOpenCode(command, options = {}, ws) {
86
+ return new Promise((resolve, reject) => {
87
+ const { sessionId, projectPath, cwd, model, sessionSummary } = options;
88
+ const workingDir = cwd || projectPath || process.cwd();
89
+ const processKey = sessionId || Date.now().toString();
90
+ let capturedSessionId = sessionId || null;
91
+ let sessionCreatedSent = false;
92
+ let stdoutLineBuffer = '';
93
+ let terminalNotificationSent = false;
94
+ let opencodeProcess = null;
95
+
96
+ const notifyTerminalState = ({ code = null, error = null } = {}) => {
97
+ if (terminalNotificationSent) {
98
+ return;
99
+ }
100
+
101
+ terminalNotificationSent = true;
102
+ const finalSessionId = capturedSessionId || sessionId || processKey;
103
+ if (code === 0 && !error) {
104
+ notifyRunStopped({
105
+ userId: ws?.userId || null,
106
+ provider: 'opencode',
107
+ sessionId: finalSessionId,
108
+ sessionName: sessionSummary,
109
+ stopReason: 'completed',
110
+ });
111
+ return;
112
+ }
113
+
114
+ notifyRunFailed({
115
+ userId: ws?.userId || null,
116
+ provider: 'opencode',
117
+ sessionId: finalSessionId,
118
+ sessionName: sessionSummary,
119
+ error: error || `OpenCode CLI exited with code ${code}`,
120
+ });
121
+ };
122
+
123
+ const registerSession = (nextSessionId) => {
124
+ if (!nextSessionId || capturedSessionId === nextSessionId) {
125
+ return;
126
+ }
127
+
128
+ capturedSessionId = nextSessionId;
129
+ if (processKey !== capturedSessionId && opencodeProcess) {
130
+ activeOpenCodeProcesses.delete(processKey);
131
+ activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess);
132
+ }
133
+ if (opencodeProcess) {
134
+ opencodeProcess.sessionId = capturedSessionId;
135
+ }
136
+
137
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
138
+ ws.setSessionId(capturedSessionId);
139
+ }
140
+
141
+ if (!sessionId && !sessionCreatedSent) {
142
+ sessionCreatedSent = true;
143
+ ws.send(createNormalizedMessage({
144
+ kind: 'session_created',
145
+ newSessionId: capturedSessionId,
146
+ sessionId: capturedSessionId,
147
+ provider: 'opencode',
148
+ }));
149
+ }
150
+ };
151
+
152
+ const processOpenCodeOutputLine = (line) => {
153
+ if (!line || !line.trim()) {
154
+ return;
155
+ }
156
+
157
+ let response;
158
+ try {
159
+ response = JSON.parse(line);
160
+ } catch {
161
+ ws.send(createNormalizedMessage({
162
+ kind: 'stream_delta',
163
+ content: line,
164
+ sessionId: capturedSessionId || sessionId || null,
165
+ provider: 'opencode',
166
+ }));
167
+ return;
168
+ }
169
+
170
+ try {
171
+ registerSession(readOpenCodeSessionId(response));
172
+ const normalized = sessionsService.normalizeMessage(
173
+ 'opencode',
174
+ response,
175
+ capturedSessionId || sessionId || null,
176
+ );
177
+ for (const msg of normalized) {
178
+ ws.send(msg);
179
+ }
180
+ } catch (error) {
181
+ const errorContent = error instanceof Error ? error.message : String(error);
182
+ console.error('[OpenCode] Failed to process JSON output:', errorContent);
183
+ ws.send(createNormalizedMessage({
184
+ kind: 'error',
185
+ content: errorContent,
186
+ sessionId: capturedSessionId || sessionId || null,
187
+ provider: 'opencode',
188
+ }));
189
+ }
190
+ };
191
+
192
+ void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
193
+ const args = ['run', '--format', 'json'];
194
+ if (sessionId) {
195
+ args.push('--session', sessionId);
196
+ }
197
+ if (resolvedModel) {
198
+ args.push('--model', resolvedModel);
199
+ }
200
+ if (command && command.trim()) {
201
+ args.push(command.trim());
202
+ }
203
+
204
+ opencodeProcess = spawnFunction('opencode', args, {
205
+ cwd: workingDir,
206
+ stdio: ['pipe', 'pipe', 'pipe'],
207
+ env: { ...process.env },
208
+ });
209
+
210
+ activeOpenCodeProcesses.set(processKey, opencodeProcess);
211
+ opencodeProcess.sessionId = processKey;
212
+ opencodeProcess.stdin.end();
213
+
214
+ opencodeProcess.stdout.on('data', (data) => {
215
+ stdoutLineBuffer += data.toString();
216
+ const completeLines = stdoutLineBuffer.split(/\r?\n/);
217
+ stdoutLineBuffer = completeLines.pop() || '';
218
+
219
+ completeLines.forEach((line) => {
220
+ processOpenCodeOutputLine(line.trim());
221
+ });
222
+ });
223
+
224
+ opencodeProcess.stderr.on('data', (data) => {
225
+ const stderrText = data.toString();
226
+ if (!stderrText.trim()) {
227
+ return;
228
+ }
229
+
230
+ ws.send(createNormalizedMessage({
231
+ kind: 'error',
232
+ content: stderrText,
233
+ sessionId: capturedSessionId || sessionId || null,
234
+ provider: 'opencode',
235
+ }));
236
+ });
237
+
238
+ opencodeProcess.on('close', async (code) => {
239
+ const finalSessionId = capturedSessionId || sessionId || processKey;
240
+ activeOpenCodeProcesses.delete(finalSessionId);
241
+ activeOpenCodeProcesses.delete(processKey);
242
+
243
+ if (stdoutLineBuffer.trim()) {
244
+ processOpenCodeOutputLine(stdoutLineBuffer.trim());
245
+ stdoutLineBuffer = '';
246
+ }
247
+
248
+ const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
249
+ if (tokenBudget) {
250
+ ws.send(createNormalizedMessage({
251
+ kind: 'status',
252
+ text: 'token_budget',
253
+ tokenBudget,
254
+ sessionId: finalSessionId,
255
+ provider: 'opencode',
256
+ }));
257
+ }
258
+
259
+ ws.send(createNormalizedMessage({
260
+ kind: 'complete',
261
+ exitCode: code,
262
+ isNewSession: !sessionId && !!command,
263
+ sessionId: finalSessionId,
264
+ provider: 'opencode',
265
+ }));
266
+
267
+ if (code === 0) {
268
+ notifyTerminalState({ code });
269
+ resolve();
270
+ return;
271
+ }
272
+
273
+ if (code === 127 || code === null) {
274
+ const installed = await providerAuthService.isProviderInstalled('opencode');
275
+ if (!installed) {
276
+ ws.send(createNormalizedMessage({
277
+ kind: 'error',
278
+ content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/',
279
+ sessionId: finalSessionId,
280
+ provider: 'opencode',
281
+ }));
282
+ }
283
+ }
284
+
285
+ notifyTerminalState({ code });
286
+ reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`));
287
+ });
288
+
289
+ opencodeProcess.on('error', async (error) => {
290
+ const finalSessionId = capturedSessionId || sessionId || processKey;
291
+ activeOpenCodeProcesses.delete(finalSessionId);
292
+ activeOpenCodeProcesses.delete(processKey);
293
+
294
+ const installed = await providerAuthService.isProviderInstalled('opencode');
295
+ const errorContent = !installed
296
+ ? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/'
297
+ : error.message;
298
+
299
+ ws.send(createNormalizedMessage({
300
+ kind: 'error',
301
+ content: errorContent,
302
+ sessionId: finalSessionId,
303
+ provider: 'opencode',
304
+ }));
305
+ notifyTerminalState({ error });
306
+ reject(error);
307
+ });
308
+ }).catch(reject);
309
+ });
310
+ }
311
+
312
+ function abortOpenCodeSession(sessionId) {
313
+ const process = activeOpenCodeProcesses.get(sessionId);
314
+ if (!process) {
315
+ return false;
316
+ }
317
+
318
+ process.kill('SIGTERM');
319
+ activeOpenCodeProcesses.delete(sessionId);
320
+ return true;
321
+ }
322
+
323
+ function isOpenCodeSessionActive(sessionId) {
324
+ return activeOpenCodeProcesses.has(sessionId);
325
+ }
326
+
327
+ function getActiveOpenCodeSessions() {
328
+ return Array.from(activeOpenCodeProcesses.keys());
329
+ }
330
+
331
+ export {
332
+ spawnOpenCode,
333
+ abortOpenCodeSession,
334
+ isOpenCodeSessionActive,
335
+ getActiveOpenCodeSessions,
336
+ };
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { spawnOpenCode } from './opencode-cli.js';
8
+
9
+ const findEnvKey = (name) =>
10
+ Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
11
+
12
+ async function createFakeOpenCodeExecutable(binDir) {
13
+ const scriptPath = path.join(binDir, 'opencode.js');
14
+ await writeFile(scriptPath, `
15
+ const events = [
16
+ { type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
17
+ { type: 'step_finish', sessionID: 'open-live-1' },
18
+ ];
19
+
20
+ for (const event of events) {
21
+ console.log(JSON.stringify(event));
22
+ }
23
+ `, 'utf8');
24
+
25
+ if (process.platform === 'win32') {
26
+ const commandPath = path.join(binDir, 'opencode.cmd');
27
+ await writeFile(commandPath, '@echo off\r\nnode "%~dp0opencode.js" %*\r\n', 'utf8');
28
+ return;
29
+ }
30
+
31
+ const commandPath = path.join(binDir, 'opencode');
32
+ await writeFile(commandPath, '#!/bin/sh\nnode "$(dirname "$0")/opencode.js" "$@"\n', 'utf8');
33
+ await chmod(commandPath, 0o755);
34
+ }
35
+
36
+ test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
37
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
38
+ const pathKey = findEnvKey('PATH');
39
+ const pathExtKey = findEnvKey('PATHEXT');
40
+ const previousPath = process.env[pathKey];
41
+ const previousPathExt = process.env[pathExtKey];
42
+ const messages = [];
43
+ const writer = {
44
+ userId: null,
45
+ sessionId: null,
46
+ send(message) {
47
+ messages.push(message);
48
+ },
49
+ setSessionId(sessionId) {
50
+ this.sessionId = sessionId;
51
+ },
52
+ };
53
+
54
+ try {
55
+ await createFakeOpenCodeExecutable(tempRoot);
56
+ process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
57
+ if (process.platform === 'win32') {
58
+ process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
59
+ ? previousPathExt
60
+ : `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
61
+ }
62
+
63
+ await spawnOpenCode('Hi', { cwd: tempRoot }, writer);
64
+
65
+ const sessionCreatedIndex = messages.findIndex((message) => message.kind === 'session_created');
66
+ const assistantDeltaIndex = messages.findIndex((message) =>
67
+ message.kind === 'stream_delta' && message.content === 'assistant response',
68
+ );
69
+ const streamEnd = messages.find((message) => message.kind === 'stream_end');
70
+ const complete = messages.find((message) => message.kind === 'complete');
71
+
72
+ assert.notEqual(sessionCreatedIndex, -1);
73
+ assert.notEqual(assistantDeltaIndex, -1);
74
+ assert.ok(sessionCreatedIndex < assistantDeltaIndex);
75
+ assert.equal(messages[sessionCreatedIndex].newSessionId, 'open-live-1');
76
+ assert.equal(writer.sessionId, 'open-live-1');
77
+ assert.equal(streamEnd?.sessionId, 'open-live-1');
78
+ assert.equal(complete?.sessionId, 'open-live-1');
79
+ assert.equal(messages.some((message) => message.kind === 'error'), false);
80
+ } finally {
81
+ if (previousPath === undefined) {
82
+ delete process.env[pathKey];
83
+ } else {
84
+ process.env[pathKey] = previousPath;
85
+ }
86
+
87
+ if (previousPathExt === undefined) {
88
+ delete process.env[pathExtKey];
89
+ } else {
90
+ process.env[pathExtKey] = previousPathExt;
91
+ }
92
+
93
+ await rm(tempRoot, { recursive: true, force: true });
94
+ }
95
+ });