@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.
- package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
- package/dist/assets/AssistantView-YmeyxehG.js +2 -0
- package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
- package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
- package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
- package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
- package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
- package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
- package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
- package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
- package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
- package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
- package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
- package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
- package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
- package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
- package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
- package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
- package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
- package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
- package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
- package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
- package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
- package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
- package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
- package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
- package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
- package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
- package/dist/assets/miniChat-B9c3y2pD.js +2 -0
- package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
- package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
- package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
- package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
- package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
- package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
- package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
- package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
- package/dist/assets/src-BpHDMgud.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
- package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
- package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
- package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
- package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
- package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
- package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistants/DOCUMENTATION.md +21 -6
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-store.js +64 -0
- package/server/lib/assistants/contact-store.test.js +12 -0
- package/server/lib/assistants/contact-tools.js +216 -25
- package/server/lib/assistants/contact-tools.test.js +167 -0
- package/server/lib/assistants/contracts.js +2 -2
- package/server/lib/assistants/harness.js +20 -3
- package/server/lib/assistants/harness.test.js +120 -1
- package/server/lib/assistants/pi-tools.js +50 -3
- package/server/lib/assistants/pi-tools.test.js +25 -0
- package/server/lib/assistants/service.js +320 -27
- package/server/lib/assistants/service.test.js +581 -9
- package/server/lib/llm/DOCUMENTATION.md +31 -18
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/llm/generate.js +43 -7
- package/server/lib/llm/generate.test.js +98 -1
- package/server/lib/llm/temp-directory.js +34 -13
- package/server/lib/llm/temp-directory.test.js +57 -0
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
- package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
- package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
- package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
- package/dist/assets/miniChat-BJ9AclZX.js +0 -2
- package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
- package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
- 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
|
|
33
|
-
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 {
|
|
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
|
|
105
|
+
it('marks vision from SDK capabilities and legacy modalities', () => {
|
|
50
106
|
const catalog = projectConnectedModels({
|
|
51
|
-
connected: ['openai'],
|
|
52
|
-
providers: [
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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 (
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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 });
|