@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.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 (195) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/multirun-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/src-BzX5UI-c.css +2 -0
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/textarea-3XR8sUfy.js +1 -0
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -1,36 +1,27 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
1
+ import { OpenCode } from '@opencode/client';
2
2
 
3
3
  const LLM_AGENT_NAME = 'openchamber-llm';
4
4
  const GENERATE_TIMEOUT_MS = 90_000;
5
- const SETTLE_POLL_MS = 250;
6
- const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
7
- const EMPTY_IDLE_PROBES = 5;
5
+ /** Single attachment payload cap (decoded bytes). Composer UI allows larger drafts; gateway enforces this. */
6
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
7
+ const MAX_INLINE_FILE_CHARS = 100_000;
8
+ const TEXT_FILE_MIME = /^(text\/|application\/(json|javascript|xml|sql|yaml|x-yaml|toml))/i;
8
9
 
9
10
  const positiveMs = (value, fallback) => (
10
11
  Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback
11
12
  );
12
13
 
13
14
  /**
14
- * Session-level deny-all. OpenCode 1.18 session.create serializes this
15
- * PermissionRuleset shape (permission/pattern/action). Confirmed on local
16
- * 1.18.23/1.18.29: create returns the same rule on the session object.
17
- */
18
- export const LLM_SESSION_DENY_PERMISSION = Object.freeze([
19
- Object.freeze({ permission: '*', pattern: '*', action: 'deny' }),
20
- ]);
21
-
22
- /**
23
- * Agent frontmatter for OpenCode 1.18. Runtime /agent metadata uses
24
- * permission:[{permission,pattern,action}]. Markdown config accepts the
25
- * PermissionActionConfig map (`"*": deny`) / scalar `deny` / tools:"*":false —
26
- * not the legacy action/resource/effect list (that loads but does not add
27
- * a terminal * deny, so build-like allows and skill guidance stay visible).
15
+ * Agent frontmatter for official client agent permissions (action/resource/effect).
16
+ * The final rule must be deny-all; assertLlmAgentDenyAll verifies that before prompt.
28
17
  */
29
18
  const AGENT_MARKDOWN = `---
30
19
  mode: primary
31
20
  hidden: true
32
- permission:
33
- "*": deny
21
+ permissions:
22
+ - action: "*"
23
+ resource: "*"
24
+ effect: deny
34
25
  ---
35
26
 
36
27
  You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
@@ -40,51 +31,31 @@ Report execution and failures from supplied tool results only.
40
31
  Do not call native OpenCode tools, MCP tools, or skill tools. Native permissions are denied for this generator session.
41
32
  `;
42
33
 
43
- const isMissing = (result) =>
44
- result?.error?.status === 404
45
- || result?.error?.statusCode === 404
46
- || result?.error?.code === 'not_found'
47
- || result?.status === 404;
48
-
49
- const promptAdmitted = (result) =>
50
- !result?.error
51
- && (result?.response?.status === 204
52
- || result?.status === 204
53
- || result?.data !== undefined
54
- || result?.response?.ok === true);
55
-
56
- const sdkErrorMessage = (result, fallback) => {
57
- const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
58
- const message = result?.error?.message || result?.error?.data?.message || fallback;
59
- return status ? `${message} (${status})` : message;
60
- };
61
-
62
- const failGenerate = (message) => {
34
+ const failGenerate = (message, code = 'upstream_error') => {
63
35
  const error = new Error(message);
64
- error.code = 'upstream_error';
36
+ error.code = code;
65
37
  throw error;
66
38
  };
67
39
 
68
- const sleep = (ms, signal) => new Promise((resolve, reject) => {
69
- if (signal?.aborted) {
70
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
71
- return;
72
- }
73
- const timer = setTimeout(() => {
74
- signal?.removeEventListener?.('abort', onAbort);
75
- resolve();
76
- }, ms);
77
- const onAbort = () => {
78
- clearTimeout(timer);
79
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
80
- };
81
- signal?.addEventListener?.('abort', onAbort, { once: true });
82
- });
40
+ const clientErrorMessage = (error, fallback) => {
41
+ if (typeof error?.message === 'string' && error.message.trim()) return error.message;
42
+ if (typeof error === 'string' && error.trim()) return error;
43
+ return fallback;
44
+ };
83
45
 
84
- const readMessageInfo = (message) => {
85
- if (!message || typeof message !== 'object') return null;
86
- if (message.info && typeof message.info === 'object') return message.info;
87
- return message;
46
+ const isAbortLike = (error, signal) => (
47
+ signal?.aborted
48
+ || error?.name === 'AbortError'
49
+ || error?.code === 'ABORT_ERR'
50
+ || (typeof error?.message === 'string' && /aborted|This operation was aborted/i.test(error.message))
51
+ );
52
+
53
+ const rethrowIfAborted = (error, signal) => {
54
+ if (!isAbortLike(error, signal)) return;
55
+ if (error instanceof Error) throw error;
56
+ const next = new Error(clientErrorMessage(error, 'aborted'));
57
+ next.name = 'AbortError';
58
+ throw next;
88
59
  };
89
60
 
90
61
  const assistantErrorDetail = (error) => {
@@ -100,179 +71,6 @@ const assistantErrorDetail = (error) => {
100
71
  return '';
101
72
  };
102
73
 
103
- const assistantTextFromPrompt = (data) => {
104
- const parts = Array.isArray(data?.parts) ? data.parts : Array.isArray(data?.data?.parts) ? data.data.parts : [];
105
- const text = parts
106
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
107
- .map((part) => part.text)
108
- .join('');
109
- if (text.trim()) return text;
110
- const info = data?.info ?? data?.data?.info;
111
- if (info?.error) {
112
- const detail = assistantErrorDetail(info.error);
113
- if (detail) failGenerate(detail);
114
- }
115
- return '';
116
- };
117
-
118
- const assistantTextFromMessages = (messages) => {
119
- if (!Array.isArray(messages) || messages.length === 0) return '';
120
- for (let i = messages.length - 1; i >= 0; i -= 1) {
121
- const message = messages[i];
122
- const info = readMessageInfo(message);
123
- if (info?.role !== 'assistant') continue;
124
- if (info.error) {
125
- const detail = assistantErrorDetail(info.error);
126
- failGenerate(detail || 'OpenCode assistant error');
127
- }
128
- const parts = Array.isArray(message?.parts) ? message.parts : [];
129
- return parts
130
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
131
- .filter(Boolean)
132
- .join('');
133
- }
134
- return '';
135
- };
136
-
137
- const deniedTools = (ids) => {
138
- const tools = Object.create(null);
139
- for (const id of ids) {
140
- if (typeof id === 'string' && id.trim()) tools[id.trim()] = false;
141
- }
142
- return tools;
143
- };
144
-
145
- const waitForIdleAssistant = async ({
146
- client,
147
- sessionID,
148
- directory,
149
- signal,
150
- onProgress = null,
151
- settlePollMs = SETTLE_POLL_MS,
152
- }) => {
153
- let incompleteAssistantProbes = 0;
154
- let emptyIdleProbes = 0;
155
- const pollMs = positiveMs(settlePollMs, SETTLE_POLL_MS);
156
-
157
- for (;;) {
158
- signal?.throwIfAborted?.();
159
-
160
- let sessionBusy = false;
161
- try {
162
- const statusResult = await client.session.status({ directory }, { signal });
163
- if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
164
- const statusValue = statusResult.data[sessionID];
165
- const type = statusValue?.type ?? statusValue?.status;
166
- sessionBusy = type === 'busy' || type === 'retry';
167
- }
168
- } catch (error) {
169
- if (signal?.aborted) throw error;
170
- }
171
-
172
- if (sessionBusy) {
173
- incompleteAssistantProbes = 0;
174
- emptyIdleProbes = 0;
175
- onProgress?.();
176
- await sleep(pollMs, signal);
177
- continue;
178
- }
179
-
180
- try {
181
- const messagesResult = await client.session.messages({
182
- sessionID,
183
- directory,
184
- limit: 20,
185
- }, { signal });
186
- if (messagesResult?.error) {
187
- // Deterministic upstream failures (e.g. 400) must not look like "still settling".
188
- failGenerate(`OpenCode LLM session.messages failed: ${sdkErrorMessage(messagesResult, 'messages failed')}`);
189
- }
190
- if (Array.isArray(messagesResult?.data)) {
191
- const lastInfo = readMessageInfo(messagesResult.data.at(-1));
192
- if (lastInfo?.role === 'assistant') {
193
- emptyIdleProbes = 0;
194
- if (lastInfo.error) {
195
- const detail = assistantErrorDetail(lastInfo.error);
196
- failGenerate(detail || 'OpenCode assistant error');
197
- }
198
- if (lastInfo.time?.completed) {
199
- return messagesResult.data;
200
- }
201
- incompleteAssistantProbes += 1;
202
- onProgress?.();
203
- if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
204
- return messagesResult.data;
205
- }
206
- } else {
207
- incompleteAssistantProbes = 0;
208
- emptyIdleProbes += 1;
209
- if (emptyIdleProbes >= EMPTY_IDLE_PROBES && lastInfo?.role === 'user') {
210
- failGenerate('OpenCode LLM generator ended without assistant text');
211
- }
212
- }
213
- }
214
- } catch (error) {
215
- if (signal?.aborted) throw error;
216
- if (error?.code === 'upstream_error') throw error;
217
- }
218
-
219
- await sleep(pollMs, signal);
220
- }
221
- };
222
-
223
- const isJsonContentType = (response) => /json/i.test(response?.headers?.get?.('content-type') || '');
224
-
225
- const looksLikeJsonObject = (text) => {
226
- const trimmed = String(text || '').trim();
227
- if (!trimmed || trimmed.startsWith('<')) return false;
228
- try {
229
- const parsed = JSON.parse(trimmed);
230
- return parsed !== null && typeof parsed === 'object';
231
- } catch {
232
- return false;
233
- }
234
- };
235
-
236
- /**
237
- * Detect a sessionless generate endpoint on the running OpenCode.
238
- * Bundled 1.18.4 does not expose POST /generate; keep the probe so a later
239
- * OpenCode that does can be used without changing the public completions API.
240
- *
241
- * SPA / OpenCode HTML often answers 200 text/html `<!doctype html>` for
242
- * unknown paths. That is not generate — only JSON (Content-Type or body)
243
- * counts as available.
244
- */
245
- export async function detectSessionlessGenerate({ fetchImpl, baseUrl, headers }) {
246
- const clientShape = typeof arguments[0]?.client?.generate === 'function'
247
- || typeof arguments[0]?.client?.v2?.generate === 'function';
248
- if (clientShape) {
249
- return { available: true, mode: 'sdk' };
250
- }
251
- const root = String(baseUrl || '').replace(/\/$/, '');
252
- const candidates = [`${root}/generate`, `${root}/api/generate`];
253
- for (const url of candidates) {
254
- try {
255
- const response = await fetchImpl(url, {
256
- method: 'POST',
257
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
258
- body: JSON.stringify({ probe: true }),
259
- signal: AbortSignal.timeout(4_000),
260
- });
261
- if (response.status === 404 || response.status === 405) continue;
262
- const body = await response.text().catch(() => '');
263
- if (isJsonContentType(response) || looksLikeJsonObject(body)) {
264
- return { available: true, mode: 'http', url };
265
- }
266
- } catch {
267
- // Probe failure is not a generate capability.
268
- }
269
- }
270
- return { available: false, mode: 'throwaway-session' };
271
- }
272
-
273
- const TEXT_FILE_MIME = /^(text\/|application\/(json|javascript|xml|sql|yaml|x-yaml|toml))/i;
274
- const MAX_INLINE_FILE_CHARS = 100_000;
275
-
276
74
  const messageText = (message) => {
277
75
  if (typeof message?.content === 'string') return message.content;
278
76
  if (Array.isArray(message?.content)) {
@@ -294,21 +92,51 @@ const messageFileParts = (message) => {
294
92
  }));
295
93
  };
296
94
 
297
- const decodeDataUrlText = (url, mime) => {
298
- if (!TEXT_FILE_MIME.test(mime) || typeof url !== 'string' || !url.startsWith('data:')) return null;
95
+ /**
96
+ * Strict data-URL parse. Returns null when the URI is not a well-formed data URL.
97
+ * Decoded size is checked against MAX_ATTACHMENT_BYTES.
98
+ */
99
+ export function parseDataUrl(url) {
100
+ if (typeof url !== 'string' || !url.startsWith('data:')) return null;
299
101
  const comma = url.indexOf(',');
300
- if (comma < 0) return null;
102
+ if (comma < 5) return null;
301
103
  const meta = url.slice(5, comma);
302
104
  const payload = url.slice(comma + 1);
105
+ if (!meta || payload.length === 0) return null;
106
+ const parts = meta.split(';');
107
+ const mime = parts[0] && /^[\w.+-]+\/[\w.+-]+$/i.test(parts[0]) ? parts[0] : null;
108
+ if (!mime) return null;
109
+ const isBase64 = parts.some((part) => part.toLowerCase() === 'base64');
110
+ let bytes;
303
111
  try {
304
- const bytes = meta.includes('base64')
112
+ bytes = isBase64
305
113
  ? Buffer.from(payload, 'base64')
306
114
  : Buffer.from(decodeURIComponent(payload), 'utf8');
307
- const text = bytes.toString('utf8');
308
- return text.length > MAX_INLINE_FILE_CHARS ? `${text.slice(0, MAX_INLINE_FILE_CHARS)}\n…` : text;
309
115
  } catch {
310
116
  return null;
311
117
  }
118
+ // Reject corrupt base64 that decodes to empty while payload was non-empty.
119
+ if (isBase64 && bytes.length === 0 && payload.replace(/\s/g, '').length > 0) return null;
120
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
121
+ const error = new Error(`Attachment exceeds ${MAX_ATTACHMENT_BYTES} byte limit`);
122
+ error.code = 'validation_error';
123
+ throw error;
124
+ }
125
+ return { mime, bytes, isBase64, byteLength: bytes.length };
126
+ }
127
+
128
+ const decodeDataUrlText = (url, mime) => {
129
+ if (!TEXT_FILE_MIME.test(mime)) return null;
130
+ let parsed;
131
+ try {
132
+ parsed = parseDataUrl(url);
133
+ } catch (error) {
134
+ if (error?.code === 'validation_error') throw error;
135
+ return null;
136
+ }
137
+ if (!parsed) return null;
138
+ const text = parsed.bytes.toString('utf8');
139
+ return text.length > MAX_INLINE_FILE_CHARS ? `${text.slice(0, MAX_INLINE_FILE_CHARS)}\n…` : text;
312
140
  };
313
141
 
314
142
  const describeContactFilePart = (part) => {
@@ -320,6 +148,25 @@ const describeContactFilePart = (part) => {
320
148
  return `[file: ${name} (${mime})]`;
321
149
  };
322
150
 
151
+ /**
152
+ * Validate every file part URL before generate. Non-data URLs are rejected
153
+ * (gateway only accepts contact data-URL attachments).
154
+ */
155
+ export function assertValidAttachmentParts(files) {
156
+ for (const part of files) {
157
+ if (!part || typeof part.url !== 'string') {
158
+ failGenerate('Attachment is missing a data URL', 'validation_error');
159
+ }
160
+ if (!part.url.startsWith('data:')) {
161
+ failGenerate('Attachment URL must be a data URL', 'validation_error');
162
+ }
163
+ const parsed = parseDataUrl(part.url);
164
+ if (!parsed) {
165
+ failGenerate('Attachment data URL is malformed', 'validation_error');
166
+ }
167
+ }
168
+ }
169
+
323
170
  const flattenMessages = (messages) => {
324
171
  const system = [];
325
172
  const turns = [];
@@ -345,50 +192,36 @@ const flattenMessages = (messages) => {
345
192
  };
346
193
  };
347
194
 
195
+ /** Image bytes only when the catalog model is vision-capable. */
348
196
  const filesForPrompt = (files, forwardImageParts) => {
349
197
  if (forwardImageParts) return files;
350
198
  return files.filter((part) => !String(part?.mime || '').startsWith('image/'));
351
199
  };
352
200
 
353
- async function generateViaSessionless({ fetchImpl, url, headers, providerID, modelID, variant, messages, signal }) {
354
- const response = await fetchImpl(url, {
355
- method: 'POST',
356
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
357
- body: JSON.stringify({
358
- model: { providerID, modelID },
359
- ...(variant ? { variant } : {}),
360
- messages,
361
- }),
362
- signal,
363
- });
364
- if (!response.ok) {
365
- const body = await response.text().catch(() => '');
366
- failGenerate(`OpenCode generate failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`);
367
- }
368
- const payload = await response.json();
369
- const text = typeof payload?.text === 'string'
370
- ? payload.text
371
- : typeof payload?.message === 'string'
372
- ? payload.message
373
- : '';
374
- if (!text.trim()) failGenerate('OpenCode generate returned no text');
375
- return { text: text.trim(), source: 'generate' };
376
- }
201
+ const imageFilesForSession = (files, forwardImageParts) => {
202
+ if (!forwardImageParts) return [];
203
+ return files.filter((part) => String(part?.mime || '').startsWith('image/'));
204
+ };
205
+
206
+ const modelRef = (providerID, modelID, variant) => ({
207
+ id: modelID,
208
+ providerID,
209
+ ...(variant ? { variant } : {}),
210
+ });
377
211
 
378
212
  const eventPayload = (event) => event?.payload?.payload ?? event?.payload ?? event;
379
213
 
380
- const eventDeltaProperties = (payload) => {
214
+ const eventDeltaData = (payload) => {
381
215
  if (!payload || typeof payload !== 'object') return null;
382
- if (payload.properties && typeof payload.properties === 'object') return payload.properties;
383
216
  if (payload.data && typeof payload.data === 'object') return payload.data;
217
+ if (payload.properties && typeof payload.properties === 'object') return payload.properties;
384
218
  return null;
385
219
  };
386
220
 
387
221
  /**
388
- * Forward real OpenCode `message.part.delta` text tokens for one throwaway
389
- * session. Returns an unsubscribe fn, or null when deltas cannot be observed
390
- * (no callback, no hub, or hub without subscribeEvent). Never fabricates
391
- * typewriter chunks from a completed string.
222
+ * Forward real OpenCode `session.text.delta` tokens for one throwaway session.
223
+ * Consumes data.sessionID / assistantMessageID / ordinal / delta.
224
+ * Returns unsubscribe, or null when deltas cannot be observed.
392
225
  *
393
226
  * @param {{ globalEventHub?: { subscribeEvent?: Function } | null, sessionID: string, onTextDelta?: ((text: string) => void) | null }} args
394
227
  * @returns {(() => void) | null}
@@ -398,22 +231,19 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
398
231
  if (typeof sessionID !== 'string' || !sessionID) return null;
399
232
  if (typeof globalEventHub?.subscribeEvent !== 'function') return null;
400
233
 
401
- // Lock to the first assistant messageID seen for this throwaway session so
402
- // concurrent sessions on the shared hub cannot leak tokens into this generate.
403
234
  let assistantMessageID = null;
235
+ let lastOrdinal = -1;
404
236
 
405
237
  const unsubscribe = globalEventHub.subscribeEvent((event) => {
406
238
  const payload = eventPayload(event);
407
- if (payload?.type !== 'message.part.delta') return;
408
- const props = eventDeltaProperties(payload);
409
- if (!props) return;
410
- if (props.sessionID !== sessionID) return;
411
- // Text field only — reasoning/other fields stay out of completion tokens.
412
- if (typeof props.field === 'string' && props.field !== 'text') return;
413
- if (typeof props.delta !== 'string' || props.delta.length === 0) return;
414
-
415
- const messageID = typeof props.messageID === 'string' && props.messageID
416
- ? props.messageID
239
+ if (payload?.type !== 'session.text.delta') return;
240
+ const data = eventDeltaData(payload);
241
+ if (!data) return;
242
+ if (data.sessionID !== sessionID) return;
243
+ if (typeof data.delta !== 'string' || data.delta.length === 0) return;
244
+
245
+ const messageID = typeof data.assistantMessageID === 'string' && data.assistantMessageID
246
+ ? data.assistantMessageID
417
247
  : null;
418
248
  if (assistantMessageID) {
419
249
  if (messageID && messageID !== assistantMessageID) return;
@@ -421,8 +251,13 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
421
251
  assistantMessageID = messageID;
422
252
  }
423
253
 
254
+ if (typeof data.ordinal === 'number' && Number.isFinite(data.ordinal)) {
255
+ if (data.ordinal <= lastOrdinal) return;
256
+ lastOrdinal = data.ordinal;
257
+ }
258
+
424
259
  try {
425
- onTextDelta(props.delta);
260
+ onTextDelta(data.delta);
426
261
  } catch {
427
262
  // Caller callback failures must not abort generate or leave the hub broken.
428
263
  }
@@ -432,19 +267,246 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
432
267
  }
433
268
 
434
269
  /**
435
- * Generate assistant text through OpenCode's connected providers.
436
- * Sessionless generate if the binary exposes it; otherwise a throwaway
437
- * archived session used only as a tools-denied text generator.
270
+ * Verify the openchamber-llm agent ends with deny-all permissions.
271
+ * Title alone is never treated as isolation. Failure must happen before prompt.
272
+ */
273
+ export async function assertLlmAgentDenyAll({ client, location, signal }) {
274
+ let result;
275
+ try {
276
+ result = await client.agent.get({
277
+ agentID: LLM_AGENT_NAME,
278
+ ...(location ? { location } : {}),
279
+ }, { signal });
280
+ } catch (error) {
281
+ failGenerate(
282
+ `OpenCode LLM agent is unavailable (${clientErrorMessage(error, 'agent.get failed')}); attachment generation blocked`,
283
+ 'llm_attachment_generation_unavailable',
284
+ );
285
+ }
286
+ const permissions = result?.data?.permissions;
287
+ if (!Array.isArray(permissions) || permissions.length === 0) {
288
+ failGenerate(
289
+ 'OpenCode LLM agent permissions are missing; attachment generation blocked',
290
+ 'llm_attachment_generation_unavailable',
291
+ );
292
+ }
293
+ const last = permissions[permissions.length - 1];
294
+ if (
295
+ !last
296
+ || last.action !== '*'
297
+ || last.resource !== '*'
298
+ || last.effect !== 'deny'
299
+ ) {
300
+ failGenerate(
301
+ 'OpenCode LLM agent final permission is not deny-all; attachment generation blocked',
302
+ 'llm_attachment_generation_unavailable',
303
+ );
304
+ }
305
+ return result.data;
306
+ }
307
+
308
+ const assistantTextFromMessages = (messages) => {
309
+ if (!Array.isArray(messages) || messages.length === 0) return '';
310
+ for (const message of messages) {
311
+ // message.list order:desc — first assistant is the newest.
312
+ const role = message?.type ?? message?.role ?? message?.info?.role;
313
+ if (role !== 'assistant') continue;
314
+ const error = message?.error ?? message?.info?.error;
315
+ if (error) {
316
+ const detail = assistantErrorDetail(error);
317
+ failGenerate(detail || 'OpenCode assistant error');
318
+ }
319
+ const finish = message?.finish ?? message?.info?.finish;
320
+ if (finish === 'error') {
321
+ failGenerate(assistantErrorDetail(message?.error) || 'OpenCode assistant finish=error');
322
+ }
323
+ const content = Array.isArray(message?.content)
324
+ ? message.content
325
+ : Array.isArray(message?.parts)
326
+ ? message.parts
327
+ : [];
328
+ const text = content
329
+ .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
330
+ .filter(Boolean)
331
+ .join('');
332
+ return text;
333
+ }
334
+ return '';
335
+ };
336
+
337
+ const makeClient = ({ baseUrl, headers, clientFactory }) => {
338
+ if (typeof clientFactory === 'function') return clientFactory();
339
+ return OpenCode.make({ baseUrl, headers });
340
+ };
341
+
342
+ async function generateViaTextApi({
343
+ client,
344
+ location,
345
+ providerID,
346
+ modelID,
347
+ variant,
348
+ prompt,
349
+ signal,
350
+ }) {
351
+ let result;
352
+ try {
353
+ result = await client.generate.text({
354
+ ...(location ? { location } : {}),
355
+ prompt,
356
+ model: modelRef(providerID, modelID, variant),
357
+ }, { signal });
358
+ } catch (error) {
359
+ rethrowIfAborted(error, signal);
360
+ failGenerate(clientErrorMessage(error, 'OpenCode generate.text failed'));
361
+ }
362
+ const text = typeof result?.text === 'string'
363
+ ? result.text.trim()
364
+ : typeof result?.data?.text === 'string'
365
+ ? result.data.text.trim()
366
+ : '';
367
+ if (!text) failGenerate('OpenCode generate.text returned no text');
368
+ return { text, source: 'generate.text' };
369
+ }
370
+
371
+ async function generateViaAttachmentSession({
372
+ client,
373
+ workingDirectory,
374
+ providerID,
375
+ modelID,
376
+ variant,
377
+ system,
378
+ prompt,
379
+ imageFiles,
380
+ signal,
381
+ onTextDelta,
382
+ globalEventHub,
383
+ }) {
384
+ const location = { directory: workingDirectory };
385
+
386
+ // Permission isolation must be verified before any prompt. Never use title as isolation.
387
+ await assertLlmAgentDenyAll({ client, location, signal });
388
+
389
+ let sessionID = null;
390
+ let unsubscribeDeltas = null;
391
+ try {
392
+ let created;
393
+ try {
394
+ created = await client.session.create({
395
+ title: '[openchamber-llm] generate',
396
+ agent: LLM_AGENT_NAME,
397
+ model: modelRef(providerID, modelID, variant),
398
+ location,
399
+ }, { signal });
400
+ } catch (error) {
401
+ // Temp workspace invisible to OpenCode must fail explicitly.
402
+ failGenerate(
403
+ `OpenCode LLM session create failed (${clientErrorMessage(error, 'create failed')})`,
404
+ 'llm_attachment_generation_unavailable',
405
+ );
406
+ }
407
+ sessionID = created?.id ?? created?.data?.id;
408
+ if (!sessionID) {
409
+ failGenerate('OpenCode LLM session create returned no id', 'llm_attachment_generation_unavailable');
410
+ }
411
+
412
+ if (system) {
413
+ try {
414
+ await client.session.instructions.entry.put({
415
+ sessionID,
416
+ key: 'system',
417
+ value: system,
418
+ }, { signal });
419
+ } catch (error) {
420
+ failGenerate(clientErrorMessage(error, 'instructions.entry.put failed'));
421
+ }
422
+ }
423
+
424
+ unsubscribeDeltas = subscribeThrowawayTextDeltas({
425
+ globalEventHub,
426
+ sessionID,
427
+ onTextDelta,
428
+ });
429
+
430
+ const files = imageFiles.map((part) => ({
431
+ uri: part.url,
432
+ ...(part.filename ? { name: part.filename } : {}),
433
+ }));
434
+
435
+ try {
436
+ await client.session.prompt({
437
+ sessionID,
438
+ text: prompt || '[attachment]',
439
+ ...(files.length > 0 ? { files } : {}),
440
+ delivery: 'steer',
441
+ }, { signal });
442
+ } catch (error) {
443
+ rethrowIfAborted(error, signal);
444
+ failGenerate(clientErrorMessage(error, 'session.prompt failed'));
445
+ }
446
+
447
+ try {
448
+ await client.session.wait({ sessionID }, { signal });
449
+ } catch (error) {
450
+ rethrowIfAborted(error, signal);
451
+ failGenerate(clientErrorMessage(error, 'session.wait failed'));
452
+ }
453
+
454
+ let listed;
455
+ try {
456
+ listed = await client.message.list({
457
+ sessionID,
458
+ limit: 20,
459
+ order: 'desc',
460
+ }, { signal });
461
+ } catch (error) {
462
+ rethrowIfAborted(error, signal);
463
+ failGenerate(clientErrorMessage(error, 'message.list failed'));
464
+ }
465
+
466
+ const messages = Array.isArray(listed?.data) ? listed.data : [];
467
+ const text = assistantTextFromMessages(messages);
468
+ if (!text.trim()) {
469
+ failGenerate('OpenCode LLM generator returned no assistant text');
470
+ }
471
+ return { text: text.trim(), source: 'attachment-session' };
472
+ } finally {
473
+ try {
474
+ unsubscribeDeltas?.();
475
+ } catch {
476
+ // Unsubscribe must not mask generate errors or block session remove.
477
+ }
478
+ unsubscribeDeltas = null;
479
+ if (sessionID) {
480
+ if (signal?.aborted) {
481
+ try {
482
+ await client.session.interrupt({ sessionID, continue: false });
483
+ } catch {
484
+ // Best-effort interrupt on timeout/abort.
485
+ }
486
+ }
487
+ try {
488
+ await client.session.remove({ sessionID });
489
+ } catch (error) {
490
+ console.warn('[llm] failed to remove throwaway OpenCode session:', error?.message || error);
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Generate assistant text through OpenCode's connected providers via
498
+ * `@opencode/client`.
499
+ *
500
+ * - Pure text: `generate.text` (no session).
501
+ * - Vision image attachments: dedicated temp session after agent deny-all verify.
502
+ * - Non-vision: keep `[image: …]` descriptions; never forward image bytes.
438
503
  *
439
- * V2 session.prompt only forwards { id, prompt, delivery, resume } and drops
440
- * model/parts/tools. Use promptAsync (body still has model, parts, tools,
441
- * system, agent) then wait for idle and read session.messages.
504
+ * Optional internal streaming: `onTextDelta` + `globalEventHub` forwards real
505
+ * `session.text.delta` tokens on the attachment-session path only.
506
+ * `generate.text` cannot emit live deltas (no fake typewriter).
442
507
  *
443
- * Optional internal streaming: pass `onTextDelta(text)` plus a live
444
- * `globalEventHub` (same hub as UI SSE). On the throwaway path only, real
445
- * `message.part.delta` tokens for this session are forwarded. Sessionless
446
- * `/generate` JSON cannot emit deltas — onTextDelta is skipped (no fake
447
- * typewriter). Public HTTP completions stay non-streaming.
508
+ * Optional `signal` from the contact continuation is combined with the generator
509
+ * deadline. Cancellation still runs throwaway-session cleanup.
448
510
  */
449
511
  export async function generateOpenCodeText({
450
512
  buildOpenCodeUrl,
@@ -453,26 +515,27 @@ export async function generateOpenCodeText({
453
515
  modelID,
454
516
  messages,
455
517
  variant,
456
- fetchImpl = globalThis.fetch.bind(globalThis),
457
518
  clientFactory,
458
519
  ensureTempDirectory,
459
- detect = detectSessionlessGenerate,
460
520
  forwardImageParts = false,
461
521
  onTextDelta = null,
462
522
  globalEventHub = null,
463
523
  signal: parentSignal = null,
464
524
  timeoutMs = GENERATE_TIMEOUT_MS,
465
- settlePollMs = SETTLE_POLL_MS,
466
525
  }) {
467
- parentSignal?.throwIfAborted();
526
+ parentSignal?.throwIfAborted?.();
468
527
  if (!providerID || !modelID) {
469
528
  const error = new Error('providerID and modelID are required');
470
529
  error.code = 'validation_error';
471
530
  throw error;
472
531
  }
473
532
  const flattened = flattenMessages(messages);
474
- const promptFiles = filesForPrompt(flattened.files, forwardImageParts);
475
- if (!flattened.prompt && promptFiles.length === 0) {
533
+ const allFiles = flattened.files;
534
+ assertValidAttachmentParts(allFiles);
535
+
536
+ const imageFiles = imageFilesForSession(allFiles, forwardImageParts);
537
+ // Non-image files stay as prompt descriptions (and inlined text when possible).
538
+ if (!flattened.prompt && imageFiles.length === 0 && allFiles.length === 0) {
476
539
  const error = new Error('messages must include a user turn');
477
540
  error.code = 'validation_error';
478
541
  throw error;
@@ -480,180 +543,88 @@ export async function generateOpenCodeText({
480
543
 
481
544
  const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
482
545
  const headers = getOpenCodeAuthHeaders() || {};
483
- const probe = await detect({ fetchImpl, baseUrl, headers, client: clientFactory?.() });
484
- parentSignal?.throwIfAborted();
546
+ const client = makeClient({ baseUrl, headers, clientFactory });
547
+
485
548
  const controller = new AbortController();
486
- const requestSignal = parentSignal ? AbortSignal.any([parentSignal, controller.signal]) : controller.signal;
487
- const stallMs = positiveMs(timeoutMs, GENERATE_TIMEOUT_MS);
488
- const abortGenerate = (message) => {
489
- if (controller.signal.aborted) return;
490
- controller.abort(new Error(message));
491
- };
492
- // Idle deadline only: live busy/delta/incomplete assistant progress
493
- // must not look like "the model hung". There is no wall-clock cap.
494
- let stallTimer = setTimeout(() => {
495
- abortGenerate(`OpenCode LLM generate timed out after ${stallMs}ms without progress`);
496
- }, stallMs);
497
- const bumpTimeout = () => {
498
- if (controller.signal.aborted) return;
499
- clearTimeout(stallTimer);
500
- stallTimer = setTimeout(() => {
501
- abortGenerate(`OpenCode LLM generate timed out after ${stallMs}ms without progress`);
502
- }, stallMs);
503
- };
549
+ const deadlineMs = positiveMs(timeoutMs, GENERATE_TIMEOUT_MS);
550
+ const requestSignal = parentSignal
551
+ ? AbortSignal.any([parentSignal, controller.signal])
552
+ : controller.signal;
553
+ const timeout = setTimeout(() => {
554
+ controller.abort(new Error(`OpenCode LLM generate timed out after ${deadlineMs}ms`));
555
+ }, deadlineMs);
556
+ timeout.unref?.();
504
557
 
505
558
  try {
506
- if (probe.available && probe.mode === 'http' && probe.url) {
507
- return await generateViaSessionless({
508
- fetchImpl,
509
- url: probe.url,
510
- headers,
559
+ if (imageFiles.length > 0) {
560
+ if (typeof ensureTempDirectory !== 'function') {
561
+ failGenerate(
562
+ 'LLM temp directory is required for attachment generation',
563
+ 'llm_attachment_generation_unavailable',
564
+ );
565
+ }
566
+ let workingDirectory;
567
+ try {
568
+ workingDirectory = await ensureTempDirectory({
569
+ agentName: LLM_AGENT_NAME,
570
+ agentMarkdown: AGENT_MARKDOWN,
571
+ });
572
+ } catch (error) {
573
+ failGenerate(
574
+ `LLM temp directory failed (${clientErrorMessage(error, 'ensure failed')})`,
575
+ 'llm_attachment_generation_unavailable',
576
+ );
577
+ }
578
+ if (typeof workingDirectory !== 'string' || !workingDirectory.trim()) {
579
+ failGenerate(
580
+ 'LLM temp directory is empty; attachment generation blocked',
581
+ 'llm_attachment_generation_unavailable',
582
+ );
583
+ }
584
+ return await generateViaAttachmentSession({
585
+ client,
586
+ workingDirectory: workingDirectory.trim(),
511
587
  providerID,
512
588
  modelID,
513
589
  variant,
514
- messages: forwardImageParts
515
- ? messages
516
- : messages.map((message) => {
517
- if (!Array.isArray(message?.parts)) return message;
518
- const parts = message.parts.filter((part) => part?.type !== 'file' || !String(part.mime || '').startsWith('image/'));
519
- return parts.length === message.parts.length ? message : { ...message, parts };
520
- }),
590
+ system: flattened.system,
591
+ prompt: flattened.prompt,
592
+ imageFiles,
521
593
  signal: requestSignal,
594
+ onTextDelta,
595
+ globalEventHub,
522
596
  });
523
597
  }
524
598
 
525
- const workingDirectory = await ensureTempDirectory({
526
- agentName: LLM_AGENT_NAME,
527
- agentMarkdown: AGENT_MARKDOWN,
599
+ // Text path: no session. System prompt is prepended into the single prompt string.
600
+ const prompt = [flattened.system, flattened.prompt].filter((part) => String(part || '').trim()).join('\n\n');
601
+ return await generateViaTextApi({
602
+ client,
603
+ providerID,
604
+ modelID,
605
+ variant,
606
+ prompt,
607
+ signal: requestSignal,
528
608
  });
529
- const client = clientFactory
530
- ? clientFactory()
531
- : createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
532
-
533
- let toolIds;
534
- try {
535
- toolIds = await client.tool.ids({ directory: workingDirectory });
536
- } catch (error) {
537
- failGenerate(`OpenCode tool.ids failed: ${error?.message || 'tool.ids failed'}`);
538
- }
539
- if (toolIds?.error) {
540
- failGenerate(`OpenCode tool.ids failed: ${sdkErrorMessage(toolIds, 'tool.ids failed')}`);
541
- }
542
- const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
543
-
544
- const created = await client.session.create({
545
- directory: workingDirectory,
546
- title: '[openchamber-llm] generate',
547
- agent: LLM_AGENT_NAME,
548
- // Session fence: deny every native OpenCode/MCP/skill tool for this throwaway.
549
- permission: LLM_SESSION_DENY_PERMISSION,
550
- metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
551
- }, { signal: requestSignal });
552
- const sessionID = created?.data?.id;
553
- if (created?.error || !sessionID) {
554
- failGenerate(`OpenCode LLM session create failed: ${sdkErrorMessage(created, 'create failed')}`);
555
- }
556
-
557
- let unsubscribeDeltas = null;
558
- try {
559
- const archiveAt = Date.now();
560
- let archived = await client.session.update({
561
- sessionID,
562
- directory: workingDirectory,
563
- time: { archived: archiveAt },
564
- });
565
- if (isMissing(archived)) {
566
- archived = await client.session.update({
567
- sessionID,
568
- directory: workingDirectory,
569
- time: { archived: archiveAt },
570
- });
571
- }
572
- if (archived?.error) {
573
- failGenerate(`OpenCode LLM session archive failed: ${sdkErrorMessage(archived, 'archive failed')}`);
574
- }
575
-
576
- // Subscribe before promptAsync so early message.part.delta tokens are not missed.
577
- unsubscribeDeltas = subscribeThrowawayTextDeltas({
578
- globalEventHub,
579
- sessionID,
580
- onTextDelta: (text) => {
581
- bumpTimeout();
582
- if (typeof onTextDelta === 'function') onTextDelta(text);
583
- },
584
- });
585
-
586
- // promptAsync still forwards model/parts/tools. v2 session.prompt does not.
587
- const prompted = await client.session.promptAsync({
588
- sessionID,
589
- directory: workingDirectory,
590
- agent: LLM_AGENT_NAME,
591
- model: { providerID, modelID },
592
- ...(variant ? { variant } : {}),
593
- ...(flattened.system ? { system: flattened.system } : {}),
594
- tools,
595
- parts: [
596
- { type: 'text', text: flattened.prompt, synthetic: false },
597
- ...promptFiles,
598
- ],
599
- }, { signal: requestSignal });
600
- if (!promptAdmitted(prompted)) {
601
- failGenerate(`OpenCode LLM promptAsync failed: ${sdkErrorMessage(prompted, 'promptAsync failed')}`);
602
- }
603
- bumpTimeout();
604
-
605
- let text = assistantTextFromPrompt(prompted?.data ?? prompted);
606
- if (!text.trim()) {
607
- const settled = await waitForIdleAssistant({
608
- client,
609
- sessionID,
610
- directory: workingDirectory,
611
- signal: requestSignal,
612
- onProgress: bumpTimeout,
613
- settlePollMs,
614
- });
615
- text = assistantTextFromMessages(settled);
616
- }
617
- if (!text.trim()) {
618
- failGenerate('OpenCode LLM generator returned no assistant text');
619
- }
620
- return { text: text.trim(), source: 'throwaway-session' };
621
- } finally {
622
- try {
623
- unsubscribeDeltas?.();
624
- } catch {
625
- // Unsubscribe must not mask generate errors or block session delete.
626
- }
627
- unsubscribeDeltas = null;
628
- try {
629
- const deleted = await client.session.delete({ sessionID, directory: workingDirectory });
630
- if (deleted?.error) {
631
- console.warn(
632
- '[llm] failed to delete throwaway OpenCode session:',
633
- sdkErrorMessage(deleted, 'delete failed'),
634
- );
635
- }
636
- } catch (error) {
637
- console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
638
- }
639
- }
640
609
  } finally {
641
- clearTimeout(stallTimer);
610
+ clearTimeout(timeout);
642
611
  }
643
612
  }
644
613
 
645
614
  export const _test = {
646
615
  flattenMessages,
647
616
  filesForPrompt,
617
+ imageFilesForSession,
648
618
  describeContactFilePart,
649
- deniedTools,
650
- assistantTextFromPrompt,
651
619
  assistantTextFromMessages,
652
620
  eventPayload,
653
- eventDeltaProperties,
621
+ eventDeltaData,
654
622
  subscribeThrowawayTextDeltas,
623
+ assertLlmAgentDenyAll,
624
+ parseDataUrl,
625
+ assertValidAttachmentParts,
655
626
  LLM_AGENT_NAME,
656
627
  AGENT_MARKDOWN,
657
- LLM_SESSION_DENY_PERMISSION,
628
+ MAX_ATTACHMENT_BYTES,
658
629
  GENERATE_TIMEOUT_MS,
659
630
  };