local-operator-ui 0.20.1 → 0.21.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 (68) hide show
  1. package/out/main/index.js +462 -343
  2. package/out/preload/index.js +10 -0
  3. package/out/renderer/assets/{_basePickBy-C7yOlBwG.js → _basePickBy-laWkgx91.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BxWDXUxR.js → _baseUniq-BqC22eo2.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-DGusXpiv.js → agent-details-page-DGagheZ3.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-BEy-xjst.js → agent-hub-page-D32dOI0k.js} +1 -1
  7. package/out/renderer/assets/{agents-page-VfOUQ-yP.js → agents-page-Svf2IDW4.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-358ZDLa0.js → architectureDiagram-IEHRJDOE-DnZHTBwm.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-tzEHVS4j.js → blockDiagram-JOT3LUYC-DYgjchjt.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-D3tP-CCo.js → c4Diagram-VJAJSXHY-BcWXeTv-.js} +1 -1
  11. package/out/renderer/assets/channel-D3VO50ky.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-BKUSTR_S.js → chunk-4BMEZGHF-BEHPBqRs.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-DvXPJSTp.js → chunk-A2AXSNBT-B9_c_zdY.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-iJyV8fDW.js → chunk-AEK57VVT-CRDJdW1n.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-B3DDJ3MU.js → chunk-D6G4REZN-ttF3lcfY.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-Dbn_07ZJ.js → chunk-RZ5BOZE2-DrXSLYPB.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-ChdMxZnm.js → chunk-XZIHB7SX-C21vkWhM.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-DvvsZs_g.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DvvsZs_g.js +1 -0
  20. package/out/renderer/assets/clone-CxJL_Jp5.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-duvMYksr.js → compact-pagination-D6D-kfpz.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-BuSM1V0w.js → dagre-OKDRZEBW-OI9InyrW.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-39xt9Mcn.js → diagram-SSKATNLV-DjLE_tpn.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-Bgyt_DWD.js → diagram-VNBRO52H-QNILArDh.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DNfsSD46.js → erDiagram-Q7BY3M3F-B5zUJ0BX.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DbS8UHwN.js → flowDiagram-4HSFHLVR-CT_emQp8.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-Dxdp2V-6.js → ganttDiagram-APWFNJXF-BIkl41-h.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BLrXJPe3.js → gitGraphDiagram-7IBYFJ6S-o1q3a_aW.js} +1 -1
  29. package/out/renderer/assets/{graph-Cgkq5xQ7.js → graph-5IfVp3-D.js} +1 -1
  30. package/out/renderer/assets/{icon-RIkwUxL5.js → icon-DfC9xz6s.js} +1 -1
  31. package/out/renderer/assets/icon-DurcHu6h.css +1 -0
  32. package/out/renderer/assets/{index-lEoaSpWQ.js → index-3JYAvVIP.js} +1 -1
  33. package/out/renderer/assets/{index-CLwRuOtJ.js → index-B2sOYNB-.js} +309 -307
  34. package/out/renderer/assets/{index-C4RcIL0i.js → index-DyCsy2F5.js} +1 -1
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DR6So_J8.js → infoDiagram-PH2N3AL5-DlM0nQ0T.js} +1 -1
  36. package/out/renderer/assets/{installer-Ck14_4Oh.js → installer-BVVQxXae.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-DubpieGX.js → journeyDiagram-U35MCT3I-DNEgTjMB.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BtwsU2Rg.js → kanban-definition-NDS4AKOZ-CEYF5q_9.js} +1 -1
  39. package/out/renderer/assets/{layout-BDeRPl4h.js → layout-DE06iqUP.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-ZyBMetf1.js → legacy-agents-page-eOxYX-8u.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-d-O-_a6F.js → mermaid.core-BQYlUB6i.js} +5 -5
  42. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BCZdiKEB.js → mindmap-definition-ALO5MXBD-CRJFFljH.js} +1 -1
  43. package/out/renderer/assets/{page-header-D-uc8qqA.js → page-header-CzSDxSlW.js} +1 -1
  44. package/out/renderer/assets/{parseISO-PdZfrqAM.js → parseISO-CuttNio1.js} +1 -1
  45. package/out/renderer/assets/{pieDiagram-IB7DONF6-Bd2e7gHe.js → pieDiagram-IB7DONF6-Djn6bGuC.js} +1 -1
  46. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-oypZsih9.js → quadrantDiagram-7GDLP6J5-DLPeaSj9.js} +1 -1
  47. package/out/renderer/assets/{radar-MK3ICKWK-c4YagPOX.js → radar-MK3ICKWK-D1c7Dfxy.js} +1 -1
  48. package/out/renderer/assets/{radient-auth-buttons-BUlY7XDi.js → radient-auth-buttons-C4t-Tn4o.js} +1 -1
  49. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-7UoAwEus.js → requirementDiagram-KVF5MWMF-Bh1Vsk-Y.js} +1 -1
  50. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-JStPMuRd.js → sankeyDiagram-QLVOVGJD-CFE_4lHF.js} +1 -1
  51. package/out/renderer/assets/{schedules-page-IjBtVuIo.js → schedules-page-BMho-_qy.js} +1 -1
  52. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-Lene9gQ-.js → sequenceDiagram-X6HHIX6F-DN47XoiL.js} +1 -1
  53. package/out/renderer/assets/{settings-page-BqJRfztd.js → settings-page-UQGdkspM.js} +1 -1
  54. package/out/renderer/assets/{square-pen-CQEKS3JT.js → square-pen-DPhq73Ln.js} +1 -1
  55. package/out/renderer/assets/{stateDiagram-DGXRK772-umC2tGUK.js → stateDiagram-DGXRK772-CkRlRCxN.js} +1 -1
  56. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-C4bVPoTE.js +1 -0
  57. package/out/renderer/assets/{timeline-definition-BDJGKUSR-h2KfXqek.js → timeline-definition-BDJGKUSR-D_FilQ9b.js} +1 -1
  58. package/out/renderer/assets/{use-agent-like-mutation-BanZ02f7.js → use-agent-like-mutation-9LC-lCBi.js} +1 -1
  59. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BOH5ZQcH.js → xychartDiagram-VJFVF3MP-sgYxwCpl.js} +1 -1
  60. package/out/renderer/index.html +3 -3
  61. package/out/renderer/installer.html +3 -3
  62. package/package.json +2 -2
  63. package/out/renderer/assets/channel-D37fF6my.js +0 -1
  64. package/out/renderer/assets/classDiagram-GIVACNV2-CcnNtrPC.js +0 -1
  65. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CcnNtrPC.js +0 -1
  66. package/out/renderer/assets/clone-DPqLvbSr.js +0 -1
  67. package/out/renderer/assets/icon-HSrrv8O-.css +0 -1
  68. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Cp1tcztr.js +0 -1
package/out/main/index.js CHANGED
@@ -6,11 +6,11 @@ const utils = require("@electron-toolkit/utils");
6
6
  const electron = require("electron");
7
7
  const posthogNode = require("posthog-node");
8
8
  const path = require("path");
9
+ const zod = require("zod");
9
10
  const node_child_process = require("node:child_process");
10
11
  const node_crypto = require("node:crypto");
11
12
  const os = require("node:os");
12
13
  const node_util = require("node:util");
13
- const zod = require("zod");
14
14
  const dotenv = require("dotenv");
15
15
  const electronLog = require("electron-log");
16
16
  const https = require("node:https");
@@ -35,291 +35,6 @@ const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path$1);
35
35
  const https__namespace = /* @__PURE__ */ _interopNamespaceDefault(https);
36
36
  const icon = path.join(__dirname, "../../resources/icon.png");
37
37
  const id$1 = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
38
- const MAX_UPLOAD_BYTES = 16 * 1024 * 1024;
39
- const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
40
- const mediaRequestSchema = zod.z.discriminatedUnion("op", [
41
- zod.z.object({
42
- op: zod.z.literal("speech.create"),
43
- request: zod.z.record(zod.z.unknown())
44
- }).strict(),
45
- zod.z.object({
46
- op: zod.z.literal("speech.agent"),
47
- agentId: id$1,
48
- request: zod.z.record(zod.z.unknown())
49
- }).strict(),
50
- zod.z.object({
51
- op: zod.z.literal("transcription.create"),
52
- fileName: zod.z.string().min(1).max(255),
53
- mimeType: zod.z.string().min(1).max(128),
54
- fields: zod.z.record(zod.z.string().max(4096))
55
- }).strict(),
56
- zod.z.object({
57
- op: zod.z.literal("agent.import"),
58
- fileName: zod.z.string().min(1).max(255)
59
- }).strict(),
60
- // Export is a gated GET that answers with a ZIP, so it belongs on this relay
61
- // rather than the JSON transport: `desktopEndpoint`'s envelope has nowhere to
62
- // put binary. It is the only read here, which is why `endpoint()` below
63
- // returns a method instead of assuming POST.
64
- zod.z.object({ op: zod.z.literal("agent.export"), agentId: id$1 }).strict(),
65
- // A durable transcript row references an image by content digest with the
66
- // payload stripped, so rendering a screenshot after a reload means fetching
67
- // bytes. That cannot go through the JSON transport (its envelope has nowhere
68
- // to put them), which is what puts it on this relay.
69
- //
70
- // Both identifiers are shape-constrained here as well as by the backend
71
- // route: this file's whole premise is that renderer code cannot pick a URL,
72
- // and a digest that reaches `endpoint()` unvalidated is renderer-controlled
73
- // path text.
74
- zod.z.object({
75
- op: zod.z.literal("sessions.attachment"),
76
- sessionId: zod.z.string().regex(/^[a-f0-9]{12}$/),
77
- digest: zod.z.string().regex(/^[a-f0-9]{32}$/)
78
- }).strict()
79
- ]);
80
- function endpoint(request) {
81
- switch (request.op) {
82
- case "speech.create":
83
- return { path: "/v1/tools/speech", method: "POST" };
84
- case "speech.agent":
85
- return { path: `/v1/agents/${request.agentId}/speech`, method: "POST" };
86
- case "transcription.create":
87
- return { path: "/v1/transcriptions", method: "POST" };
88
- case "agent.import":
89
- return { path: "/v1/agents/import", method: "POST" };
90
- case "agent.export":
91
- return { path: `/v1/agents/${request.agentId}/export`, method: "GET" };
92
- case "sessions.attachment":
93
- return {
94
- path: `/v1/desktop/sessions/${request.sessionId}/attachments/${request.digest}`,
95
- method: "GET"
96
- };
97
- }
98
- }
99
- async function requestDesktopMedia(input, bytes, backendUrl, token) {
100
- const parsed = mediaRequestSchema.safeParse(input);
101
- if (!parsed.success) {
102
- return { status: 422, kind: "error", detail: "Invalid media operation." };
103
- }
104
- if (!token) {
105
- return {
106
- status: 503,
107
- kind: "error",
108
- detail: "Restart with a desktop-managed backend to use these controls."
109
- };
110
- }
111
- const request = parsed.data;
112
- if (bytes && bytes.byteLength > MAX_UPLOAD_BYTES) {
113
- return { status: 413, kind: "error", detail: "This file is too large." };
114
- }
115
- const target2 = endpoint(request);
116
- let body;
117
- let contentType;
118
- if (request.op === "agent.export" || request.op === "sessions.attachment") {
119
- body = void 0;
120
- } else if (request.op === "speech.create" || request.op === "speech.agent") {
121
- body = JSON.stringify(request.request);
122
- contentType = "application/json";
123
- } else {
124
- if (!bytes) {
125
- return { status: 422, kind: "error", detail: "A file is required." };
126
- }
127
- const form = new FormData();
128
- const blob = new Blob([bytes], {
129
- type: request.op === "transcription.create" ? request.mimeType : "application/zip"
130
- });
131
- form.append("file", blob, request.fileName);
132
- if (request.op === "transcription.create") {
133
- for (const [key, value] of Object.entries(request.fields)) {
134
- form.append(key, value);
135
- }
136
- }
137
- body = form;
138
- }
139
- try {
140
- const response = await fetch(new URL(target2.path, backendUrl), {
141
- method: target2.method,
142
- headers: {
143
- Accept: "application/json, audio/*, image/*, application/octet-stream",
144
- ...contentType ? { "Content-Type": contentType } : {},
145
- Authorization: `Bearer ${token}`
146
- },
147
- body,
148
- redirect: "error",
149
- signal: AbortSignal.timeout(12e4)
150
- });
151
- const responseType = response.headers.get("content-type") ?? "";
152
- if (!response.ok) {
153
- let detail = "The media request failed.";
154
- if (responseType.includes("application/json")) {
155
- try {
156
- const parsedError = await response.json();
157
- const text = parsedError.detail ?? parsedError.message;
158
- if (typeof text === "string") detail = text.slice(0, 512);
159
- } catch {
160
- }
161
- }
162
- return { status: response.status, kind: "error", detail };
163
- }
164
- if (responseType.includes("application/json")) {
165
- return {
166
- status: response.status,
167
- kind: "json",
168
- body: await response.json()
169
- };
170
- }
171
- const buffer = new Uint8Array(await response.arrayBuffer());
172
- if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
173
- return {
174
- status: 502,
175
- kind: "error",
176
- detail: "The backend returned too much data."
177
- };
178
- }
179
- return {
180
- status: response.status,
181
- kind: "bytes",
182
- mimeType: responseType || "application/octet-stream",
183
- data: buffer
184
- };
185
- } catch {
186
- return {
187
- status: 503,
188
- kind: "error",
189
- detail: "The backend could not complete this request. Check its connection and try again."
190
- };
191
- }
192
- }
193
- const SESSION_ID = /^[a-f0-9]{12}$/;
194
- const SAFE_ID = /^[a-zA-Z0-9_-]{1,128}$/;
195
- const MAX_FRAME_BYTES = 8 * 1024 * 1024;
196
- class DesktopStreamRelay {
197
- constructor(backendUrl, token) {
198
- this.backendUrl = backendUrl;
199
- this.token = token;
200
- }
201
- streams = /* @__PURE__ */ new Map();
202
- /** Frame observer for main-owned side effects (notifications). It sees
203
- * every parsed frame before the renderer does, on the same subscription. */
204
- observer = null;
205
- observe(observer) {
206
- this.observer = observer;
207
- }
208
- /** True when this relay can authenticate; a false answer is why the UI
209
- * shows the pairing/setup state rather than a dead transcript. */
210
- get available() {
211
- return Boolean(this.token);
212
- }
213
- subscribe(args, emit) {
214
- if (!SESSION_ID.test(args.sessionId)) {
215
- throw new Error("Invalid session for streaming.");
216
- }
217
- if (args.epoch !== void 0 && !SAFE_ID.test(args.epoch)) {
218
- throw new Error("Invalid stream epoch.");
219
- }
220
- if (args.afterSeq !== void 0 && (!Number.isInteger(args.afterSeq) || args.afterSeq < 0)) {
221
- throw new Error("Invalid stream cursor.");
222
- }
223
- if (!this.token) {
224
- throw new Error("This backend was not started with desktop controls.");
225
- }
226
- const streamId = node_crypto.randomBytes(16).toString("hex");
227
- const controller = new AbortController();
228
- this.streams.set(streamId, controller);
229
- const query = new URLSearchParams();
230
- if (args.epoch) query.set("epoch", args.epoch);
231
- if (args.afterSeq !== void 0)
232
- query.set("after_seq", String(args.afterSeq));
233
- const suffix = query.size > 0 ? `?${query}` : "";
234
- const url = new URL(
235
- `/v1/desktop/sessions/${args.sessionId}/events${suffix}`,
236
- this.backendUrl
237
- );
238
- void this.pump(
239
- streamId,
240
- args.sessionId,
241
- url,
242
- controller.signal,
243
- emit
244
- ).finally(() => {
245
- this.streams.delete(streamId);
246
- });
247
- return { streamId };
248
- }
249
- async pump(streamId, sessionId2, url, signal, emit) {
250
- try {
251
- const response = await fetch(url, {
252
- headers: {
253
- Accept: "text/event-stream",
254
- Authorization: `Bearer ${this.token}`
255
- },
256
- redirect: "error",
257
- signal
258
- });
259
- if (!response.ok || !response.body) {
260
- emit({
261
- streamId,
262
- kind: "error",
263
- detail: `The event stream was refused (${response.status}).`
264
- });
265
- return;
266
- }
267
- const reader = response.body.getReader();
268
- const decoder = new TextDecoder();
269
- let buffer = "";
270
- for (; ; ) {
271
- const { done, value } = await reader.read();
272
- if (done) break;
273
- buffer += decoder.decode(value, { stream: true });
274
- for (; ; ) {
275
- const boundary = buffer.indexOf("\n\n");
276
- if (boundary < 0) {
277
- if (buffer.length > MAX_FRAME_BYTES) {
278
- emit({
279
- streamId,
280
- kind: "error",
281
- detail: "The event stream exceeded its frame budget."
282
- });
283
- reader.cancel().catch(() => void 0);
284
- return;
285
- }
286
- break;
287
- }
288
- const record = buffer.slice(0, boundary);
289
- buffer = buffer.slice(boundary + 2);
290
- for (const line of record.split("\n")) {
291
- if (line.startsWith("data:")) {
292
- const data = line.slice(5).replace(/^ /, "");
293
- this.observer?.(sessionId2, data);
294
- emit({ streamId, kind: "data", data });
295
- }
296
- }
297
- }
298
- }
299
- emit({ streamId, kind: "end" });
300
- } catch (error) {
301
- if (signal.aborted) {
302
- emit({ streamId, kind: "end" });
303
- return;
304
- }
305
- emit({
306
- streamId,
307
- kind: "error",
308
- detail: error instanceof Error && error.name === "AbortError" ? "The event stream ended." : "The event stream connection failed."
309
- });
310
- }
311
- }
312
- unsubscribe(streamId) {
313
- if (!/^[a-f0-9]{32}$/.test(streamId)) return;
314
- this.streams.get(streamId)?.abort();
315
- this.streams.delete(streamId);
316
- }
317
- dispose() {
318
- for (const controller of this.streams.values()) controller.abort();
319
- this.streams.clear();
320
- }
321
- }
322
- const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
323
38
  const settingKey = zod.z.string().min(1).max(256).regex(/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/);
324
39
  const secret = zod.z.string().min(1).max(32768);
325
40
  const sessionId = zod.z.string().regex(/^[a-f0-9]{12}$/);
@@ -418,11 +133,37 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
418
133
  cwd: zod.z.string().min(1).max(4096),
419
134
  target: target.optional()
420
135
  }).strict(),
136
+ /*
137
+ * What a NEW conversation's readings would be, without creating anything.
138
+ *
139
+ * The composer's status strip needs a model, an effort ladder and a window
140
+ * BEFORE a session exists, and every honest way to get them was rejected: a
141
+ * `sessions.create` on pane open writes a directory and a marker, so every
142
+ * abandoned new-chat pane would leave a visible empty row in the sidebar,
143
+ * and composing it in the renderer from `config.get` + `models.catalogue`
144
+ * moves model RESOLUTION (Python policy) into the app and reports nothing
145
+ * when the pair is absent from the catalogue. This op runs the backend's own
146
+ * cold resolution and returns the same canonical projection a cold session
147
+ * publishes, so the strip keeps one arithmetic path and the draft cannot
148
+ * disagree with the session the first send creates.
149
+ *
150
+ * Body and response mirror `sessions.create` deliberately: same `cwd`, same
151
+ * optional `target`, same 422 for an unresolvable profile. The response is a
152
+ * `CanonicalFrontendSync` — the wire shape `sessions.watch` streams — whose
153
+ * `snapshot.session_id` is EMPTY, because there is no session. The renderer
154
+ * passes it to the strip and never into the canonical sessions store.
155
+ */
156
+ zod.z.object({
157
+ op: zod.z.literal("sessions.preview"),
158
+ requestId,
159
+ cwd: zod.z.string().min(1).max(4096),
160
+ target: target.optional()
161
+ }).strict(),
421
162
  zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
422
163
  zod.z.object({
423
164
  op: zod.z.literal("sessions.history"),
424
165
  sessionId,
425
- beforeId: id.optional(),
166
+ beforeId: id$1.optional(),
426
167
  limit: zod.z.number().int().min(1).max(500).optional()
427
168
  }).strict(),
428
169
  zod.z.object({
@@ -444,8 +185,8 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
444
185
  zod.z.object({
445
186
  op: zod.z.literal("sessions.answer"),
446
187
  sessionId,
447
- epoch: id,
448
- requestId: id,
188
+ epoch: id$1,
189
+ requestId: id$1,
449
190
  value: zod.z.string().max(32768).optional(),
450
191
  approved: zod.z.boolean().optional(),
451
192
  questionIndex: zod.z.number().int().min(0).optional()
@@ -494,8 +235,8 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
494
235
  zod.z.object({ op: zod.z.literal("legacy.models.providers") }).strict(),
495
236
  // Reachability is checked only when the user asks for it: a probe is a real
496
237
  // network round trip, and nothing behind first paint may make one.
497
- zod.z.object({ op: zod.z.literal("auth.probe"), provider: id }).strict(),
498
- zod.z.object({ op: zod.z.literal("legacy.agent.upload"), agentId: id }).strict(),
238
+ zod.z.object({ op: zod.z.literal("auth.probe"), provider: id$1 }).strict(),
239
+ zod.z.object({ op: zod.z.literal("legacy.agent.upload"), agentId: id$1 }).strict(),
499
240
  zod.z.object({
500
241
  op: zod.z.literal("legacy.agents.list"),
501
242
  page: zod.z.number().int().min(1).optional(),
@@ -504,19 +245,19 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
504
245
  sort: zod.z.string().max(64).optional(),
505
246
  direction: zod.z.enum(["asc", "desc"]).optional()
506
247
  }).strict(),
507
- zod.z.object({ op: zod.z.literal("legacy.agent.get"), agentId: id }).strict(),
248
+ zod.z.object({ op: zod.z.literal("legacy.agent.get"), agentId: id$1 }).strict(),
508
249
  zod.z.object({
509
250
  op: zod.z.literal("legacy.agent.history"),
510
- agentId: id,
251
+ agentId: id$1,
511
252
  page: zod.z.number().int().min(1).optional(),
512
253
  perPage: zod.z.number().int().min(1).max(500).optional()
513
254
  }).strict(),
514
255
  zod.z.object({
515
256
  op: zod.z.literal("legacy.jobs.list"),
516
- agentId: id.optional(),
257
+ agentId: id$1.optional(),
517
258
  status: zod.z.string().max(64).optional()
518
259
  }).strict(),
519
- zod.z.object({ op: zod.z.literal("legacy.job.get"), jobId: id }).strict(),
260
+ zod.z.object({ op: zod.z.literal("legacy.job.get"), jobId: id$1 }).strict(),
520
261
  // The schedules surface. Gated in managed mode not because a schedule is
521
262
  // sensitive to READ but because both writes hand their `prompt` to the
522
263
  // scheduler, which later runs it as the user's own agent -- so a bare
@@ -528,13 +269,13 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
528
269
  }).strict(),
529
270
  zod.z.object({
530
271
  op: zod.z.literal("legacy.agent.schedules.list"),
531
- agentId: id,
272
+ agentId: id$1,
532
273
  page: zod.z.number().int().min(1).optional(),
533
274
  perPage: zod.z.number().int().min(1).max(100).optional()
534
275
  }).strict(),
535
276
  zod.z.object({
536
277
  op: zod.z.literal("legacy.agent.schedule.create"),
537
- agentId: id,
278
+ agentId: id$1,
538
279
  // Passed through as the request body. Declared field-by-field rather
539
280
  // than as a passthrough object so the renderer cannot smuggle keys the
540
281
  // backend model would silently accept.
@@ -544,10 +285,10 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
544
285
  unit: scheduleUnit
545
286
  })
546
287
  }).strict(),
547
- zod.z.object({ op: zod.z.literal("legacy.schedule.get"), scheduleId: id }).strict(),
288
+ zod.z.object({ op: zod.z.literal("legacy.schedule.get"), scheduleId: id$1 }).strict(),
548
289
  zod.z.object({
549
290
  op: zod.z.literal("legacy.schedule.edit"),
550
- scheduleId: id,
291
+ scheduleId: id$1,
551
292
  // Every field optional: the backend applies `exclude_unset`, so sending
552
293
  // a key the user did not touch would overwrite it with a default.
553
294
  schedule: scheduleWrite.extend({
@@ -556,7 +297,7 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
556
297
  unit: scheduleUnit.nullish()
557
298
  })
558
299
  }).strict(),
559
- zod.z.object({ op: zod.z.literal("legacy.schedule.remove"), scheduleId: id }).strict(),
300
+ zod.z.object({ op: zod.z.literal("legacy.schedule.remove"), scheduleId: id$1 }).strict(),
560
301
  // The remaining gated legacy writes and reads the renderer still issued as
561
302
  // bare same-origin `fetch`. `apiConfig.baseUrl` points AT THE BACKEND, not
562
303
  // through the main-process relay, so in managed mode these went out with no
@@ -572,52 +313,52 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
572
313
  }).strict(),
573
314
  zod.z.object({
574
315
  op: zod.z.literal("legacy.agent.update"),
575
- agentId: id,
316
+ agentId: id$1,
576
317
  update: zod.z.record(zod.z.unknown())
577
318
  }).strict(),
578
- zod.z.object({ op: zod.z.literal("legacy.agent.delete"), agentId: id }).strict(),
579
- zod.z.object({ op: zod.z.literal("legacy.agent.conversation.clear"), agentId: id }).strict(),
580
- zod.z.object({ op: zod.z.literal("legacy.agent.systemPrompt.get"), agentId: id }).strict(),
319
+ zod.z.object({ op: zod.z.literal("legacy.agent.delete"), agentId: id$1 }).strict(),
320
+ zod.z.object({ op: zod.z.literal("legacy.agent.conversation.clear"), agentId: id$1 }).strict(),
321
+ zod.z.object({ op: zod.z.literal("legacy.agent.systemPrompt.get"), agentId: id$1 }).strict(),
581
322
  zod.z.object({
582
323
  op: zod.z.literal("legacy.agent.systemPrompt.update"),
583
- agentId: id,
324
+ agentId: id$1,
584
325
  systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
585
326
  }).strict(),
586
- zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id }).strict(),
587
- zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id }).strict(),
327
+ zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id$1 }).strict(),
328
+ zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id$1 }).strict(),
588
329
  zod.z.object({
589
330
  op: zod.z.literal("legacy.agent.variables.create"),
590
- agentId: id,
331
+ agentId: id$1,
591
332
  variable: zod.z.record(zod.z.unknown())
592
333
  }).strict(),
593
334
  zod.z.object({
594
335
  op: zod.z.literal("legacy.agent.variables.get"),
595
- agentId: id,
336
+ agentId: id$1,
596
337
  key: variableKey
597
338
  }).strict(),
598
339
  zod.z.object({
599
340
  op: zod.z.literal("legacy.agent.variables.update"),
600
- agentId: id,
341
+ agentId: id$1,
601
342
  key: variableKey,
602
343
  variable: zod.z.record(zod.z.unknown())
603
344
  }).strict(),
604
345
  zod.z.object({
605
346
  op: zod.z.literal("legacy.agent.variables.delete"),
606
- agentId: id,
347
+ agentId: id$1,
607
348
  key: variableKey
608
349
  }).strict(),
609
- zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id }).strict(),
350
+ zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id$1 }).strict(),
610
351
  zod.z.object({ op: zod.z.literal("commands.list") }).strict(),
611
352
  zod.z.object({
612
353
  op: zod.z.literal("commands.entities"),
613
354
  sessionId,
614
- command: id,
355
+ command: id$1,
615
356
  name: zod.z.string().max(128).optional()
616
357
  }).strict(),
617
358
  zod.z.object({ op: zod.z.literal("models.catalogue"), live: zod.z.boolean().optional() }).strict(),
618
359
  zod.z.object({
619
360
  op: zod.z.literal("usage.get"),
620
- provider: id.optional(),
361
+ provider: id$1.optional(),
621
362
  live: zod.z.boolean().optional(),
622
363
  refresh: zod.z.boolean().optional()
623
364
  }).strict(),
@@ -628,7 +369,7 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
628
369
  untilMs: zod.z.number().int().nonnegative().optional(),
629
370
  days: zod.z.number().int().min(1).max(366).optional()
630
371
  }).strict(),
631
- zod.z.object({ op: zod.z.literal("skills.list"), sessionId, name: id.optional() }).strict(),
372
+ zod.z.object({ op: zod.z.literal("skills.list"), sessionId, name: id$1.optional() }).strict(),
632
373
  zod.z.object({ op: zod.z.literal("sessions.failovers"), sessionId }).strict(),
633
374
  zod.z.object({
634
375
  op: zod.z.literal("sessions.credential"),
@@ -737,10 +478,10 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
737
478
  "account.agents"
738
479
  ]),
739
480
  request_id: requestId.optional(),
740
- tenant_id: id.optional(),
741
- account_id: id.optional(),
742
- agent_id: id.optional(),
743
- comment_id: id.optional(),
481
+ tenant_id: id$1.optional(),
482
+ account_id: id$1.optional(),
483
+ agent_id: id$1.optional(),
484
+ comment_id: id$1.optional(),
744
485
  query: zod.z.record(zod.z.union([zod.z.string().max(1024), zod.z.number().int()])).optional(),
745
486
  payload: zod.z.record(zod.z.unknown()).optional(),
746
487
  confirmed: zod.z.boolean().optional()
@@ -753,12 +494,12 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
753
494
  accountId: zod.z.number().int().positive(),
754
495
  confirmed: zod.z.literal(true)
755
496
  }).strict(),
756
- zod.z.object({ op: zod.z.literal("auth.start"), provider: id }).strict(),
757
- zod.z.object({ op: zod.z.literal("auth.status"), id }).strict(),
758
- zod.z.object({ op: zod.z.literal("auth.input"), id, promptId: id, value: secret }).strict(),
759
- zod.z.object({ op: zod.z.literal("auth.cancel"), id }).strict(),
760
- zod.z.object({ op: zod.z.literal("auth.key"), provider: id, value: secret }).strict(),
761
- zod.z.object({ op: zod.z.literal("auth.logout"), provider: id }).strict(),
497
+ zod.z.object({ op: zod.z.literal("auth.start"), provider: id$1 }).strict(),
498
+ zod.z.object({ op: zod.z.literal("auth.status"), id: id$1 }).strict(),
499
+ zod.z.object({ op: zod.z.literal("auth.input"), id: id$1, promptId: id$1, value: secret }).strict(),
500
+ zod.z.object({ op: zod.z.literal("auth.cancel"), id: id$1 }).strict(),
501
+ zod.z.object({ op: zod.z.literal("auth.key"), provider: id$1, value: secret }).strict(),
502
+ zod.z.object({ op: zod.z.literal("auth.logout"), provider: id$1 }).strict(),
762
503
  zod.z.object({ op: zod.z.literal("settings.list") }).strict(),
763
504
  zod.z.object({
764
505
  op: zod.z.literal("settings.edit"),
@@ -876,6 +617,16 @@ function desktopEndpoint(request) {
876
617
  ...request.target ? { target: request.target } : {}
877
618
  }
878
619
  };
620
+ case "sessions.preview":
621
+ return {
622
+ path: "/v1/desktop/sessions/preview",
623
+ method: "POST",
624
+ body: {
625
+ request_id: request.requestId,
626
+ cwd: request.cwd,
627
+ ...request.target ? { target: request.target } : {}
628
+ }
629
+ };
879
630
  case "sessions.get":
880
631
  return {
881
632
  path: `/v1/desktop/sessions/${request.sessionId}`,
@@ -1262,18 +1013,305 @@ function desktopEndpoint(request) {
1262
1013
  return { path: "/v1/config/system-prompt", method: "GET" };
1263
1014
  case "instructions.update":
1264
1015
  return {
1265
- path: "/v1/config/system-prompt",
1266
- method: "PATCH",
1267
- body: { content: request.content }
1016
+ path: "/v1/config/system-prompt",
1017
+ method: "PATCH",
1018
+ body: { content: request.content }
1019
+ };
1020
+ case "credentials.list":
1021
+ return { path: "/v1/credentials", method: "GET" };
1022
+ case "credentials.update":
1023
+ return {
1024
+ path: "/v1/credentials",
1025
+ method: "PATCH",
1026
+ body: { key: request.key, value: request.value }
1027
+ };
1028
+ }
1029
+ }
1030
+ const MAX_PROBE_PATHS = 64;
1031
+ const MAX_FILE_READ_BYTES = 64 * 1024 * 1024;
1032
+ const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
1033
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024;
1034
+ const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
1035
+ const mediaRequestSchema = zod.z.discriminatedUnion("op", [
1036
+ zod.z.object({
1037
+ op: zod.z.literal("speech.create"),
1038
+ request: zod.z.record(zod.z.unknown())
1039
+ }).strict(),
1040
+ zod.z.object({
1041
+ op: zod.z.literal("speech.agent"),
1042
+ agentId: id,
1043
+ request: zod.z.record(zod.z.unknown())
1044
+ }).strict(),
1045
+ zod.z.object({
1046
+ op: zod.z.literal("transcription.create"),
1047
+ fileName: zod.z.string().min(1).max(255),
1048
+ mimeType: zod.z.string().min(1).max(128),
1049
+ fields: zod.z.record(zod.z.string().max(4096))
1050
+ }).strict(),
1051
+ zod.z.object({
1052
+ op: zod.z.literal("agent.import"),
1053
+ fileName: zod.z.string().min(1).max(255)
1054
+ }).strict(),
1055
+ // Export is a gated GET that answers with a ZIP, so it belongs on this relay
1056
+ // rather than the JSON transport: `desktopEndpoint`'s envelope has nowhere to
1057
+ // put binary. It is the only read here, which is why `endpoint()` below
1058
+ // returns a method instead of assuming POST.
1059
+ zod.z.object({ op: zod.z.literal("agent.export"), agentId: id }).strict(),
1060
+ // A durable transcript row references an image by content digest with the
1061
+ // payload stripped, so rendering a screenshot after a reload means fetching
1062
+ // bytes. That cannot go through the JSON transport (its envelope has nowhere
1063
+ // to put them), which is what puts it on this relay.
1064
+ //
1065
+ // Both identifiers are shape-constrained here as well as by the backend
1066
+ // route: this file's whole premise is that renderer code cannot pick a URL,
1067
+ // and a digest that reaches `endpoint()` unvalidated is renderer-controlled
1068
+ // path text.
1069
+ zod.z.object({
1070
+ op: zod.z.literal("sessions.attachment"),
1071
+ sessionId: zod.z.string().regex(/^[a-f0-9]{12}$/),
1072
+ digest: zod.z.string().regex(/^[a-f0-9]{32}$/)
1073
+ }).strict()
1074
+ ]);
1075
+ function endpoint(request) {
1076
+ switch (request.op) {
1077
+ case "speech.create":
1078
+ return { path: "/v1/tools/speech", method: "POST" };
1079
+ case "speech.agent":
1080
+ return { path: `/v1/agents/${request.agentId}/speech`, method: "POST" };
1081
+ case "transcription.create":
1082
+ return { path: "/v1/transcriptions", method: "POST" };
1083
+ case "agent.import":
1084
+ return { path: "/v1/agents/import", method: "POST" };
1085
+ case "agent.export":
1086
+ return { path: `/v1/agents/${request.agentId}/export`, method: "GET" };
1087
+ case "sessions.attachment":
1088
+ return {
1089
+ path: `/v1/desktop/sessions/${request.sessionId}/attachments/${request.digest}`,
1090
+ method: "GET"
1091
+ };
1092
+ }
1093
+ }
1094
+ async function requestDesktopMedia(input, bytes, backendUrl, token) {
1095
+ const parsed = mediaRequestSchema.safeParse(input);
1096
+ if (!parsed.success) {
1097
+ return { status: 422, kind: "error", detail: "Invalid media operation." };
1098
+ }
1099
+ if (!token) {
1100
+ return {
1101
+ status: 503,
1102
+ kind: "error",
1103
+ detail: "Restart with a desktop-managed backend to use these controls."
1104
+ };
1105
+ }
1106
+ const request = parsed.data;
1107
+ if (bytes && bytes.byteLength > MAX_UPLOAD_BYTES) {
1108
+ return { status: 413, kind: "error", detail: "This file is too large." };
1109
+ }
1110
+ const target2 = endpoint(request);
1111
+ let body;
1112
+ let contentType;
1113
+ if (request.op === "agent.export" || request.op === "sessions.attachment") {
1114
+ body = void 0;
1115
+ } else if (request.op === "speech.create" || request.op === "speech.agent") {
1116
+ body = JSON.stringify(request.request);
1117
+ contentType = "application/json";
1118
+ } else {
1119
+ if (!bytes) {
1120
+ return { status: 422, kind: "error", detail: "A file is required." };
1121
+ }
1122
+ const form = new FormData();
1123
+ const blob = new Blob([bytes], {
1124
+ type: request.op === "transcription.create" ? request.mimeType : "application/zip"
1125
+ });
1126
+ form.append("file", blob, request.fileName);
1127
+ if (request.op === "transcription.create") {
1128
+ for (const [key, value] of Object.entries(request.fields)) {
1129
+ form.append(key, value);
1130
+ }
1131
+ }
1132
+ body = form;
1133
+ }
1134
+ try {
1135
+ const response = await fetch(new URL(target2.path, backendUrl), {
1136
+ method: target2.method,
1137
+ headers: {
1138
+ Accept: "application/json, audio/*, image/*, application/octet-stream",
1139
+ ...contentType ? { "Content-Type": contentType } : {},
1140
+ Authorization: `Bearer ${token}`
1141
+ },
1142
+ body,
1143
+ redirect: "error",
1144
+ signal: AbortSignal.timeout(12e4)
1145
+ });
1146
+ const responseType = response.headers.get("content-type") ?? "";
1147
+ if (!response.ok) {
1148
+ let detail = "The media request failed.";
1149
+ if (responseType.includes("application/json")) {
1150
+ try {
1151
+ const parsedError = await response.json();
1152
+ const text = parsedError.detail ?? parsedError.message;
1153
+ if (typeof text === "string") detail = text.slice(0, 512);
1154
+ } catch {
1155
+ }
1156
+ }
1157
+ return { status: response.status, kind: "error", detail };
1158
+ }
1159
+ if (responseType.includes("application/json")) {
1160
+ return {
1161
+ status: response.status,
1162
+ kind: "json",
1163
+ body: await response.json()
1268
1164
  };
1269
- case "credentials.list":
1270
- return { path: "/v1/credentials", method: "GET" };
1271
- case "credentials.update":
1165
+ }
1166
+ const buffer = new Uint8Array(await response.arrayBuffer());
1167
+ if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
1272
1168
  return {
1273
- path: "/v1/credentials",
1274
- method: "PATCH",
1275
- body: { key: request.key, value: request.value }
1169
+ status: 502,
1170
+ kind: "error",
1171
+ detail: "The backend returned too much data."
1276
1172
  };
1173
+ }
1174
+ return {
1175
+ status: response.status,
1176
+ kind: "bytes",
1177
+ mimeType: responseType || "application/octet-stream",
1178
+ data: buffer
1179
+ };
1180
+ } catch {
1181
+ return {
1182
+ status: 503,
1183
+ kind: "error",
1184
+ detail: "The backend could not complete this request. Check its connection and try again."
1185
+ };
1186
+ }
1187
+ }
1188
+ const SESSION_ID = /^[a-f0-9]{12}$/;
1189
+ const SAFE_ID = /^[a-zA-Z0-9_-]{1,128}$/;
1190
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
1191
+ class DesktopStreamRelay {
1192
+ constructor(backendUrl, token) {
1193
+ this.backendUrl = backendUrl;
1194
+ this.token = token;
1195
+ }
1196
+ streams = /* @__PURE__ */ new Map();
1197
+ /** Frame observer for main-owned side effects (notifications). It sees
1198
+ * every parsed frame before the renderer does, on the same subscription. */
1199
+ observer = null;
1200
+ observe(observer) {
1201
+ this.observer = observer;
1202
+ }
1203
+ /** True when this relay can authenticate; a false answer is why the UI
1204
+ * shows the pairing/setup state rather than a dead transcript. */
1205
+ get available() {
1206
+ return Boolean(this.token);
1207
+ }
1208
+ subscribe(args, emit) {
1209
+ if (!SESSION_ID.test(args.sessionId)) {
1210
+ throw new Error("Invalid session for streaming.");
1211
+ }
1212
+ if (args.epoch !== void 0 && !SAFE_ID.test(args.epoch)) {
1213
+ throw new Error("Invalid stream epoch.");
1214
+ }
1215
+ if (args.afterSeq !== void 0 && (!Number.isInteger(args.afterSeq) || args.afterSeq < 0)) {
1216
+ throw new Error("Invalid stream cursor.");
1217
+ }
1218
+ if (!this.token) {
1219
+ throw new Error("This backend was not started with desktop controls.");
1220
+ }
1221
+ const streamId = node_crypto.randomBytes(16).toString("hex");
1222
+ const controller = new AbortController();
1223
+ this.streams.set(streamId, controller);
1224
+ const query = new URLSearchParams();
1225
+ if (args.epoch) query.set("epoch", args.epoch);
1226
+ if (args.afterSeq !== void 0)
1227
+ query.set("after_seq", String(args.afterSeq));
1228
+ const suffix = query.size > 0 ? `?${query}` : "";
1229
+ const url = new URL(
1230
+ `/v1/desktop/sessions/${args.sessionId}/events${suffix}`,
1231
+ this.backendUrl
1232
+ );
1233
+ void this.pump(
1234
+ streamId,
1235
+ args.sessionId,
1236
+ url,
1237
+ controller.signal,
1238
+ emit
1239
+ ).finally(() => {
1240
+ this.streams.delete(streamId);
1241
+ });
1242
+ return { streamId };
1243
+ }
1244
+ async pump(streamId, sessionId2, url, signal, emit) {
1245
+ try {
1246
+ const response = await fetch(url, {
1247
+ headers: {
1248
+ Accept: "text/event-stream",
1249
+ Authorization: `Bearer ${this.token}`
1250
+ },
1251
+ redirect: "error",
1252
+ signal
1253
+ });
1254
+ if (!response.ok || !response.body) {
1255
+ emit({
1256
+ streamId,
1257
+ kind: "error",
1258
+ detail: `The event stream was refused (${response.status}).`
1259
+ });
1260
+ return;
1261
+ }
1262
+ const reader = response.body.getReader();
1263
+ const decoder = new TextDecoder();
1264
+ let buffer = "";
1265
+ for (; ; ) {
1266
+ const { done, value } = await reader.read();
1267
+ if (done) break;
1268
+ buffer += decoder.decode(value, { stream: true });
1269
+ for (; ; ) {
1270
+ const boundary = buffer.indexOf("\n\n");
1271
+ if (boundary < 0) {
1272
+ if (buffer.length > MAX_FRAME_BYTES) {
1273
+ emit({
1274
+ streamId,
1275
+ kind: "error",
1276
+ detail: "The event stream exceeded its frame budget."
1277
+ });
1278
+ reader.cancel().catch(() => void 0);
1279
+ return;
1280
+ }
1281
+ break;
1282
+ }
1283
+ const record = buffer.slice(0, boundary);
1284
+ buffer = buffer.slice(boundary + 2);
1285
+ for (const line of record.split("\n")) {
1286
+ if (line.startsWith("data:")) {
1287
+ const data = line.slice(5).replace(/^ /, "");
1288
+ this.observer?.(sessionId2, data);
1289
+ emit({ streamId, kind: "data", data });
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+ emit({ streamId, kind: "end" });
1295
+ } catch (error) {
1296
+ if (signal.aborted) {
1297
+ emit({ streamId, kind: "end" });
1298
+ return;
1299
+ }
1300
+ emit({
1301
+ streamId,
1302
+ kind: "error",
1303
+ detail: error instanceof Error && error.name === "AbortError" ? "The event stream ended." : "The event stream connection failed."
1304
+ });
1305
+ }
1306
+ }
1307
+ unsubscribe(streamId) {
1308
+ if (!/^[a-f0-9]{32}$/.test(streamId)) return;
1309
+ this.streams.get(streamId)?.abort();
1310
+ this.streams.delete(streamId);
1311
+ }
1312
+ dispose() {
1313
+ for (const controller of this.streams.values()) controller.abort();
1314
+ this.streams.clear();
1277
1315
  }
1278
1316
  }
1279
1317
  async function requestDesktop(input, backendUrl, token) {
@@ -7492,6 +7530,14 @@ function describeWindowLaunch(plan) {
7492
7530
  return `window mode ${plan.mode}: ${plan.width}x${plan.height}, ${behaviour}, page throttling ${plan.backgroundThrottling ? "on" : "off"}`;
7493
7531
  }
7494
7532
  const BASE64_FILE_EXTENSIONS = ["csv", "tsv", "xls", "xlsx", "ods"];
7533
+ const resolveUserPath = (filePath, cwd) => {
7534
+ if (filePath === "~") return electron.app.getPath("home");
7535
+ if (filePath.startsWith("~/"))
7536
+ return path$1.join(electron.app.getPath("home"), filePath.slice(2));
7537
+ if (cwd && !filePath.startsWith("/"))
7538
+ return path$1.join(cwd.startsWith("~/") ? resolveUserPath(cwd) : cwd, filePath);
7539
+ return filePath;
7540
+ };
7495
7541
  electron.app.setName("Local Operator");
7496
7542
  const image = electron.nativeImage.createFromPath(icon);
7497
7543
  if (process.platform === "darwin" && electron.app.dock) {
@@ -7856,8 +7902,7 @@ electron.app.whenReady().then(async () => {
7856
7902
  "read-file",
7857
7903
  async (_, filePath, encoding = "utf-8") => {
7858
7904
  try {
7859
- const normalizedPath = filePath.startsWith("~/") ? path$1.join(electron.app.getPath("home"), filePath.slice(2)) : filePath;
7860
- const data = fs.readFileSync(normalizedPath, encoding);
7905
+ const data = fs.readFileSync(resolveUserPath(filePath), encoding);
7861
7906
  return { success: true, data };
7862
7907
  } catch (error) {
7863
7908
  logger.error("Error reading file:", LogFileType.BACKEND, error);
@@ -7865,6 +7910,82 @@ electron.app.whenReady().then(async () => {
7865
7910
  }
7866
7911
  }
7867
7912
  );
7913
+ electron.ipcMain.handle(
7914
+ "probe-files",
7915
+ async (_, paths, cwd) => {
7916
+ const asked = Array.isArray(paths) ? paths.filter((path2) => typeof path2 === "string") : [];
7917
+ return asked.slice(0, MAX_PROBE_PATHS).map((input) => {
7918
+ let resolved = input;
7919
+ try {
7920
+ resolved = resolveUserPath(input, cwd);
7921
+ const stat = fs.statSync(resolved, { throwIfNoEntry: false });
7922
+ return {
7923
+ input,
7924
+ resolved,
7925
+ exists: stat !== void 0,
7926
+ isFile: stat?.isFile() ?? false,
7927
+ sizeBytes: stat?.isFile() ? stat.size : null,
7928
+ mtimeMs: stat?.isFile() ? stat.mtimeMs : null
7929
+ };
7930
+ } catch (error) {
7931
+ return {
7932
+ input,
7933
+ resolved,
7934
+ exists: false,
7935
+ isFile: false,
7936
+ sizeBytes: null,
7937
+ mtimeMs: null,
7938
+ error: error instanceof Error ? error.message : String(error)
7939
+ };
7940
+ }
7941
+ });
7942
+ }
7943
+ );
7944
+ electron.ipcMain.handle(
7945
+ "read-file-bytes",
7946
+ async (_, filePath, maxBytes = MAX_FILE_READ_BYTES) => {
7947
+ const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.min(maxBytes, MAX_FILE_READ_BYTES) : MAX_FILE_READ_BYTES;
7948
+ try {
7949
+ const resolved = resolveUserPath(filePath);
7950
+ const stat = fs.statSync(resolved, { throwIfNoEntry: false });
7951
+ if (!stat) {
7952
+ return {
7953
+ success: false,
7954
+ code: "not-found",
7955
+ error: `No file at ${resolved}`
7956
+ };
7957
+ }
7958
+ if (!stat.isFile()) {
7959
+ return {
7960
+ success: false,
7961
+ code: "not-a-file",
7962
+ error: `${resolved} is not a regular file`
7963
+ };
7964
+ }
7965
+ if (stat.size > cap) {
7966
+ return {
7967
+ success: false,
7968
+ code: "too-large",
7969
+ error: `${resolved} is ${stat.size} bytes, over the ${cap}-byte preview cap`,
7970
+ sizeBytes: stat.size
7971
+ };
7972
+ }
7973
+ const buffer = fs.readFileSync(resolved);
7974
+ return {
7975
+ success: true,
7976
+ data: new Uint8Array(buffer),
7977
+ sizeBytes: buffer.byteLength
7978
+ };
7979
+ } catch (error) {
7980
+ logger.error("Error reading file bytes:", LogFileType.BACKEND, error);
7981
+ return {
7982
+ success: false,
7983
+ code: "unreadable",
7984
+ error: error instanceof Error ? error.message : String(error)
7985
+ };
7986
+ }
7987
+ }
7988
+ );
7868
7989
  electron.ipcMain.handle("open-external", async (_, url) => {
7869
7990
  try {
7870
7991
  await electron.shell.openExternal(url);
@@ -7883,8 +8004,7 @@ electron.app.whenReady().then(async () => {
7883
8004
  "save-file",
7884
8005
  async (_, filePath, content, encoding = "utf-8") => {
7885
8006
  try {
7886
- const normalizedPath = filePath.startsWith("~/") ? path$1.join(electron.app.getPath("home"), filePath.slice(2)) : filePath;
7887
- fs.writeFileSync(normalizedPath, content, encoding);
8007
+ fs.writeFileSync(resolveUserPath(filePath), content, encoding);
7888
8008
  } catch (error) {
7889
8009
  logger.error("Error saving file:", LogFileType.BACKEND, error);
7890
8010
  throw error;
@@ -7892,14 +8012,13 @@ electron.app.whenReady().then(async () => {
7892
8012
  }
7893
8013
  );
7894
8014
  electron.ipcMain.handle("file-exists", async (_, filePath) => {
7895
- const normalizedPath = filePath.startsWith("~/") ? path$1.join(electron.app.getPath("home"), filePath.slice(2)) : filePath;
7896
- return fs.existsSync(normalizedPath);
8015
+ return fs.existsSync(resolveUserPath(filePath));
7897
8016
  });
7898
8017
  electron.ipcMain.handle("directory-exists", async (_, dirPath) => {
7899
- const home = electron.app.getPath("home");
7900
- const normalizedPath = dirPath === "~" ? home : dirPath.startsWith("~/") ? path$1.join(home, dirPath.slice(2)) : dirPath;
7901
8018
  try {
7902
- return fs.statSync(normalizedPath, { throwIfNoEntry: false })?.isDirectory() ?? false;
8019
+ return fs.statSync(resolveUserPath(dirPath), {
8020
+ throwIfNoEntry: false
8021
+ })?.isDirectory() ?? false;
7903
8022
  } catch {
7904
8023
  return false;
7905
8024
  }