@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
@@ -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.
@@ -30,10 +30,12 @@ type ChatWebSocketDependencies = {
30
30
  spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
31
31
  queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
32
32
  spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
33
+ spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
33
34
  abortClaudeSDKSession: (sessionId: string, userId?: string | number | null) => Promise<boolean>;
34
35
  abortCursorSession: (sessionId: string, userId?: string | number | null) => boolean;
35
36
  abortCodexSession: (sessionId: string, userId?: string | number | null) => boolean;
36
37
  abortGeminiSession: (sessionId: string, userId?: string | number | null) => boolean;
38
+ abortOpenCodeSession: (sessionId: string) => boolean;
37
39
  resolveToolApproval: (
38
40
  requestId: string,
39
41
  payload: {
@@ -47,19 +49,21 @@ type ChatWebSocketDependencies = {
47
49
  isCursorSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
48
50
  isCodexSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
49
51
  isGeminiSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
52
+ isOpenCodeSessionActive: (sessionId: string) => boolean;
50
53
  reconnectSessionWriter: (sessionId: string, ws: WebSocket, userId?: string | number | null) => boolean;
51
54
  getPendingApprovalsForSession: (sessionId: string, userId?: string | number | null) => unknown[];
52
55
  getActiveClaudeSDKSessions: (userId?: string | number | null) => unknown;
53
56
  getActiveCursorSessions: (userId?: string | number | null) => unknown;
54
57
  getActiveCodexSessions: (userId?: string | number | null) => unknown;
55
58
  getActiveGeminiSessions: (userId?: string | number | null) => unknown;
59
+ getActiveOpenCodeSessions: () => unknown;
56
60
  };
57
61
 
58
62
  /**
59
63
  * Normalizes potentially invalid provider names coming from websocket payloads.
60
64
  */
61
65
  function readProvider(value: unknown): LLMProvider {
62
- if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
66
+ if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
63
67
  return value;
64
68
  }
65
69
 
@@ -199,6 +203,17 @@ export function handleChatConnection(
199
203
  return;
200
204
  }
201
205
 
206
+ if (messageType === 'opencode-command') {
207
+ const authorization = authorizeOptionsCwd(writer.userId, data.options);
208
+ if (!authorization.authorized) {
209
+ sendAuthorizationError(authorization.error, 'opencode');
210
+ return;
211
+ }
212
+ const sanitizedOptions = { ...(data.options ?? {}), cwd: authorization.cwd };
213
+ await dependencies.spawnOpenCode(data.command ?? '', sanitizedOptions, writer);
214
+ return;
215
+ }
216
+
202
217
  if (messageType === 'cursor-resume') {
203
218
  const authorization = authorizeOptionsCwd(writer.userId, data.options);
204
219
  if (!authorization.authorized) {
@@ -229,6 +244,8 @@ export function handleChatConnection(
229
244
  success = dependencies.abortCodexSession(sessionId, userId);
230
245
  } else if (provider === 'gemini') {
231
246
  success = dependencies.abortGeminiSession(sessionId, userId);
247
+ } else if (provider === 'opencode') {
248
+ success = dependencies.abortOpenCodeSession(sessionId);
232
249
  } else {
233
250
  success = await dependencies.abortClaudeSDKSession(sessionId, userId);
234
251
  }
@@ -286,6 +303,8 @@ export function handleChatConnection(
286
303
  isActive = dependencies.isCodexSessionActive(sessionId, userId);
287
304
  } else if (provider === 'gemini') {
288
305
  isActive = dependencies.isGeminiSessionActive(sessionId, userId);
306
+ } else if (provider === 'opencode') {
307
+ isActive = dependencies.isOpenCodeSessionActive(sessionId);
289
308
  } else {
290
309
  isActive = dependencies.isClaudeSDKSessionActive(sessionId, userId);
291
310
  if (isActive) {
@@ -325,6 +344,7 @@ export function handleChatConnection(
325
344
  cursor: dependencies.getActiveCursorSessions(userId),
326
345
  codex: dependencies.getActiveCodexSessions(userId),
327
346
  gemini: dependencies.getActiveGeminiSessions(userId),
347
+ opencode: dependencies.getActiveOpenCodeSessions(),
328
348
  },
329
349
  });
330
350
  }
@@ -138,6 +138,13 @@ function buildShellCommand(
138
138
  return command;
139
139
  }
140
140
 
141
+ if (provider === 'opencode') {
142
+ if (hasSession && sessionId) {
143
+ return `opencode --session "${sessionId}"`;
144
+ }
145
+ return initialCommand || 'opencode';
146
+ }
147
+
141
148
  const command = initialCommand || 'claude';
142
149
  if (hasSession && sessionId) {
143
150
  if (os.platform() === 'win32') {
@@ -425,6 +432,8 @@ export function handleShellConnection(
425
432
  ? 'Codex'
426
433
  : provider === 'gemini'
427
434
  ? 'Gemini'
435
+ : provider === 'opencode'
436
+ ? 'OpenCode'
428
437
  : 'Claude';
429
438
  welcomeMsg = hasSession
430
439
  ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
@@ -17,6 +17,7 @@ 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 – keys are namespaced as `${userId}:${sessionId}`
@@ -27,6 +28,34 @@ function buildCodexSessionKey(userId, sessionId) {
27
28
  return `${safeUser}:${sessionId}`;
28
29
  }
29
30
 
31
+ function readUsageNumber(value) {
32
+ const parsed = Number(value);
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ function extractCodexTokenBudget(event) {
37
+ const info = event?.info || event?.payload?.info || event?.usage?.info;
38
+ const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
39
+ if (!usage || typeof usage !== 'object') {
40
+ return null;
41
+ }
42
+
43
+ const inputTokens = readUsageNumber(usage.input_tokens);
44
+ const outputTokens = readUsageNumber(usage.output_tokens);
45
+ const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
46
+
47
+ return {
48
+ used,
49
+ total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
50
+ inputTokens,
51
+ outputTokens,
52
+ breakdown: {
53
+ input: inputTokens,
54
+ output: outputTokens,
55
+ },
56
+ };
57
+ }
58
+
30
59
  /**
31
60
  * Transform Codex SDK event to WebSocket message format
32
61
  * @param {object} event - SDK event
@@ -207,6 +236,11 @@ export async function queryCodex(command, options = {}, ws) {
207
236
  permissionMode = 'default'
208
237
  } = options;
209
238
 
239
+ const resolvedModel = await providerModelsService.resolveResumeModel(
240
+ 'codex',
241
+ sessionId,
242
+ model,
243
+ );
210
244
  const userId = ws?.userId || null;
211
245
  const workingDirectory = cwd || projectPath || process.cwd();
212
246
  const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
@@ -228,7 +262,7 @@ export async function queryCodex(command, options = {}, ws) {
228
262
  skipGitRepoCheck: true,
229
263
  sandboxMode,
230
264
  approvalPolicy,
231
- model
265
+ model: resolvedModel
232
266
  };
233
267
 
234
268
  // Start or resume thread
@@ -318,9 +352,11 @@ export async function queryCodex(command, options = {}, ws) {
318
352
  }
319
353
 
320
354
  // Extract and send token usage if available (normalized to match Claude format)
321
- if (event.type === 'turn.completed' && event.usage) {
322
- const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
323
- sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
355
+ if (event.type === 'turn.completed') {
356
+ const tokenBudget = extractCodexTokenBudget(event);
357
+ if (tokenBudget) {
358
+ sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
359
+ }
324
360
  }
325
361
  }
326
362
 
@@ -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
+ });