@openchambery/web 1.19.11 → 1.19.13-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 (123) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +21 -6
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +216 -25
  87. package/server/lib/assistants/contact-tools.test.js +167 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +20 -3
  90. package/server/lib/assistants/harness.test.js +120 -1
  91. package/server/lib/assistants/pi-tools.js +50 -3
  92. package/server/lib/assistants/pi-tools.test.js +25 -0
  93. package/server/lib/assistants/service.js +320 -27
  94. package/server/lib/assistants/service.test.js +581 -9
  95. package/server/lib/llm/DOCUMENTATION.md +31 -18
  96. package/server/lib/llm/catalog.js +91 -16
  97. package/server/lib/llm/catalog.test.js +152 -11
  98. package/server/lib/llm/generate.js +43 -7
  99. package/server/lib/llm/generate.test.js +98 -1
  100. package/server/lib/llm/temp-directory.js +34 -13
  101. package/server/lib/llm/temp-directory.test.js +57 -0
  102. package/server/lib/notifications/APNS.md +20 -4
  103. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  104. package/server/lib/notifications/apns-runtime.js +136 -18
  105. package/server/lib/notifications/apns-runtime.test.js +128 -0
  106. package/server/lib/notifications/apns-titles.js +21 -1
  107. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  108. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  109. package/server/lib/notifications/runtime.js +93 -2
  110. package/server/lib/notifications/runtime.test.js +167 -4
  111. package/server/lib/opencode/settings-helpers.js +12 -0
  112. package/server/lib/opencode/settings-runtime.js +16 -0
  113. package/server/lib/opencode/settings-runtime.test.js +21 -0
  114. package/server/lib/scheduled-tasks/runtime.js +21 -0
  115. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  116. package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
  117. package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
  118. package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
  119. package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
  120. package/dist/assets/miniChat-BJ9AclZX.js +0 -2
  121. package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
  122. package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
  123. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -29,27 +29,40 @@ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
29
29
  the response is JSON (`Content-Type` or a JSON object body). SPA / OpenCode
30
30
  HTML `200 <!doctype` is not generate — fall through to the throwaway path.
31
31
  Bundled 1.18.4 has no sessionless generate.
32
- 2. Otherwise create a throwaway archived OpenCode session, deny every tool
33
- (`client.tool.ids()` `{ [id]: false }`), send our messages as
34
- `system` + user text via `session.promptAsync` (v2 `session.prompt` only
35
- forwards `{ id, prompt, delivery, resume }` and drops `model`/`parts`
36
- that produced empty assistant text and a 502). Contact file parts reuse the
37
- existing OpenCode `{ type: 'file', mime, url, filename? }` delivery shape
38
- (data URLs in the contact SQLite store not a second attachment store) and
39
- are forwarded on `promptAsync` only when the connected catalog marks that
40
- model as image-capable (`modalities.input`, `input`, or `attachment`).
41
- Non-vision models (for example deepseek-v4-flash) keep the `[image: …]`
42
- description and any text-file bytes, and skip image data URLs so generate
43
- cannot stall on unsupported vision parts. Non-image
44
- text files are also inlined into the flattened prompt. Wait for idle via
45
- `session.status` + `session.messages`, then delete the session. Throwaway
46
- sessions are created with
47
- `metadata.openchamber.llm.purpose = 'chat-completions'` (and archived
48
- immediately) so sidebar visibility, session-index, session-title, and
49
- notification/push fanout treat them as system-owned same contract as
32
+ 2. Otherwise create a throwaway archived OpenCode session under a hidden
33
+ `openchamber-llm` agent whose frontmatter uses OpenCode 1.18
34
+ `permission: { "*": deny }` (not legacy `action`/`resource`/`effect` lists —
35
+ those load without a terminal `*` deny). `session.create` also sets
36
+ `permission: [{ permission: '*', pattern: '*', action: 'deny' }]` (SDK
37
+ PermissionRuleset; verified to round-trip on the session object). Deny every
38
+ catalog tool via `client.tool.ids()` `{ [id]: false }` on `promptAsync`;
39
+ a `tool.ids` SDK/transport error fails generate (never silent empty deny).
40
+ Send messages as `system` + user text via `session.promptAsync` (v2
41
+ `session.prompt` only forwards `{ id, prompt, delivery, resume }` and drops
42
+ `model`/`parts` that produced empty assistant text and a 502). Contact
43
+ file parts reuse the existing OpenCode `{ type: 'file', mime, url, filename? }`
44
+ delivery shape (data URLs in the contact SQLite store not a second
45
+ attachment store) and are forwarded on `promptAsync` only when the connected
46
+ catalog marks that model as image-capable (`modalities.input`, `input`, or
47
+ `attachment`). Non-vision models (for example deepseek-v4-flash) keep the
48
+ `[image: …]` description and any text-file bytes, and skip image data URLs so
49
+ generate cannot stall on unsupported vision parts. Non-image text files are
50
+ also inlined into the flattened prompt. Wait for idle via `session.status` +
51
+ `session.messages`; a deterministic `session.messages` SDK error (for example
52
+ HTTP 400) fails generate immediately. Then delete the session and surface
53
+ delete SDK errors in diagnostics without erasing a successful text result.
54
+ Throwaway sessions use `metadata.openchamber.llm.purpose = 'chat-completions'`
55
+ (and archived immediately) so sidebar visibility, session-index, session-title,
56
+ and notification/push fanout treat them as system-owned — same contract as
50
57
  non-empty `smallModel.purpose`. This is a text generator only — never the
51
58
  contact transcript and never a coding SessionPrompt loop. Upstream
52
59
  `info.error.message` is forwarded on 502.
60
+ `ensureLlmTempDirectory` keeps one process-wide throwaway root (`openchamber-llm-…`).
61
+ Concurrent first callers share a single `mkdtemp` inflight; every caller still
62
+ writes/refreshes `.opencode/agent/<name>.md` after settle so a mid-flight ensure
63
+ cannot skip a newer agent body. Concurrent warm refreshes serialize through one
64
+ write chain (last committed body wins; no interleaved partial writes).
65
+ `stopLlmTempDirectory` clears the singleton for shutdown/tests.
53
66
 
54
67
  ### Internal token callback (in-process only)
55
68
 
@@ -5,8 +5,19 @@ const stringID = (value) => (typeof value === 'string' && value.trim() ? value.t
5
5
  const listIncludesImage = (value) => Array.isArray(value)
6
6
  && value.some((item) => String(item).toLowerCase() === 'image');
7
7
 
8
- function modelAcceptsImages(model) {
8
+ /**
9
+ * Vision capability projection.
10
+ * Prefer authoritative OpenCode SDK Model.capabilities (`input.image` / `attachment`),
11
+ * then fall back to legacy modalities/input/attachment shapes used by older fixtures.
12
+ */
13
+ export function modelAcceptsImages(model) {
9
14
  if (!isRecord(model)) return false;
15
+ if (isRecord(model.capabilities)) {
16
+ if (isRecord(model.capabilities.input) && typeof model.capabilities.input.image === 'boolean') {
17
+ return model.capabilities.input.image === true;
18
+ }
19
+ if (model.capabilities.attachment === true) return true;
20
+ }
10
21
  if (isRecord(model.modalities) && listIncludesImage(model.modalities.input)) return true;
11
22
  if (listIncludesImage(model.input)) return true;
12
23
  return model.attachment === true;
@@ -40,7 +51,12 @@ export function projectConnectedModels(source) {
40
51
  if (!isRecord(model)) continue;
41
52
  const modelID = stringID(model.id);
42
53
  if (!modelID) continue;
43
- const entry = { providerID, modelID, name: stringID(model.name) || modelID, acceptsImages: modelAcceptsImages(model) };
54
+ const entry = {
55
+ providerID,
56
+ modelID,
57
+ name: stringID(model.name) || modelID,
58
+ acceptsImages: modelAcceptsImages(model),
59
+ };
44
60
  models.push(entry);
45
61
  providerModels.push({ id: modelID, name: entry.name });
46
62
  }
@@ -76,25 +92,84 @@ export function isConnectedModel(catalog, providerID, modelID) {
76
92
  return catalog.models.some((entry) => entry.providerID === providerID && entry.modelID === modelID);
77
93
  }
78
94
 
95
+ /** Bound provider/config catalog loads so a pending SDK call cannot stall a contact lane. */
96
+ export const CONNECTED_CATALOG_TIMEOUT_MS = 8_000;
97
+
98
+ const isAbortError = (error) => (
99
+ error?.name === 'AbortError'
100
+ || error?.code === 'ABORT_ERR'
101
+ || (typeof error?.message === 'string' && /aborted|timed out/i.test(error.message))
102
+ );
103
+
79
104
  /**
80
105
  * Load the connected catalog from an OpenCode SDK client.
81
106
  * Failure is distinct from a successful empty catalog.
107
+ * Uses a bounded AbortSignal (default 8s) on provider.list + config.providers.
108
+ *
109
+ * @param {object} client
110
+ * @param {{ signal?: AbortSignal, timeoutMs?: number }} [options]
82
111
  */
83
- export async function loadConnectedCatalog(client) {
84
- const listed = await client.provider.list({});
85
- if (listed?.error || !isRecord(listed?.data)) {
86
- const error = new Error('OpenCode provider list is unavailable');
87
- error.code = 'upstream_error';
88
- throw error;
112
+ export async function loadConnectedCatalog(client, options = {}) {
113
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
114
+ ? Math.trunc(options.timeoutMs)
115
+ : CONNECTED_CATALOG_TIMEOUT_MS;
116
+ const parentSignal = options.signal;
117
+ const controller = new AbortController();
118
+ const onParentAbort = () => {
119
+ try {
120
+ controller.abort(parentSignal?.reason instanceof Error
121
+ ? parentSignal.reason
122
+ : new Error('Connected catalog aborted'));
123
+ } catch {
124
+ // ignore
125
+ }
126
+ };
127
+ if (parentSignal) {
128
+ if (parentSignal.aborted) onParentAbort();
129
+ else parentSignal.addEventListener('abort', onParentAbort, { once: true });
89
130
  }
90
- const configured = await client.config.providers({});
91
- if (configured?.error || !isRecord(configured?.data)) {
92
- const error = new Error('OpenCode provider catalog is unavailable');
93
- error.code = 'upstream_error';
131
+ const timer = setTimeout(() => {
132
+ try {
133
+ controller.abort(new Error(`Connected catalog timed out after ${timeoutMs}ms`));
134
+ } catch {
135
+ // ignore
136
+ }
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+
140
+ const requestOptions = { signal: controller.signal };
141
+ try {
142
+ const listed = await client.provider.list({}, requestOptions);
143
+ if (listed?.error || !isRecord(listed?.data)) {
144
+ const error = new Error('OpenCode provider list is unavailable');
145
+ error.code = 'upstream_error';
146
+ throw error;
147
+ }
148
+ const configured = await client.config.providers({}, requestOptions);
149
+ if (configured?.error || !isRecord(configured?.data)) {
150
+ const error = new Error('OpenCode provider catalog is unavailable');
151
+ error.code = 'upstream_error';
152
+ throw error;
153
+ }
154
+ return projectConnectedModels({
155
+ connected: listed.data.connected,
156
+ providers: configured.data.providers,
157
+ });
158
+ } catch (error) {
159
+ if (controller.signal.aborted || isAbortError(error)) {
160
+ const timedOut = typeof error?.message === 'string' && /timed out/i.test(error.message)
161
+ || (controller.signal.reason instanceof Error && /timed out/i.test(controller.signal.reason.message));
162
+ const next = new Error(
163
+ timedOut
164
+ ? `Connected catalog timed out after ${timeoutMs}ms`
165
+ : 'Connected catalog request was aborted',
166
+ );
167
+ next.code = 'upstream_error';
168
+ throw next;
169
+ }
94
170
  throw error;
171
+ } finally {
172
+ clearTimeout(timer);
173
+ parentSignal?.removeEventListener?.('abort', onParentAbort);
95
174
  }
96
- return projectConnectedModels({
97
- connected: listed.data.connected,
98
- providers: configured.data.providers,
99
- });
100
175
  }
@@ -1,5 +1,11 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
- import { loadConnectedCatalog, parseModelRef, projectConnectedModels } from './catalog.js'
2
+ import {
3
+ CONNECTED_CATALOG_TIMEOUT_MS,
4
+ loadConnectedCatalog,
5
+ modelAcceptsImages,
6
+ parseModelRef,
7
+ projectConnectedModels,
8
+ } from './catalog.js'
3
9
 
4
10
  describe('parseModelRef', () => {
5
11
  it('splits provider/model refs', () => {
@@ -22,6 +28,56 @@ describe('parseModelRef', () => {
22
28
  })
23
29
  })
24
30
 
31
+ describe('modelAcceptsImages', () => {
32
+ it('prefers authoritative SDK capabilities.input.image (Grok-shaped)', () => {
33
+ // Real OpenCode Model shape from types.gen Model.capabilities
34
+ expect(modelAcceptsImages({
35
+ id: 'grok-4.6',
36
+ name: 'Grok 4.6',
37
+ capabilities: {
38
+ temperature: true,
39
+ reasoning: true,
40
+ attachment: true,
41
+ toolcall: true,
42
+ input: { text: true, audio: false, image: true, video: false, pdf: false },
43
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
44
+ interleaved: false,
45
+ },
46
+ })).toBe(true)
47
+
48
+ expect(modelAcceptsImages({
49
+ id: 'deepseek-v4-flash',
50
+ capabilities: {
51
+ temperature: false,
52
+ reasoning: false,
53
+ attachment: false,
54
+ toolcall: true,
55
+ input: { text: true, audio: false, image: false, video: false, pdf: false },
56
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
57
+ interleaved: false,
58
+ },
59
+ })).toBe(false)
60
+ })
61
+
62
+ it('does not let legacy attachment override explicit capabilities.input.image=false', () => {
63
+ expect(modelAcceptsImages({
64
+ attachment: true,
65
+ modalities: { input: ['text', 'image'] },
66
+ capabilities: {
67
+ attachment: false,
68
+ input: { text: true, image: false },
69
+ },
70
+ })).toBe(false)
71
+ })
72
+
73
+ it('falls back to legacy modalities/attachment when capabilities are absent', () => {
74
+ expect(modelAcceptsImages({ modalities: { input: ['text', 'image'] } })).toBe(true)
75
+ expect(modelAcceptsImages({ input: ['image'] })).toBe(true)
76
+ expect(modelAcceptsImages({ attachment: true })).toBe(true)
77
+ expect(modelAcceptsImages({ id: 'plain' })).toBe(false)
78
+ })
79
+ })
80
+
25
81
  describe('projectConnectedModels', () => {
26
82
  it('projects only connected providers', () => {
27
83
  const catalog = projectConnectedModels({
@@ -46,21 +102,44 @@ describe('projectConnectedModels', () => {
46
102
  ])
47
103
  })
48
104
 
49
- it('marks vision models from modalities or attachment', () => {
105
+ it('marks vision from SDK capabilities and legacy modalities', () => {
50
106
  const catalog = projectConnectedModels({
51
- connected: ['openai'],
52
- providers: [{
53
- id: 'openai',
54
- name: 'OpenAI',
55
- models: {
56
- 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
57
- 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
107
+ connected: ['openai', 'xai'],
108
+ providers: [
109
+ {
110
+ id: 'openai',
111
+ name: 'OpenAI',
112
+ models: {
113
+ 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
114
+ 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
115
+ },
58
116
  },
59
- }],
117
+ {
118
+ id: 'xai',
119
+ name: 'xAI',
120
+ models: {
121
+ 'grok-4.6': {
122
+ id: 'grok-4.6',
123
+ name: 'Grok 4.6',
124
+ // Authoritative SDK shape — no modalities.input array
125
+ capabilities: {
126
+ temperature: true,
127
+ reasoning: true,
128
+ attachment: true,
129
+ toolcall: true,
130
+ input: { text: true, audio: false, image: true, video: false, pdf: false },
131
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
132
+ interleaved: false,
133
+ },
134
+ },
135
+ },
136
+ },
137
+ ],
60
138
  })
61
139
  expect(catalog.models.find((model) => model.modelID === 'gpt-4o')?.acceptsImages).toBe(true)
140
+ expect(catalog.models.find((model) => model.modelID === 'grok-4.6')?.acceptsImages).toBe(true)
62
141
  expect(catalog.models.find((model) => model.modelID === 'deepseek-v4-flash')?.acceptsImages).toBe(false)
63
- expect(catalog.connected).toEqual(['openai'])
142
+ expect(catalog.connected).toEqual(['openai', 'xai'])
64
143
  })
65
144
  })
66
145
 
@@ -75,4 +154,66 @@ describe('loadConnectedCatalog', () => {
75
154
  config: { providers: async () => ({ error: { status: 500 } }) },
76
155
  })).rejects.toThrow(/provider catalog/)
77
156
  })
157
+
158
+ it('passes AbortSignal to provider.list and config.providers', async () => {
159
+ const signals = []
160
+ const catalog = await loadConnectedCatalog({
161
+ provider: {
162
+ list: async (_params, options) => {
163
+ signals.push(options?.signal)
164
+ return { data: { connected: ['xai'] } }
165
+ },
166
+ },
167
+ config: {
168
+ providers: async (_params, options) => {
169
+ signals.push(options?.signal)
170
+ return {
171
+ data: {
172
+ providers: [{
173
+ id: 'xai',
174
+ name: 'xAI',
175
+ models: {
176
+ 'grok-4.6': {
177
+ id: 'grok-4.6',
178
+ name: 'Grok 4.6',
179
+ capabilities: {
180
+ input: { text: true, image: true },
181
+ attachment: true,
182
+ },
183
+ },
184
+ },
185
+ }],
186
+ },
187
+ }
188
+ },
189
+ },
190
+ })
191
+ expect(signals).toHaveLength(2)
192
+ expect(signals[0]).toBeInstanceOf(AbortSignal)
193
+ expect(signals[1]).toBe(signals[0])
194
+ expect(catalog.models).toEqual([
195
+ expect.objectContaining({
196
+ providerID: 'xai',
197
+ modelID: 'grok-4.6',
198
+ acceptsImages: true,
199
+ }),
200
+ ])
201
+ })
202
+
203
+ it('fails closed when the catalog load exceeds the deadline', async () => {
204
+ await expect(loadConnectedCatalog({
205
+ provider: {
206
+ list: async (_params, options) => new Promise((resolve, reject) => {
207
+ const onAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
208
+ if (options?.signal?.aborted) onAbort()
209
+ else options?.signal?.addEventListener('abort', onAbort, { once: true })
210
+ }),
211
+ },
212
+ config: { providers: async () => ({ data: { providers: [] } }) },
213
+ }, { timeoutMs: 30 })).rejects.toMatchObject({
214
+ code: 'upstream_error',
215
+ message: expect.stringMatching(/timed out|aborted/i),
216
+ })
217
+ expect(CONNECTED_CATALOG_TIMEOUT_MS).toBeGreaterThan(0)
218
+ })
78
219
  })
@@ -6,19 +6,34 @@ const SETTLE_POLL_MS = 250;
6
6
  const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
7
7
  const EMPTY_IDLE_PROBES = 5;
8
8
 
9
+ /**
10
+ * Session-level deny-all. OpenCode 1.18 session.create serializes this
11
+ * PermissionRuleset shape (permission/pattern/action). Confirmed on local
12
+ * 1.18.23/1.18.29: create returns the same rule on the session object.
13
+ */
14
+ export const LLM_SESSION_DENY_PERMISSION = Object.freeze([
15
+ Object.freeze({ permission: '*', pattern: '*', action: 'deny' }),
16
+ ]);
17
+
18
+ /**
19
+ * Agent frontmatter for OpenCode 1.18. Runtime /agent metadata uses
20
+ * permission:[{permission,pattern,action}]. Markdown config accepts the
21
+ * PermissionActionConfig map (`"*": deny`) / scalar `deny` / tools:"*":false —
22
+ * not the legacy action/resource/effect list (that loads but does not add
23
+ * a terminal * deny, so build-like allows and skill guidance stay visible).
24
+ */
9
25
  const AGENT_MARKDOWN = `---
10
26
  mode: primary
11
27
  hidden: true
12
- permissions:
13
- - action: "*"
14
- resource: "*"
15
- effect: deny
28
+ permission:
29
+ "*": deny
16
30
  ---
17
31
 
18
32
  You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
19
33
  The application executes its registered tools, including openchamber-tool JSON fences in your response, and supplies their results on the next request.
20
34
  Emit the requested application tool call when an action requires one. Your native tool permissions describe this generator process; the application's supplied tool catalog describes the assistant's capabilities.
21
35
  Report execution and failures from supplied tool results only.
36
+ Do not call native OpenCode tools, MCP tools, or skill tools. Native permissions are denied for this generator session.
22
37
  `;
23
38
 
24
39
  const isMissing = (result) =>
@@ -152,7 +167,11 @@ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) =>
152
167
  directory,
153
168
  limit: 20,
154
169
  }, { signal });
155
- if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
170
+ if (messagesResult?.error) {
171
+ // Deterministic upstream failures (e.g. 400) must not look like "still settling".
172
+ failGenerate(`OpenCode LLM session.messages failed: ${sdkErrorMessage(messagesResult, 'messages failed')}`);
173
+ }
174
+ if (Array.isArray(messagesResult?.data)) {
156
175
  const lastInfo = readMessageInfo(messagesResult.data.at(-1));
157
176
  if (lastInfo?.role === 'assistant') {
158
177
  emptyIdleProbes = 0;
@@ -469,13 +488,23 @@ export async function generateOpenCodeText({
469
488
  ? clientFactory()
470
489
  : createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
471
490
 
472
- const toolIds = await client.tool.ids({ directory: workingDirectory }).catch(() => ({ data: [] }));
491
+ let toolIds;
492
+ try {
493
+ toolIds = await client.tool.ids({ directory: workingDirectory });
494
+ } catch (error) {
495
+ failGenerate(`OpenCode tool.ids failed: ${error?.message || 'tool.ids failed'}`);
496
+ }
497
+ if (toolIds?.error) {
498
+ failGenerate(`OpenCode tool.ids failed: ${sdkErrorMessage(toolIds, 'tool.ids failed')}`);
499
+ }
473
500
  const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
474
501
 
475
502
  const created = await client.session.create({
476
503
  directory: workingDirectory,
477
504
  title: '[openchamber-llm] generate',
478
505
  agent: LLM_AGENT_NAME,
506
+ // Session fence: deny every native OpenCode/MCP/skill tool for this throwaway.
507
+ permission: LLM_SESSION_DENY_PERMISSION,
479
508
  metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
480
509
  }, { signal: controller.signal });
481
510
  const sessionID = created?.data?.id;
@@ -548,7 +577,13 @@ export async function generateOpenCodeText({
548
577
  }
549
578
  unsubscribeDeltas = null;
550
579
  try {
551
- await client.session.delete({ sessionID, directory: workingDirectory });
580
+ const deleted = await client.session.delete({ sessionID, directory: workingDirectory });
581
+ if (deleted?.error) {
582
+ console.warn(
583
+ '[llm] failed to delete throwaway OpenCode session:',
584
+ sdkErrorMessage(deleted, 'delete failed'),
585
+ );
586
+ }
552
587
  } catch (error) {
553
588
  console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
554
589
  }
@@ -570,4 +605,5 @@ export const _test = {
570
605
  subscribeThrowawayTextDeltas,
571
606
  LLM_AGENT_NAME,
572
607
  AGENT_MARKDOWN,
608
+ LLM_SESSION_DENY_PERMISSION,
573
609
  };
@@ -74,8 +74,11 @@ describe('generateOpenCodeText', () => {
74
74
  clientFactory: createOpencodeClient,
75
75
  ensureTempDirectory: async ({ agentMarkdown }) => {
76
76
  expect(agentMarkdown).toContain('openchamber-tool JSON fences')
77
- expect(agentMarkdown).toContain('effect: deny')
77
+ expect(agentMarkdown).toMatch(/"\*"\s*:\s*deny/)
78
+ expect(agentMarkdown).toContain('permission:')
79
+ expect(agentMarkdown).not.toContain('effect: deny')
78
80
  expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
81
+ expect(agentMarkdown).toContain('Native permissions are denied')
79
82
  return '/tmp/openchamber-llm'
80
83
  },
81
84
  detect: async () => ({ available: false, mode: 'throwaway-session' }),
@@ -83,6 +86,8 @@ describe('generateOpenCodeText', () => {
83
86
 
84
87
  expect(create).toHaveBeenCalledWith(expect.objectContaining({
85
88
  title: expect.stringContaining('[openchamber-llm]'),
89
+ agent: 'openchamber-llm',
90
+ permission: [{ permission: '*', pattern: '*', action: 'deny' }],
86
91
  metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
87
92
  }), expect.anything())
88
93
  expect(update).toHaveBeenCalled()
@@ -417,6 +422,98 @@ describe('generateOpenCodeText', () => {
417
422
  expect(subscribers.size).toBe(0)
418
423
  })
419
424
 
425
+ it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
426
+ const remove = vi.fn(async () => ({ data: true }))
427
+ await expect(generateOpenCodeText({
428
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
429
+ getOpenCodeAuthHeaders: () => ({}),
430
+ providerID: 'opencode',
431
+ modelID: 'gpt-5-nano',
432
+ messages: [{ role: 'user', content: 'hi' }],
433
+ clientFactory: () => ({
434
+ session: {
435
+ create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
436
+ update: vi.fn(),
437
+ prompt: vi.fn(),
438
+ promptAsync: vi.fn(),
439
+ status: vi.fn(),
440
+ messages: vi.fn(),
441
+ delete: remove,
442
+ },
443
+ tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
444
+ }),
445
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
446
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
447
+ })).rejects.toMatchObject({
448
+ code: 'upstream_error',
449
+ message: expect.stringContaining('tool.ids failed'),
450
+ })
451
+ expect(remove).not.toHaveBeenCalled()
452
+ })
453
+
454
+ it('throws when session.messages returns a deterministic 400', async () => {
455
+ const remove = vi.fn(async () => ({ data: true }))
456
+ await expect(generateOpenCodeText({
457
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
458
+ getOpenCodeAuthHeaders: () => ({}),
459
+ providerID: 'opencode',
460
+ modelID: 'gpt-5-nano',
461
+ messages: [{ role: 'user', content: 'hi' }],
462
+ clientFactory: () => ({
463
+ session: {
464
+ create: async () => ({ data: { id: 'ses_tmp' } }),
465
+ update: async () => ({ data: { id: 'ses_tmp' } }),
466
+ prompt: vi.fn(),
467
+ promptAsync: async () => ({ response: { status: 204 } }),
468
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
469
+ messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
470
+ delete: remove,
471
+ },
472
+ tool: { ids: async () => ({ data: ['bash'] }) },
473
+ }),
474
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
475
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
476
+ })).rejects.toMatchObject({
477
+ code: 'upstream_error',
478
+ message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
479
+ })
480
+ expect(remove).toHaveBeenCalled()
481
+ })
482
+
483
+ it('logs SDK delete errors without erasing a successful generate result', async () => {
484
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
485
+ try {
486
+ const result = await generateOpenCodeText({
487
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
488
+ getOpenCodeAuthHeaders: () => ({}),
489
+ providerID: 'opencode',
490
+ modelID: 'gpt-5-nano',
491
+ messages: [{ role: 'user', content: 'hi' }],
492
+ clientFactory: () => ({
493
+ session: {
494
+ create: async () => ({ data: { id: 'ses_tmp' } }),
495
+ update: async () => ({ data: { id: 'ses_tmp' } }),
496
+ prompt: vi.fn(),
497
+ promptAsync: async () => ({ response: { status: 204 } }),
498
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
499
+ messages: async () => ({ data: [completedAssistant('ok')] }),
500
+ delete: async () => ({ error: { status: 500, message: 'delete denied' } }),
501
+ },
502
+ tool: { ids: async () => ({ data: [] }) },
503
+ }),
504
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
505
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
506
+ })
507
+ expect(result).toEqual({ text: 'ok', source: 'throwaway-session' })
508
+ expect(warn).toHaveBeenCalledWith(
509
+ expect.stringContaining('[llm] failed to delete throwaway OpenCode session:'),
510
+ expect.stringContaining('delete denied'),
511
+ )
512
+ } finally {
513
+ warn.mockRestore()
514
+ }
515
+ })
516
+
420
517
  it('does not invent deltas on the sessionless /generate JSON path', async () => {
421
518
  const onTextDelta = vi.fn()
422
519
  const subscribers = new Set()
@@ -6,28 +6,49 @@ import path from 'node:path';
6
6
  let tempDirectory = null;
7
7
  /** @type {Promise<string> | null} */
8
8
  let inflight = null;
9
+ /** Serialize markdown refreshes so concurrent ensure cannot interleave one file. */
10
+ let writeChain = Promise.resolve();
11
+
12
+ const writeAgentMarkdown = async (root, agentName, agentMarkdown) => {
13
+ const agentDir = path.join(root, '.opencode', 'agent');
14
+ await fs.mkdir(agentDir, { recursive: true });
15
+ await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
16
+ };
17
+
18
+ const enqueueWrite = (work) => {
19
+ const run = writeChain.catch(() => {}).then(work);
20
+ writeChain = run.catch(() => {});
21
+ return run;
22
+ };
9
23
 
10
24
  export async function ensureLlmTempDirectory({ agentName, agentMarkdown }) {
11
- if (tempDirectory) return tempDirectory;
12
- if (!inflight) {
13
- inflight = (async () => {
14
- const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
15
- const agentDir = path.join(root, '.opencode', 'agent');
16
- await fs.mkdir(agentDir, { recursive: true });
17
- await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
18
- tempDirectory = root;
19
- return root;
20
- })().finally(() => {
21
- inflight = null;
22
- });
25
+ // Long-lived servers / HMR must refresh the hidden agent markdown so a
26
+ // permission-format fix is not stuck behind a stale temp directory.
27
+ // Concurrent first callers share one mkdtemp; every caller still writes its
28
+ // markdown after settle so a mid-flight ensure cannot skip a newer body.
29
+ // Concurrent warm writes are serialized (last committed body wins).
30
+ if (!tempDirectory) {
31
+ if (!inflight) {
32
+ inflight = (async () => {
33
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
34
+ tempDirectory = root;
35
+ return root;
36
+ })().finally(() => {
37
+ inflight = null;
38
+ });
39
+ }
40
+ await inflight;
23
41
  }
24
- return inflight;
42
+ const root = tempDirectory;
43
+ await enqueueWrite(() => writeAgentMarkdown(root, agentName, agentMarkdown));
44
+ return root;
25
45
  }
26
46
 
27
47
  export async function stopLlmTempDirectory() {
28
48
  const dir = tempDirectory;
29
49
  tempDirectory = null;
30
50
  inflight = null;
51
+ writeChain = Promise.resolve();
31
52
  if (!dir) return;
32
53
  try {
33
54
  await fs.rm(dir, { recursive: true, force: true });