@cloudcli-ai/cloudcli 1.35.1 → 1.36.1

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 (223) hide show
  1. package/README.de.md +17 -18
  2. package/README.ja.md +16 -17
  3. package/README.ko.md +17 -18
  4. package/README.md +19 -20
  5. package/README.ru.md +17 -18
  6. package/README.tr.md +17 -18
  7. package/README.zh-CN.md +17 -18
  8. package/README.zh-TW.md +17 -18
  9. package/dist/api-docs.html +8 -3
  10. package/dist/assets/{index-D1IdCTT6.js → index-Bz9vXV2m.js} +265 -260
  11. package/dist/assets/index-DLmhQ15L.css +32 -0
  12. package/dist/assets/{vendor-xterm-CJZjLICi.js → vendor-xterm-CS4rQ5Mr.js} +12 -12
  13. package/dist/index.html +3 -3
  14. package/dist-server/server/claude-sdk.js +62 -116
  15. package/dist-server/server/claude-sdk.js.map +1 -1
  16. package/dist-server/server/cli.js +1 -4
  17. package/dist-server/server/cli.js.map +1 -1
  18. package/dist-server/server/cursor-cli.js +12 -11
  19. package/dist-server/server/cursor-cli.js.map +1 -1
  20. package/dist-server/server/index.js +10 -141
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/assets/assets.routes.js +89 -0
  23. package/dist-server/server/modules/assets/assets.routes.js.map +1 -0
  24. package/dist-server/server/modules/assets/index.js +4 -0
  25. package/dist-server/server/modules/assets/index.js.map +1 -0
  26. package/dist-server/server/modules/assets/services/image-assets.service.js +57 -0
  27. package/dist-server/server/modules/assets/services/image-assets.service.js.map +1 -0
  28. package/dist-server/server/modules/assets/tests/image-assets.service.test.js +36 -0
  29. package/dist-server/server/modules/assets/tests/image-assets.service.test.js.map +1 -0
  30. package/dist-server/server/modules/browser-use/browser-use.service.js +6 -3
  31. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
  32. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -2
  33. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  34. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  35. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
  36. package/dist-server/server/modules/projects/services/project-clone.service.js +2 -1
  37. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +70 -1
  39. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +28 -0
  41. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +52 -11
  43. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +54 -1
  45. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +36 -1
  47. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  48. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +3 -17
  49. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js +7 -1
  51. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +49 -13
  53. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  54. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +0 -1
  55. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -1
  56. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +145 -14
  57. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  58. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +24 -5
  59. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  60. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +10 -21
  61. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  62. package/dist-server/server/modules/providers/provider.registry.js +0 -2
  63. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  64. package/dist-server/server/modules/providers/provider.routes.js +0 -1
  65. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  66. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +11 -13
  67. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/provider-models.service.js +2 -2
  69. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +3 -77
  71. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +0 -1
  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 +0 -13
  75. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +96 -0
  77. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -0
  78. package/dist-server/server/modules/providers/tests/mcp.test.js +3 -29
  79. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  80. package/dist-server/server/modules/providers/tests/opencode-models.test.js +65 -1
  81. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  82. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +117 -0
  83. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  84. package/dist-server/server/modules/providers/tests/provider-image-history.test.js +147 -0
  85. package/dist-server/server/modules/providers/tests/provider-image-history.test.js.map +1 -0
  86. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +8 -8
  87. package/dist-server/server/modules/providers/tests/skills.test.js +3 -26
  88. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +14 -0
  90. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +36 -2
  92. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  93. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +3 -12
  94. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  95. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js +33 -0
  96. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js.map +1 -0
  97. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +37 -4
  98. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  99. package/dist-server/server/openai-codex.js +16 -9
  100. package/dist-server/server/openai-codex.js.map +1 -1
  101. package/dist-server/server/opencode-cli.js +62 -7
  102. package/dist-server/server/opencode-cli.js.map +1 -1
  103. package/dist-server/server/opencode-cli.test.js +103 -3
  104. package/dist-server/server/opencode-cli.test.js.map +1 -1
  105. package/dist-server/server/routes/agent.js +22 -21
  106. package/dist-server/server/routes/agent.js.map +1 -1
  107. package/dist-server/server/routes/commands.js +1 -2
  108. package/dist-server/server/routes/commands.js.map +1 -1
  109. package/dist-server/server/routes/git.js +179 -53
  110. package/dist-server/server/routes/git.js.map +1 -1
  111. package/dist-server/server/routes/git.test.js +80 -0
  112. package/dist-server/server/routes/git.test.js.map +1 -0
  113. package/dist-server/server/routes/taskmaster.js +3 -1
  114. package/dist-server/server/routes/taskmaster.js.map +1 -1
  115. package/dist-server/server/routes/user.js +2 -1
  116. package/dist-server/server/routes/user.js.map +1 -1
  117. package/dist-server/server/shared/image-attachments.js +270 -0
  118. package/dist-server/server/shared/image-attachments.js.map +1 -0
  119. package/dist-server/server/shared/tests/image-attachments.test.js +220 -0
  120. package/dist-server/server/shared/tests/image-attachments.test.js.map +1 -0
  121. package/dist-server/server/shared/utils.js +43 -0
  122. package/dist-server/server/shared/utils.js.map +1 -1
  123. package/dist-server/server/utils/gitConfig.js +2 -1
  124. package/dist-server/server/utils/gitConfig.js.map +1 -1
  125. package/dist-server/server/utils/plugin-process-manager.js +2 -1
  126. package/dist-server/server/utils/plugin-process-manager.js.map +1 -1
  127. package/electron/launcher/launcher.js +1 -1
  128. package/package.json +3 -3
  129. package/public/api-docs.html +8 -3
  130. package/server/claude-sdk.js +69 -131
  131. package/server/cli.js +1 -4
  132. package/server/cursor-cli.js +13 -12
  133. package/server/index.js +11 -159
  134. package/server/modules/assets/assets.routes.ts +103 -0
  135. package/server/modules/assets/index.ts +3 -0
  136. package/server/modules/assets/services/image-assets.service.ts +82 -0
  137. package/server/modules/assets/tests/image-assets.service.test.ts +46 -0
  138. package/server/modules/browser-use/browser-use.service.ts +7 -3
  139. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -2
  140. package/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  141. package/server/modules/projects/services/project-clone.service.ts +3 -1
  142. package/server/modules/providers/README.md +1 -7
  143. package/server/modules/providers/list/claude/claude-models.provider.ts +73 -1
  144. package/server/modules/providers/list/claude/claude-sessions.provider.ts +30 -0
  145. package/server/modules/providers/list/codex/codex-models.provider.ts +59 -11
  146. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +60 -1
  147. package/server/modules/providers/list/codex/codex-sessions.provider.ts +41 -1
  148. package/server/modules/providers/list/cursor/cursor-models.provider.ts +3 -17
  149. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +7 -1
  150. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +56 -15
  151. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +0 -1
  152. package/server/modules/providers/list/opencode/opencode-models.provider.ts +193 -18
  153. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +25 -4
  154. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +10 -21
  155. package/server/modules/providers/provider.registry.ts +0 -2
  156. package/server/modules/providers/provider.routes.ts +0 -1
  157. package/server/modules/providers/services/provider-capabilities.service.ts +13 -13
  158. package/server/modules/providers/services/provider-models.service.ts +2 -2
  159. package/server/modules/providers/services/session-conversations-search.service.ts +3 -93
  160. package/server/modules/providers/services/session-synchronizer.service.ts +0 -1
  161. package/server/modules/providers/services/sessions-watcher.service.ts +0 -14
  162. package/server/modules/providers/tests/codex-sessions.test.ts +111 -0
  163. package/server/modules/providers/tests/mcp.test.ts +3 -34
  164. package/server/modules/providers/tests/opencode-models.test.ts +69 -0
  165. package/server/modules/providers/tests/opencode-sessions.test.ts +139 -0
  166. package/server/modules/providers/tests/provider-image-history.test.ts +180 -0
  167. package/server/modules/providers/tests/provider-models.service.test.ts +8 -8
  168. package/server/modules/providers/tests/skills.test.ts +3 -42
  169. package/server/modules/websocket/services/chat-run-registry.service.ts +16 -0
  170. package/server/modules/websocket/services/chat-websocket.service.ts +41 -2
  171. package/server/modules/websocket/services/shell-websocket.service.ts +1 -11
  172. package/server/modules/websocket/tests/chat-image-filter.test.ts +44 -0
  173. package/server/modules/websocket/tests/chat-run-registry.test.ts +42 -4
  174. package/server/openai-codex.js +18 -8
  175. package/server/opencode-cli.js +64 -7
  176. package/server/opencode-cli.test.js +112 -3
  177. package/server/routes/agent.js +22 -21
  178. package/server/routes/commands.js +1 -2
  179. package/server/routes/git.js +201 -60
  180. package/server/routes/git.test.js +106 -0
  181. package/server/routes/taskmaster.js +3 -1
  182. package/server/routes/user.js +2 -1
  183. package/server/shared/image-attachments.ts +341 -0
  184. package/server/shared/tests/image-attachments.test.ts +298 -0
  185. package/server/shared/types.ts +8 -1
  186. package/server/shared/utils.ts +45 -0
  187. package/server/utils/gitConfig.js +2 -1
  188. package/server/utils/plugin-process-manager.js +3 -1
  189. package/dist/assets/index-BvX30NW5.css +0 -32
  190. package/dist/icons/gemini-ai-icon.svg +0 -1
  191. package/dist-server/server/gemini-cli.js +0 -551
  192. package/dist-server/server/gemini-cli.js.map +0 -1
  193. package/dist-server/server/gemini-response-handler.js +0 -106
  194. package/dist-server/server/gemini-response-handler.js.map +0 -1
  195. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +0 -254
  196. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +0 -1
  197. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js +0 -82
  198. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js.map +0 -1
  199. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +0 -24
  200. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +0 -1
  201. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js +0 -312
  202. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js.map +0 -1
  203. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +0 -474
  204. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +0 -1
  205. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +0 -40
  206. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +0 -1
  207. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +0 -19
  208. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +0 -1
  209. package/dist-server/server/routes/gemini.js +0 -21
  210. package/dist-server/server/routes/gemini.js.map +0 -1
  211. package/dist-server/server/sessionManager.js +0 -194
  212. package/dist-server/server/sessionManager.js.map +0 -1
  213. package/server/gemini-cli.js +0 -638
  214. package/server/gemini-response-handler.js +0 -117
  215. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +0 -307
  216. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +0 -110
  217. package/server/modules/providers/list/gemini/gemini-models.provider.ts +0 -39
  218. package/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +0 -405
  219. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +0 -540
  220. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +0 -44
  221. package/server/modules/providers/list/gemini/gemini.provider.ts +0 -27
  222. package/server/routes/gemini.js +0 -25
  223. package/server/sessionManager.js +0 -226
@@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills
444
444
  });
445
445
 
446
446
  /**
447
- * This test covers Gemini and Cursor skill directory rules, including shared
447
+ * This test covers Cursor skill directory rules, including shared
448
448
  * `.agents/skills` project support.
449
449
  */
450
- test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
450
+ test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => {
451
451
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
452
452
  const workspacePath = path.join(tempRoot, 'workspace');
453
453
  await fs.mkdir(workspacePath, { recursive: true });
454
454
 
455
455
  const restoreHomeDir = patchHomeDir(tempRoot);
456
456
  try {
457
- await writeSkill(
458
- path.join(tempRoot, '.gemini', 'skills'),
459
- 'gemini-user-dir',
460
- 'gemini-user',
461
- 'Gemini user skill',
462
- );
463
457
  await writeSkill(
464
458
  path.join(tempRoot, '.agents', 'skills'),
465
459
  'agents-user-dir',
466
460
  'agents-user',
467
461
  'Agents user skill',
468
462
  );
469
- await writeSkill(
470
- path.join(workspacePath, '.gemini', 'skills'),
471
- 'gemini-project-dir',
472
- 'gemini-project',
473
- 'Gemini project skill',
474
- );
475
463
  await writeSkill(
476
464
  path.join(workspacePath, '.agents', 'skills'),
477
465
  'agents-project-dir',
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
491
479
  'Cursor user skill',
492
480
  );
493
481
 
494
- const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
495
- const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
496
- assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
497
- assert.equal(geminiByName.get('agents-user')?.scope, 'user');
498
- assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
499
- assert.equal(geminiByName.get('agents-project')?.scope, 'project');
500
- assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
501
-
502
482
  const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
503
483
  const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
504
484
  assert.equal(cursorByName.get('agents-project')?.scope, 'project');
@@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured
515
495
  * This test covers managed global skill creation for providers that own a
516
496
  * writable user skill directory.
517
497
  */
518
- test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
498
+ test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => {
519
499
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
520
500
  const restoreHomeDir = patchHomeDir(tempRoot);
521
501
 
@@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
618
598
  );
619
599
  await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
620
600
 
621
- const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
622
- entries: [
623
- {
624
- directoryName: 'gemini-global-dir',
625
- content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
626
- },
627
- ],
628
- });
629
- const createdGeminiSkill = createdGeminiSkills[0];
630
- assert.ok(createdGeminiSkill);
631
- assert.equal(createdGeminiSkill.command, '/gemini-global');
632
- assert.equal(
633
- createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
634
- true,
635
- );
636
-
637
601
  const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
638
602
  entries: [
639
603
  {
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
656
620
  const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
657
621
  assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
658
622
 
659
- const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
660
- assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
661
-
662
623
  const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
663
624
  assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
664
625
 
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
318
318
  run.writer.sendComplete(opts);
319
319
  },
320
320
 
321
+ /**
322
+ * Safety-net variant of `completeRun` scoped to one specific run: a no-op
323
+ * unless `run` is still the session's current, running run. A runtime
324
+ * promise can resolve after its own `complete` already streamed AND a new
325
+ * run has replaced it in the registry (a queued message sends within
326
+ * milliseconds of the previous turn ending) — the session-keyed
327
+ * `completeRun` would terminate that newer run.
328
+ */
329
+ completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
330
+ if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
331
+ return;
332
+ }
333
+
334
+ run.writer.sendComplete(opts);
335
+ },
336
+
321
337
  /**
322
338
  * Test-only escape hatch: clears every tracked run.
323
339
  */
@@ -1,8 +1,11 @@
1
+ import path from 'node:path';
2
+
1
3
  import type { WebSocket } from 'ws';
2
4
 
3
5
  import { sessionsDb } from '@/modules/database/index.js';
4
6
  import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
5
7
  import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
8
+ import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js';
6
9
  import type {
7
10
  AnyRecord,
8
11
  AuthenticatedWebSocketRequest,
@@ -10,6 +13,37 @@ import type {
10
13
  } from '@/shared/types.js';
11
14
  import { parseIncomingJsonObject } from '@/shared/utils.js';
12
15
 
16
+ /**
17
+ * Trust boundary for client-supplied image attachments: chat.send options come
18
+ * straight from the browser, and the provider runtimes read the referenced
19
+ * files off disk (Claude base64-encodes them into the prompt). Only images
20
+ * that live directly inside the global upload store (`~/.cloudcli/assets`,
21
+ * where POST /api/assets/images puts them) are allowed through — anything
22
+ * else (absolute paths elsewhere, traversal, subdirectories) is dropped.
23
+ *
24
+ * Exported for tests; `assetsRootOverride` exists only for them.
25
+ */
26
+ export function filterImagesToUploadStore(images: unknown, assetsRootOverride?: string): AnyRecord[] {
27
+ const assetsRoot = path.resolve(assetsRootOverride ?? getGlobalImageAssetsDir());
28
+
29
+ return normalizeImageDescriptors(images).filter((descriptor) => {
30
+ // Relative paths are anchored in the store; absolute ones must already be in it.
31
+ const resolved = path.resolve(assetsRoot, descriptor.path);
32
+ const relative = path.relative(assetsRoot, resolved);
33
+ const isDirectChild =
34
+ relative.length > 0 &&
35
+ !relative.startsWith('..') &&
36
+ !path.isAbsolute(relative) &&
37
+ !relative.includes(path.sep) &&
38
+ !relative.includes('/');
39
+
40
+ if (!isDirectChild) {
41
+ console.warn(`[Chat] Dropping image outside the upload store: ${descriptor.path}`);
42
+ }
43
+ return isDirectChild;
44
+ });
45
+ }
46
+
13
47
  /**
14
48
  * One provider runtime entry point. All five runtimes share this signature,
15
49
  * which lets the chat handler dispatch through a provider-keyed map instead
@@ -161,6 +195,9 @@ async function handleChatSend(
161
195
  // gateway writer captures and maps back to the app session id.
162
196
  const runtimeOptions: AnyRecord = {
163
197
  ...clientOptions,
198
+ // Image attachments are re-validated server-side: only files inside the
199
+ // global upload store may reach the provider runtimes' file reads.
200
+ images: filterImagesToUploadStore(clientOptions.images),
164
201
  sessionId: session.provider_session_id ?? undefined,
165
202
  resume: Boolean(session.provider_session_id),
166
203
  cwd: clientOptions.cwd ?? session.project_path ?? undefined,
@@ -175,8 +212,10 @@ async function handleChatSend(
175
212
  } finally {
176
213
  // Safety net: a runtime that crashed (or resolved) without emitting its
177
214
  // terminal `complete` would otherwise leave the session stuck in
178
- // "processing" forever on every connected client.
179
- chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
215
+ // "processing" forever on every connected client. Scoped to THIS run —
216
+ // a queued message can start the session's next run before this promise
217
+ // settles, and the session-keyed completeRun would kill that new run.
218
+ chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
180
219
  }
181
220
  }
182
221
 
@@ -146,14 +146,6 @@ function buildShellCommand(
146
146
  return 'codex';
147
147
  }
148
148
 
149
- if (provider === 'gemini') {
150
- const command = initialCommand || 'gemini';
151
- if (resumeSessionId) {
152
- return `${command} --resume "${resumeSessionId}"`;
153
- }
154
- return command;
155
- }
156
-
157
149
  if (provider === 'opencode') {
158
150
  if (resumeSessionId) {
159
151
  return `opencode --session "${resumeSessionId}"`;
@@ -477,9 +469,7 @@ export function handleShellConnection(
477
469
  ? 'Cursor'
478
470
  : provider === 'codex'
479
471
  ? 'Codex'
480
- : provider === 'gemini'
481
- ? 'Gemini'
482
- : provider === 'opencode'
472
+ : provider === 'opencode'
483
473
  ? 'OpenCode'
484
474
  : 'Claude';
485
475
  welcomeMsg = hasSession && resumeSessionId
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ import { filterImagesToUploadStore } from '@/modules/websocket/services/chat-websocket.service.js';
7
+
8
+ const STORE = path.join(os.tmpdir(), 'cloudcli-assets-store');
9
+
10
+ test('images inside the upload store pass through', () => {
11
+ const inside = path.join(STORE, 'shot.png');
12
+ const result = filterImagesToUploadStore(
13
+ [{ path: inside, name: 'shot.png', mimeType: 'image/png' }],
14
+ STORE,
15
+ );
16
+ assert.equal(result.length, 1);
17
+ assert.equal(result[0].path, inside);
18
+ });
19
+
20
+ test('bare filenames are anchored inside the store', () => {
21
+ const result = filterImagesToUploadStore(['shot.png'], STORE);
22
+ assert.equal(result.length, 1);
23
+ });
24
+
25
+ test('paths outside the store, traversal, and subdirs are dropped', () => {
26
+ const result = filterImagesToUploadStore(
27
+ [
28
+ { path: 'C:/Users/victim/.ssh/id_rsa' },
29
+ { path: '/etc/passwd' },
30
+ { path: '../outside.png' },
31
+ { path: path.join(STORE, '..', 'escaped.png') },
32
+ { path: path.join(STORE, 'nested', 'deep.png') },
33
+ { path: STORE }, // the store folder itself is not a file
34
+ ],
35
+ STORE,
36
+ );
37
+ assert.deepEqual(result, []);
38
+ });
39
+
40
+ test('malformed payloads yield no images', () => {
41
+ assert.deepEqual(filterImagesToUploadStore(undefined, STORE), []);
42
+ assert.deepEqual(filterImagesToUploadStore('nope', STORE), []);
43
+ assert.deepEqual(filterImagesToUploadStore([{ name: 'no-path' }, 42], STORE), []);
44
+ });
@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
129
129
  });
130
130
  });
131
131
 
132
+ test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
133
+ await withIsolatedDatabase(() => {
134
+ sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
135
+ const connection = new FakeConnection();
136
+
137
+ const firstRun = chatRunRegistry.startRun({
138
+ appSessionId: 'app-run-9',
139
+ provider: 'codex',
140
+ providerSessionId: null,
141
+ connection,
142
+ userId: null,
143
+ });
144
+ assert.ok(firstRun);
145
+ firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
146
+
147
+ // A queued message starts the next run before the first run's runtime
148
+ // promise settles (the chat handler's `finally` hasn't executed yet).
149
+ const secondRun = chatRunRegistry.startRun({
150
+ appSessionId: 'app-run-9',
151
+ provider: 'codex',
152
+ providerSessionId: null,
153
+ connection,
154
+ userId: null,
155
+ });
156
+ assert.ok(secondRun);
157
+
158
+ // First run's safety net fires late: it must not touch the new run.
159
+ chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
160
+ assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
161
+ assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
162
+
163
+ // The second run's own safety net still works while it is current.
164
+ chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
165
+ assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
166
+ assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
167
+ });
168
+ });
169
+
132
170
  test('listRunningRuns returns only currently running app sessions', async () => {
133
171
  await withIsolatedDatabase(() => {
134
172
  sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
@@ -186,22 +224,22 @@ test('replayEvents returns only events after the requested seq', async () => {
186
224
 
187
225
  test('attachConnection reroutes the live stream to a new socket', async () => {
188
226
  await withIsolatedDatabase(() => {
189
- sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
227
+ sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
190
228
  const firstConnection = new FakeConnection();
191
229
  const run = chatRunRegistry.startRun({
192
230
  appSessionId: 'app-run-5',
193
- provider: 'gemini',
231
+ provider: 'opencode',
194
232
  providerSessionId: null,
195
233
  connection: firstConnection,
196
234
  userId: null,
197
235
  });
198
236
  assert.ok(run);
199
237
 
200
- run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
238
+ run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' });
201
239
 
202
240
  const secondConnection = new FakeConnection();
203
241
  assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
204
- run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
242
+ run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' });
205
243
 
206
244
  assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
207
245
  assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
@@ -14,13 +14,14 @@
14
14
  */
15
15
 
16
16
  import { Codex } from '@openai/codex-sdk';
17
+
18
+ import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
17
19
  import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
18
20
  import { sessionsService } from './modules/providers/services/sessions.service.js';
19
21
  import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
20
22
  import { providerModelsService } from './modules/providers/services/provider-models.service.js';
21
23
  import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
22
24
 
23
- // Track active sessions
24
25
  const activeCodexSessions = new Map();
25
26
 
26
27
  function readUsageNumber(value) {
@@ -228,6 +229,8 @@ export async function queryCodex(command, options = {}, ws) {
228
229
  cwd,
229
230
  projectPath,
230
231
  model,
232
+ effort,
233
+ images,
231
234
  permissionMode = 'default'
232
235
  } = options;
233
236
 
@@ -239,6 +242,12 @@ export async function queryCodex(command, options = {}, ws) {
239
242
 
240
243
  const workingDirectory = cwd || projectPath || process.cwd();
241
244
  const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
245
+ const catalog = (await providerModelsService.getProviderModels('codex')).models;
246
+ const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
247
+ const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
248
+ const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
249
+ ? effort
250
+ : undefined;
242
251
 
243
252
  let codex;
244
253
  let thread;
@@ -248,19 +257,17 @@ export async function queryCodex(command, options = {}, ws) {
248
257
  const abortController = new AbortController();
249
258
 
250
259
  try {
251
- // Initialize Codex SDK
252
260
  codex = new Codex();
253
261
 
254
- // Thread options with sandbox and approval settings
255
262
  const threadOptions = {
256
263
  workingDirectory,
257
264
  skipGitRepoCheck: true,
258
265
  sandboxMode,
259
266
  approvalPolicy,
260
- model: resolvedModel
267
+ model: resolvedModel,
268
+ modelReasoningEffort: resolvedEffort,
261
269
  };
262
270
 
263
- // Start or resume thread
264
271
  if (sessionId) {
265
272
  thread = codex.resumeThread(sessionId, threadOptions);
266
273
  } else {
@@ -280,13 +287,16 @@ export async function queryCodex(command, options = {}, ws) {
280
287
  });
281
288
  };
282
289
 
283
- // Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
284
290
  if (capturedSessionId) {
285
291
  registerSession(capturedSessionId);
286
292
  }
287
293
 
288
- // Execute with streaming
289
- const streamedTurn = await thread.runStreamed(command, {
294
+ // Execute with streaming. Turns with image attachments send structured
295
+ // input items so Codex reads the images from their local asset paths.
296
+ const turnInput = normalizeImageDescriptors(images).length > 0
297
+ ? buildCodexInputItems(command, images, workingDirectory)
298
+ : command;
299
+ const streamedTurn = await thread.runStreamed(turnInput, {
290
300
  signal: abortController.signal
291
301
  });
292
302
 
@@ -1,19 +1,59 @@
1
- import { spawn } from 'child_process';
2
1
  import fsSync from 'node:fs';
3
2
 
4
3
  import crossSpawn from 'cross-spawn';
5
4
  import Database from 'better-sqlite3';
6
5
 
6
+ import { appendImagesInputTag } from './shared/image-attachments.js';
7
7
  import { sessionsService } from './modules/providers/services/sessions.service.js';
8
8
  import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
9
9
  import { providerModelsService } from './modules/providers/services/provider-models.service.js';
10
10
  import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
11
- import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
11
+ import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js';
12
12
 
13
- const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
13
+ // cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
14
+ // child_process.spawn everywhere else.
15
+ const spawnFunction = crossSpawn;
14
16
 
15
17
  const activeOpenCodeProcesses = new Map();
16
18
 
19
+ /**
20
+ * Maps the UI permission mode onto OpenCode's non-interactive controls.
21
+ *
22
+ * OpenCode has no single "permission mode" flag; each mode uses a different
23
+ * lever of the `opencode run` CLI (verified against v1.17.13):
24
+ * - plan → the built-in read-only `plan` agent (`--agent plan`).
25
+ * - bypassPermissions → `--auto`, which auto-approves every permission that
26
+ * is not explicitly denied in the user's config.
27
+ * - acceptEdits → the OPENCODE_PERMISSION env var, whose JSON body the
28
+ * CLI merges into its permission config. Forcing
29
+ * `edit: allow` guarantees file edits go through while
30
+ * every other rule stays under the user's own config.
31
+ * - default → nothing; the user's opencode.json governs. In
32
+ * non-interactive `run` mode any `ask` rule is denied.
33
+ *
34
+ * Exported for tests only.
35
+ */
36
+ export function resolveOpenCodePermissionOptions(permissionMode) {
37
+ switch (permissionMode) {
38
+ case 'plan':
39
+ return { args: ['--agent', 'plan'], env: {} };
40
+ case 'bypassPermissions':
41
+ return { args: ['--auto'], env: {} };
42
+ case 'acceptEdits':
43
+ return { args: [], env: { OPENCODE_PERMISSION: JSON.stringify({ edit: 'allow' }) } };
44
+ default:
45
+ return { args: [], env: {} };
46
+ }
47
+ }
48
+
49
+ function resolveOpenCodeEffort(model, effort, modelsDefinition) {
50
+ const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
51
+ const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
52
+ return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
53
+ ? effort
54
+ : undefined;
55
+ }
56
+
17
57
  function readOpenCodeSessionId(event) {
18
58
  if (!event || typeof event !== 'object') {
19
59
  return null;
@@ -84,7 +124,7 @@ function readOpenCodeTokenUsage(sessionId) {
84
124
 
85
125
  async function spawnOpenCode(command, options = {}, ws) {
86
126
  return new Promise((resolve, reject) => {
87
- const { sessionId, projectPath, cwd, model, sessionSummary } = options;
127
+ const { sessionId, projectPath, cwd, model, effort, sessionSummary, images, permissionMode } = options;
88
128
  const workingDir = cwd || projectPath || process.cwd();
89
129
  const processKey = sessionId || Date.now().toString();
90
130
  let capturedSessionId = sessionId || null;
@@ -192,7 +232,15 @@ async function spawnOpenCode(command, options = {}, ws) {
192
232
  }
193
233
  };
194
234
 
195
- void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
235
+ void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => {
236
+ let effortModels = null;
237
+ try {
238
+ effortModels = (await providerModelsService.getProviderModels('opencode')).models;
239
+ } catch (error) {
240
+ console.warn('[OpenCode] Unable to load provider models for effort validation:', error);
241
+ }
242
+
243
+ const resolvedEffort = resolveOpenCodeEffort(resolvedModel, effort, effortModels);
196
244
  const args = ['run', '--format', 'json'];
197
245
  // OpenCode's `run` command owns workspace selection through `--dir`.
198
246
  // Relying on the child-process cwd alone is not enough on Linux, where
@@ -204,14 +252,23 @@ async function spawnOpenCode(command, options = {}, ws) {
204
252
  if (resolvedModel) {
205
253
  args.push('--model', resolvedModel);
206
254
  }
255
+ if (resolvedEffort) {
256
+ args.push('--variant', resolvedEffort);
257
+ }
258
+ const permissionOptions = resolveOpenCodePermissionOptions(permissionMode);
259
+ args.push(...permissionOptions.args);
207
260
  if (command && command.trim()) {
208
- args.push(command.trim());
261
+ // Image attachments ride along as an <images_input> path list appended
262
+ // to the prompt; the session history reader strips the tag back out.
263
+ // opencode is a .cmd shim on Windows, so the whole argument must be
264
+ // newline-free or cmd.exe silently truncates it at the first newline.
265
+ args.push(flattenPromptForWindowsShell(appendImagesInputTag(command.trim(), images)));
209
266
  }
210
267
 
211
268
  opencodeProcess = spawnFunction('opencode', args, {
212
269
  cwd: workingDir,
213
270
  stdio: ['pipe', 'pipe', 'pipe'],
214
- env: { ...process.env },
271
+ env: { ...process.env, ...permissionOptions.env },
215
272
  });
216
273
 
217
274
  activeOpenCodeProcesses.set(processKey, opencodeProcess);
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
- import { spawnOpenCode } from './opencode-cli.js';
7
+ import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js';
8
8
 
9
9
  const findEnvKey = (name) =>
10
10
  Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
@@ -14,7 +14,10 @@ async function createFakeOpenCodeExecutable(binDir) {
14
14
  await writeFile(scriptPath, `
15
15
  const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
16
16
  if (capturePath) {
17
- require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
17
+ require('node:fs').writeFileSync(capturePath, JSON.stringify({
18
+ args: process.argv.slice(2),
19
+ permissionEnv: process.env.OPENCODE_PERMISSION ?? null,
20
+ }));
18
21
  }
19
22
 
20
23
  const events = [
@@ -86,10 +89,116 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
86
89
  assert.equal(complete?.sessionId, 'open-live-1');
87
90
  assert.equal(messages.some((message) => message.kind === 'error'), false);
88
91
 
89
- const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
92
+ const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
93
+ const launchedArgs = capture.args;
90
94
  assert.ok(Array.isArray(launchedArgs));
91
95
  assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
92
96
  assert.equal(launchedArgs[4], tempRoot);
97
+ // No permission mode requested → no permission flags and no env override.
98
+ assert.equal(launchedArgs.includes('--auto'), false);
99
+ assert.equal(launchedArgs.includes('--agent'), false);
100
+ assert.equal(capture.permissionEnv, null);
101
+ } finally {
102
+ if (previousPath === undefined) {
103
+ delete process.env[pathKey];
104
+ } else {
105
+ process.env[pathKey] = previousPath;
106
+ }
107
+
108
+ if (previousPathExt === undefined) {
109
+ delete process.env[pathExtKey];
110
+ } else {
111
+ process.env[pathExtKey] = previousPathExt;
112
+ }
113
+
114
+ if (previousArgsCapture === undefined) {
115
+ delete process.env.OPENCODE_ARGS_CAPTURE;
116
+ } else {
117
+ process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
118
+ }
119
+
120
+ await rm(tempRoot, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('resolveOpenCodePermissionOptions maps UI permission modes onto OpenCode controls', () => {
125
+ assert.deepEqual(resolveOpenCodePermissionOptions('plan'), {
126
+ args: ['--agent', 'plan'],
127
+ env: {},
128
+ });
129
+ assert.deepEqual(resolveOpenCodePermissionOptions('bypassPermissions'), {
130
+ args: ['--auto'],
131
+ env: {},
132
+ });
133
+ assert.deepEqual(resolveOpenCodePermissionOptions('acceptEdits'), {
134
+ args: [],
135
+ env: { OPENCODE_PERMISSION: '{"edit":"allow"}' },
136
+ });
137
+ // default and anything unknown leave the user's own opencode config in charge.
138
+ assert.deepEqual(resolveOpenCodePermissionOptions('default'), { args: [], env: {} });
139
+ assert.deepEqual(resolveOpenCodePermissionOptions(undefined), { args: [], env: {} });
140
+ });
141
+
142
+ test('spawnOpenCode passes permission mode flags and env to the CLI', async () => {
143
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-perms-'));
144
+ const pathKey = findEnvKey('PATH');
145
+ const pathExtKey = findEnvKey('PATHEXT');
146
+ const previousPath = process.env[pathKey];
147
+ const previousPathExt = process.env[pathExtKey];
148
+ const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
149
+ const writer = {
150
+ userId: null,
151
+ sessionId: null,
152
+ send() {},
153
+ setSessionId(sessionId) {
154
+ this.sessionId = sessionId;
155
+ },
156
+ };
157
+
158
+ try {
159
+ await createFakeOpenCodeExecutable(tempRoot);
160
+ process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
161
+ if (process.platform === 'win32') {
162
+ process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
163
+ ? previousPathExt
164
+ : `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
165
+ }
166
+
167
+ const scenarios = [
168
+ {
169
+ permissionMode: 'plan',
170
+ expectArgs: ['--agent', 'plan'],
171
+ expectPermissionEnv: null,
172
+ },
173
+ {
174
+ permissionMode: 'bypassPermissions',
175
+ expectArgs: ['--auto'],
176
+ expectPermissionEnv: null,
177
+ },
178
+ {
179
+ permissionMode: 'acceptEdits',
180
+ expectArgs: [],
181
+ expectPermissionEnv: '{"edit":"allow"}',
182
+ },
183
+ ];
184
+
185
+ for (const scenario of scenarios) {
186
+ const argsCapturePath = path.join(tempRoot, `opencode-args-${scenario.permissionMode}.json`);
187
+ process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
188
+
189
+ await spawnOpenCode('Hi', { cwd: tempRoot, permissionMode: scenario.permissionMode }, writer);
190
+
191
+ const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
192
+ for (const expectedArg of scenario.expectArgs) {
193
+ assert.ok(
194
+ capture.args.includes(expectedArg),
195
+ `${scenario.permissionMode}: expected "${expectedArg}" in ${JSON.stringify(capture.args)}`,
196
+ );
197
+ }
198
+ // The prompt stays the last positional argument, after any permission flags.
199
+ assert.equal(capture.args[capture.args.length - 1], 'Hi');
200
+ assert.equal(capture.permissionEnv, scenario.expectPermissionEnv);
201
+ }
93
202
  } finally {
94
203
  if (previousPath === undefined) {
95
204
  delete process.env[pathKey];