local-operator-ui 0.16.0 → 0.17.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/out/main/index.js +461 -18
- package/out/preload/index.js +6 -0
- package/out/renderer/assets/{_basePickBy-Bw7mQqmp.js → _basePickBy-C3hLSrXE.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BnazTPmS.js → _baseUniq-DvVdHboz.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BtWGsmjK.js → agent-details-page-6irHwYok.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-w823LFh-.js → agent-hub-page-t3NZyvhe.js} +1 -1
- package/out/renderer/assets/{agents-page-B7ZWTJyU.js → agents-page-Cu3tV_JM.js} +2 -2
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-D2ubQTOC.js → architectureDiagram-IEHRJDOE-Dy32lq21.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-ByU_ahgT.js → blockDiagram-JOT3LUYC-D9CCHreU.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BC3KE4lQ.js → c4Diagram-VJAJSXHY-D6JYul4W.js} +1 -1
- package/out/renderer/assets/channel-CLzqtgQj.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-yCczBk2C.js → chunk-4BMEZGHF-CWZ3r21W.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-hQtr2CYL.js → chunk-A2AXSNBT-_jdJLEL4.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-BF7FMjiN.js → chunk-AEK57VVT-BRqxxp9X.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-D1ZkK01q.js → chunk-D6G4REZN-mvuW_LPO.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-CF-6knpx.js → chunk-RZ5BOZE2-B2kzBW8d.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-wxUXF_xN.js → chunk-XZIHB7SX-CqZmhFu_.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-Bu04i3t4.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bu04i3t4.js +1 -0
- package/out/renderer/assets/clone-DHy7FuoP.js +1 -0
- package/out/renderer/assets/{compact-pagination-D9Y6NSdy.js → compact-pagination-BJa5bxJe.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-CmhmcUUf.js → dagre-OKDRZEBW-BLi9lW3T.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Zehvro6p.js → diagram-SSKATNLV-DQa3WYjU.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-BarSLhrS.js → diagram-VNBRO52H-C99A8WuH.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-DPeXV5YU.js → erDiagram-Q7BY3M3F-DLWwdshb.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-CN5KLBDn.js → flowDiagram-4HSFHLVR-CHDQ3e9O.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-BZoKoMXG.js → ganttDiagram-APWFNJXF-G4Rf9Yhs.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-qg18taCn.js → gitGraphDiagram-7IBYFJ6S-2JN_jG8T.js} +1 -1
- package/out/renderer/assets/{graph-OhiwQ8kq.js → graph-BR85xHbv.js} +1 -1
- package/out/renderer/assets/icon-2KHv58kf.css +1 -0
- package/out/renderer/assets/{icon-DREivh6T.js → icon-2OdwuA9u.js} +32 -27
- package/out/renderer/assets/{index-BAltHHlD.js → index-BIyDfbOQ.js} +1 -1
- package/out/renderer/assets/{index-kC3Gu5qg.js → index-BuhuPWhs.js} +1 -1
- package/out/renderer/assets/{index-D5Z_uzU-.js → index-_UzVA1oU.js} +338 -301
- package/out/renderer/assets/index-aW0Bo_1a.css +1 -0
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-D9zrB_S2.js → infoDiagram-PH2N3AL5-xVzynFeN.js} +1 -1
- package/out/renderer/assets/installer-CwpKGBgz.js +16 -0
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CiKGQ8ZZ.js → journeyDiagram-U35MCT3I-C8-eHlWx.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CMVH6spE.js → kanban-definition-NDS4AKOZ-C2Oyzo6d.js} +1 -1
- package/out/renderer/assets/{layout-neDpz9Oy.js → layout-mlSyc_4q.js} +1 -1
- package/out/renderer/assets/legacy-agents-page-CTGA2lul.js +43 -0
- package/out/renderer/assets/{mermaid.core-1bIbhsqM.js → mermaid.core-BvFuUEpc.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-COKGXFAt.js → mindmap-definition-ALO5MXBD-DWnloutf.js} +1 -1
- package/out/renderer/assets/{page-header-CtwhpX97.js → page-header-C-9zjakn.js} +1 -1
- package/out/renderer/assets/{parseISO-DoGRsp53.js → parseISO-Bp3fMoKU.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-G5934Pyw.js → pieDiagram-IB7DONF6-B990Tpnk.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CXu2hVFd.js → quadrantDiagram-7GDLP6J5-BYeOekBR.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-C99cc5bu.js → radar-MK3ICKWK-DcxkUK5Z.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-D3fZKkWZ.js → radient-auth-buttons-CK9Icvbq.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BWEMU0o3.js → requirementDiagram-KVF5MWMF-D_tf_1lk.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-IBOCcssn.js → sankeyDiagram-QLVOVGJD-DlDfWN8k.js} +1 -1
- package/out/renderer/assets/{schedules-page-D4a05UgL.js → schedules-page-AQ9gMlkR.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CqE6tvSG.js → sequenceDiagram-X6HHIX6F-wr3-RcoT.js} +1 -1
- package/out/renderer/assets/{settings-page-BAZmRT08.js → settings-page-BSjdxTFW.js} +29 -29
- package/out/renderer/assets/{square-pen-C0syRM6p.js → square-pen-2Yxvf_S9.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-ZQrc2AeF.js → stateDiagram-DGXRK772-BLD6Vmgd.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsGkljza.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-C8wsYt9c.js → timeline-definition-BDJGKUSR-B-JeF04j.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-DdWy2oKh.js → use-agent-like-mutation-tstZNIUn.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-pTqLOs-T.js → xychartDiagram-VJFVF3MP-DuFpzjW6.js} +1 -1
- package/out/renderer/index.html +21 -6
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -2
- package/out/renderer/assets/channel-CBntux_E.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-7UDlKgfd.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-7UDlKgfd.js +0 -1
- package/out/renderer/assets/clone-DkhUgY-v.js +0 -1
- package/out/renderer/assets/icon-CvlWlLiK.css +0 -1
- package/out/renderer/assets/index-DikLkf3S.css +0 -1
- package/out/renderer/assets/installer-BRKNGt5t.js +0 -21
- package/out/renderer/assets/legacy-agents-page-Bjs1Z9g8.js +0 -43
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D7ygFjc0.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -63,7 +63,21 @@ const mediaRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
63
63
|
// rather than the JSON transport: `desktopEndpoint`'s envelope has nowhere to
|
|
64
64
|
// put binary. It is the only read here, which is why `endpoint()` below
|
|
65
65
|
// returns a method instead of assuming POST.
|
|
66
|
-
zod.z.object({ op: zod.z.literal("agent.export"), agentId: id$1 }).strict()
|
|
66
|
+
zod.z.object({ op: zod.z.literal("agent.export"), agentId: id$1 }).strict(),
|
|
67
|
+
// A durable transcript row references an image by content digest with the
|
|
68
|
+
// payload stripped, so rendering a screenshot after a reload means fetching
|
|
69
|
+
// bytes. That cannot go through the JSON transport (its envelope has nowhere
|
|
70
|
+
// to put them), which is what puts it on this relay.
|
|
71
|
+
//
|
|
72
|
+
// Both identifiers are shape-constrained here as well as by the backend
|
|
73
|
+
// route: this file's whole premise is that renderer code cannot pick a URL,
|
|
74
|
+
// and a digest that reaches `endpoint()` unvalidated is renderer-controlled
|
|
75
|
+
// path text.
|
|
76
|
+
zod.z.object({
|
|
77
|
+
op: zod.z.literal("sessions.attachment"),
|
|
78
|
+
sessionId: zod.z.string().regex(/^[a-f0-9]{12}$/),
|
|
79
|
+
digest: zod.z.string().regex(/^[a-f0-9]{32}$/)
|
|
80
|
+
}).strict()
|
|
67
81
|
]);
|
|
68
82
|
function endpoint(request) {
|
|
69
83
|
switch (request.op) {
|
|
@@ -77,6 +91,11 @@ function endpoint(request) {
|
|
|
77
91
|
return { path: "/v1/agents/import", method: "POST" };
|
|
78
92
|
case "agent.export":
|
|
79
93
|
return { path: `/v1/agents/${request.agentId}/export`, method: "GET" };
|
|
94
|
+
case "sessions.attachment":
|
|
95
|
+
return {
|
|
96
|
+
path: `/v1/desktop/sessions/${request.sessionId}/attachments/${request.digest}`,
|
|
97
|
+
method: "GET"
|
|
98
|
+
};
|
|
80
99
|
}
|
|
81
100
|
}
|
|
82
101
|
async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
@@ -98,7 +117,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
98
117
|
const target2 = endpoint(request);
|
|
99
118
|
let body;
|
|
100
119
|
let contentType;
|
|
101
|
-
if (request.op === "agent.export") {
|
|
120
|
+
if (request.op === "agent.export" || request.op === "sessions.attachment") {
|
|
102
121
|
body = void 0;
|
|
103
122
|
} else if (request.op === "speech.create" || request.op === "speech.agent") {
|
|
104
123
|
body = JSON.stringify(request.request);
|
|
@@ -123,7 +142,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
123
142
|
const response = await fetch(new URL(target2.path, backendUrl), {
|
|
124
143
|
method: target2.method,
|
|
125
144
|
headers: {
|
|
126
|
-
Accept: "application/json, audio/*, application/octet-stream",
|
|
145
|
+
Accept: "application/json, audio/*, image/*, application/octet-stream",
|
|
127
146
|
...contentType ? { "Content-Type": contentType } : {},
|
|
128
147
|
Authorization: `Bearer ${token}`
|
|
129
148
|
},
|
|
@@ -311,6 +330,8 @@ const sessionImage = zod.z.object({
|
|
|
311
330
|
data_b64: zod.z.string().min(1).max(1e6),
|
|
312
331
|
mime_type: zod.z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
|
313
332
|
}).strict();
|
|
333
|
+
const DESKTOP_MESSAGE_MAX_CHARS = 2e5;
|
|
334
|
+
const DESKTOP_SYSTEM_PROMPT_MAX_CHARS = 1e6;
|
|
314
335
|
const profileName = zod.z.string().min(1).max(128).refine(
|
|
315
336
|
(name) => name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && [...name].every(
|
|
316
337
|
(character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127
|
|
@@ -410,7 +431,7 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
410
431
|
op: zod.z.literal("sessions.message"),
|
|
411
432
|
sessionId,
|
|
412
433
|
requestId,
|
|
413
|
-
text: zod.z.string().max(
|
|
434
|
+
text: zod.z.string().max(DESKTOP_MESSAGE_MAX_CHARS),
|
|
414
435
|
images: zod.z.array(sessionImage).max(8).optional(),
|
|
415
436
|
mode: zod.z.enum(["prompt", "steer"]).optional()
|
|
416
437
|
}).strict(),
|
|
@@ -419,7 +440,7 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
419
440
|
sessionId,
|
|
420
441
|
requestId,
|
|
421
442
|
command: zod.z.string().regex(/^\/?[A-Za-z]+$/).max(64),
|
|
422
|
-
args: zod.z.string().max(
|
|
443
|
+
args: zod.z.string().max(DESKTOP_MESSAGE_MAX_CHARS).optional(),
|
|
423
444
|
images: zod.z.array(sessionImage).max(8).optional()
|
|
424
445
|
}).strict(),
|
|
425
446
|
zod.z.object({
|
|
@@ -436,6 +457,17 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
436
457
|
sessionId,
|
|
437
458
|
completionToken: zod.z.string().uuid()
|
|
438
459
|
}).strict(),
|
|
460
|
+
// Cross-surface delivery claim, NOT a read receipt. `claim_delivery`
|
|
461
|
+
// serialises the observers that can see one completion (a TUI, this app) so
|
|
462
|
+
// exactly one of them toasts it. It deliberately never advances the read
|
|
463
|
+
// watermark: routing a notification must not clear the sidebar's unseen mark
|
|
464
|
+
// for a session the user never opened, which is why this is its own op and
|
|
465
|
+
// not a reuse of `sessions.seen`.
|
|
466
|
+
zod.z.object({
|
|
467
|
+
op: zod.z.literal("sessions.notified"),
|
|
468
|
+
sessionId,
|
|
469
|
+
completionToken: zod.z.string().uuid()
|
|
470
|
+
}).strict(),
|
|
439
471
|
zod.z.object({
|
|
440
472
|
op: zod.z.literal("sessions.watch"),
|
|
441
473
|
sessionId,
|
|
@@ -546,7 +578,7 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
546
578
|
zod.z.object({
|
|
547
579
|
op: zod.z.literal("legacy.agent.systemPrompt.update"),
|
|
548
580
|
agentId: id,
|
|
549
|
-
systemPrompt: zod.z.string().max(
|
|
581
|
+
systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
|
|
550
582
|
}).strict(),
|
|
551
583
|
zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id }).strict(),
|
|
552
584
|
zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id }).strict(),
|
|
@@ -746,6 +778,34 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
746
778
|
value: secret
|
|
747
779
|
}).strict()
|
|
748
780
|
]);
|
|
781
|
+
const DESKTOP_MESSAGE_BYTE_BUDGET = 88e4;
|
|
782
|
+
const DESKTOP_CONTROL_BYTE_BUDGET = 262144;
|
|
783
|
+
const DESKTOP_SYSTEM_PROMPT_BYTE_BUDGET = 11e5;
|
|
784
|
+
const MESSAGE_OPS = /* @__PURE__ */ new Set([
|
|
785
|
+
"sessions.message",
|
|
786
|
+
"sessions.command",
|
|
787
|
+
// `sessions.fork` declares the SAME 200,000-character text field as
|
|
788
|
+
// `sessions.message` and carries it to the same session, so it belongs in the
|
|
789
|
+
// same tier; leaving it on the control budget refused a fork message the
|
|
790
|
+
// schema promised to accept (round 1, R3).
|
|
791
|
+
"sessions.fork"
|
|
792
|
+
]);
|
|
793
|
+
function desktopRequestByteBudget(op) {
|
|
794
|
+
if (op === "legacy.agent.systemPrompt.update")
|
|
795
|
+
return DESKTOP_SYSTEM_PROMPT_BYTE_BUDGET;
|
|
796
|
+
return MESSAGE_OPS.has(op) ? DESKTOP_MESSAGE_BYTE_BUDGET : DESKTOP_CONTROL_BYTE_BUDGET;
|
|
797
|
+
}
|
|
798
|
+
const DESKTOP_REQUEST_TOO_LARGE_DETAIL = "This message is too large to send in one request. Remove an image, or split the text across two messages.";
|
|
799
|
+
function desktopRequestTooLargeDetail(op) {
|
|
800
|
+
if (op === "legacy.agent.systemPrompt.update")
|
|
801
|
+
return "This system prompt is too large to save in one request. Shorten it.";
|
|
802
|
+
if (op === "sessions.fork")
|
|
803
|
+
return "This first message is too large to send with the fork. Shorten it, or send it in the new conversation instead.";
|
|
804
|
+
if (op === "sessions.command")
|
|
805
|
+
return "This command is too large to send in one request. Shorten it, or put the text in a message instead.";
|
|
806
|
+
if (MESSAGE_OPS.has(op)) return DESKTOP_REQUEST_TOO_LARGE_DETAIL;
|
|
807
|
+
return "This request is too large to send. Shorten the text in this form.";
|
|
808
|
+
}
|
|
749
809
|
function desktopEndpoint(request) {
|
|
750
810
|
switch (request.op) {
|
|
751
811
|
case "capabilities":
|
|
@@ -868,6 +928,12 @@ function desktopEndpoint(request) {
|
|
|
868
928
|
method: "POST",
|
|
869
929
|
body: { completion_token: request.completionToken }
|
|
870
930
|
};
|
|
931
|
+
case "sessions.notified":
|
|
932
|
+
return {
|
|
933
|
+
path: `/v1/desktop/sessions/${request.sessionId}/notified`,
|
|
934
|
+
method: "POST",
|
|
935
|
+
body: { completion_token: request.completionToken }
|
|
936
|
+
};
|
|
871
937
|
case "sessions.watch":
|
|
872
938
|
return {
|
|
873
939
|
path: `/v1/desktop/sessions/${request.sessionId}/watch`,
|
|
@@ -1214,10 +1280,10 @@ async function requestDesktop(input, backendUrl, token) {
|
|
|
1214
1280
|
const target2 = desktopEndpoint(request);
|
|
1215
1281
|
try {
|
|
1216
1282
|
const body = target2.body === void 0 ? void 0 : JSON.stringify(target2.body);
|
|
1217
|
-
if (body && Buffer.byteLength(body) >
|
|
1283
|
+
if (body && Buffer.byteLength(body) > desktopRequestByteBudget(request.op)) {
|
|
1218
1284
|
return {
|
|
1219
1285
|
status: 413,
|
|
1220
|
-
body: { detail:
|
|
1286
|
+
body: { detail: desktopRequestTooLargeDetail(request.op) }
|
|
1221
1287
|
};
|
|
1222
1288
|
}
|
|
1223
1289
|
const response = await fetch(new URL(target2.path, backendUrl), {
|
|
@@ -1786,6 +1852,31 @@ class BackendServiceManager {
|
|
|
1786
1852
|
/** Survives relay recreation so notifications never silently detach
|
|
1787
1853
|
* when the backend URL rotates. */
|
|
1788
1854
|
streamObserver = null;
|
|
1855
|
+
/**
|
|
1856
|
+
* Called every time the backend becomes reachable and authenticated.
|
|
1857
|
+
*
|
|
1858
|
+
* The desktop token is minted inside `start()`, so anything that must query
|
|
1859
|
+
* the backend cannot be issued from `app.whenReady()` — at that point the
|
|
1860
|
+
* port is dead on the ordinary self-managed cold start and the request is
|
|
1861
|
+
* lost. This fires after the health check passes, which is the first moment
|
|
1862
|
+
* `requestDesktop` can succeed.
|
|
1863
|
+
*
|
|
1864
|
+
* It fires AGAIN on every restart and on external-backend discovery, on
|
|
1865
|
+
* purpose: the backend underneath a running app can be replaced by a
|
|
1866
|
+
* different version, and a capability read taken once at startup would
|
|
1867
|
+
* outlive the backend it described.
|
|
1868
|
+
*/
|
|
1869
|
+
backendReadyObserver = null;
|
|
1870
|
+
onBackendReady(observer) {
|
|
1871
|
+
this.backendReadyObserver = observer;
|
|
1872
|
+
}
|
|
1873
|
+
notifyBackendReady() {
|
|
1874
|
+
try {
|
|
1875
|
+
this.backendReadyObserver?.();
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
logger.error("Backend-ready observer threw:", LogFileType.BACKEND, error);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1789
1880
|
getStreamRelay() {
|
|
1790
1881
|
if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl) {
|
|
1791
1882
|
this.streamRelay?.dispose();
|
|
@@ -2133,6 +2224,7 @@ class BackendServiceManager {
|
|
|
2133
2224
|
LogFileType.BACKEND
|
|
2134
2225
|
);
|
|
2135
2226
|
this.isExternalBackend = true;
|
|
2227
|
+
this.notifyBackendReady();
|
|
2136
2228
|
return true;
|
|
2137
2229
|
}
|
|
2138
2230
|
} catch (error) {
|
|
@@ -2162,6 +2254,7 @@ class BackendServiceManager {
|
|
|
2162
2254
|
LogFileType.BACKEND
|
|
2163
2255
|
);
|
|
2164
2256
|
this.isExternalBackend = true;
|
|
2257
|
+
this.notifyBackendReady();
|
|
2165
2258
|
return true;
|
|
2166
2259
|
}
|
|
2167
2260
|
}
|
|
@@ -2195,6 +2288,7 @@ class BackendServiceManager {
|
|
|
2195
2288
|
"Backend Service Manager is disabled. Skipping backend start.",
|
|
2196
2289
|
LogFileType.BACKEND
|
|
2197
2290
|
);
|
|
2291
|
+
this.notifyBackendReady();
|
|
2198
2292
|
return true;
|
|
2199
2293
|
}
|
|
2200
2294
|
if (await this.checkExistingBackend()) {
|
|
@@ -2330,6 +2424,7 @@ class BackendServiceManager {
|
|
|
2330
2424
|
if (await this.checkHealth()) {
|
|
2331
2425
|
this.isRunning = true;
|
|
2332
2426
|
this.startHealthCheck();
|
|
2427
|
+
this.notifyBackendReady();
|
|
2333
2428
|
return true;
|
|
2334
2429
|
}
|
|
2335
2430
|
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
@@ -3889,6 +3984,21 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
|
|
|
3889
3984
|
}
|
|
3890
3985
|
const NOTIFY_TTL_MS = 10 * 60 * 1e3;
|
|
3891
3986
|
const MAX_DEDUPE_KEYS = 2048;
|
|
3987
|
+
const CONTRACT_PROBE_ATTEMPTS = 3;
|
|
3988
|
+
const CONTRACT_PROBE_RETRY_MS = 250;
|
|
3989
|
+
const MAX_BODY_CHARS = 240;
|
|
3990
|
+
const TRAILING_COLONS = /:+$/;
|
|
3991
|
+
const GATE_BODIES = {
|
|
3992
|
+
ask: "Waiting for your answer",
|
|
3993
|
+
approval: "Waiting for approval"
|
|
3994
|
+
};
|
|
3995
|
+
function gateBody(kind, gateTitle, gateDetail) {
|
|
3996
|
+
let subject = (gateDetail ?? "").trim();
|
|
3997
|
+
if (subject && gateTitle && !subject.toLowerCase().startsWith(gateTitle.toLowerCase())) {
|
|
3998
|
+
subject = `${gateTitle}: ${subject}`.trim().replace(TRAILING_COLONS, "").trim();
|
|
3999
|
+
}
|
|
4000
|
+
return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
|
|
4001
|
+
}
|
|
3892
4002
|
class DesktopNotifier {
|
|
3893
4003
|
constructor(window, request) {
|
|
3894
4004
|
this.window = window;
|
|
@@ -3899,9 +4009,163 @@ class DesktopNotifier {
|
|
|
3899
4009
|
epochs = /* @__PURE__ */ new Map();
|
|
3900
4010
|
/** Window state per (window id) used to decide whether a toast is needed. */
|
|
3901
4011
|
windows = /* @__PURE__ */ new Map();
|
|
4012
|
+
/**
|
|
4013
|
+
* `features.notification_contract` from `/v1/capabilities`; 0 means legacy.
|
|
4014
|
+
*
|
|
4015
|
+
* A HARD switch, not a heuristic. At >= 1 the backend owns every completion
|
|
4016
|
+
* toast and the legacy event path must stay silent or one turn produces two
|
|
4017
|
+
* banners. Absent or unfetchable stays 0 on purpose: a missing capability
|
|
4018
|
+
* response is a transient HTTP failure far more often than it is an old
|
|
4019
|
+
* backend, and failing toward silence would lose completions outright.
|
|
4020
|
+
*/
|
|
4021
|
+
notificationContract = 0;
|
|
4022
|
+
/**
|
|
4023
|
+
* The capability read in flight, or the last one that settled.
|
|
4024
|
+
*
|
|
4025
|
+
* `null` only before the first read is ever issued.
|
|
4026
|
+
*/
|
|
4027
|
+
contractProbe = null;
|
|
4028
|
+
/**
|
|
4029
|
+
* Generation of the newest capability read, so the LAST-STARTED one wins.
|
|
4030
|
+
*
|
|
4031
|
+
* Probes are not idempotent and they overlap: the backend-ready hook can
|
|
4032
|
+
* fire twice in a tick (external-backend discovery and `start()` both
|
|
4033
|
+
* reported ready), a health-check restart can fire it while an earlier probe
|
|
4034
|
+
* is still retrying, and `awaitContract` starts one of its own. Without this
|
|
4035
|
+
* the shared fields are written by whichever probe SETTLES last, which on a
|
|
4036
|
+
* transient blip is the older read — a successful answer clobbered by a
|
|
4037
|
+
* stale failure, decided by timing rather than by recency (review round 2,
|
|
4038
|
+
* R2-2).
|
|
4039
|
+
*
|
|
4040
|
+
* A superseded probe therefore writes nothing at all and returns. It is not
|
|
4041
|
+
* cancelled — its in-flight `fetch` is left to settle on its own, because
|
|
4042
|
+
* the only cost is one wasted response and the alternative is threading an
|
|
4043
|
+
* `AbortSignal` through the desktop transport for no user-visible gain.
|
|
4044
|
+
*/
|
|
4045
|
+
contractGeneration = 0;
|
|
4046
|
+
/**
|
|
4047
|
+
* Whether a capability read has ever come back with a real HTTP answer.
|
|
4048
|
+
*
|
|
4049
|
+
* This is the distinction that closes the double-banner window BY
|
|
4050
|
+
* CONSTRUCTION. A `notificationContract` of 0 has three causes that look
|
|
4051
|
+
* identical on the field alone: the backend answered and has no contract
|
|
4052
|
+
* (old backend, legacy path is correct), nobody has asked yet, and the ask
|
|
4053
|
+
* never reached a backend at all. Only the first is an answer. The other two
|
|
4054
|
+
* are the cold start the review reproduced — the read went to a port nothing
|
|
4055
|
+
* was listening on, was swallowed, and the legacy `agent_end` toast then
|
|
4056
|
+
* fired alongside the composed frame, two banners for one turn.
|
|
4057
|
+
*
|
|
4058
|
+
* So the legacy path re-asks until it has a genuine answer rather than
|
|
4059
|
+
* reading an unanswered 0 as "no contract". Failing toward the legacy path
|
|
4060
|
+
* rather than toward silence (design 4.3) is preserved: once a bounded probe
|
|
4061
|
+
* really has failed at the moment the toast is due, the toast is raised.
|
|
4062
|
+
*
|
|
4063
|
+
* ONCE TRUE THIS AND `notificationContract` MOVE ONLY TOGETHER. A failed
|
|
4064
|
+
* probe that reset the value while leaving this true produced a pair meaning
|
|
4065
|
+
* "we were told there is no contract" when in fact we were told nothing, and
|
|
4066
|
+
* `awaitContract` then declined to re-ask — the double banner returned and
|
|
4067
|
+
* STAYED, every turn, until the next backend bounce (review round 2, R2-1).
|
|
4068
|
+
*/
|
|
4069
|
+
contractAnswered = false;
|
|
3902
4070
|
get canNotify() {
|
|
3903
4071
|
return electron.Notification.isSupported();
|
|
3904
4072
|
}
|
|
4073
|
+
/**
|
|
4074
|
+
* Read `features.notification_contract` from the connected backend.
|
|
4075
|
+
*
|
|
4076
|
+
* Call this every time the backend becomes reachable — first start, a health
|
|
4077
|
+
* check restarting it, external-backend discovery rotating the URL. It is
|
|
4078
|
+
* wired to `BackendServiceManager.onBackendReady` for exactly that reason:
|
|
4079
|
+
* issuing it earlier (inside `app.whenReady()`) queries a port nothing is
|
|
4080
|
+
* listening on yet on the ordinary self-managed cold start, and a swallowed
|
|
4081
|
+
* failure there leaves the gate on the legacy path against a backend that
|
|
4082
|
+
* composes — two banners for one turn.
|
|
4083
|
+
*
|
|
4084
|
+
* Re-reading is also the downgrade story the design names (4.3): an app
|
|
4085
|
+
* pointed from a new backend at an old one re-reads 0 and gets its legacy
|
|
4086
|
+
* completion signal back. The previous one-way latch could not do that.
|
|
4087
|
+
*
|
|
4088
|
+
* A throw or a non-200 after the bounded retry leaves the gate at 0, which
|
|
4089
|
+
* is the legacy path rather than silence: see `notificationContract`.
|
|
4090
|
+
*/
|
|
4091
|
+
refreshNotificationContract() {
|
|
4092
|
+
const generation = ++this.contractGeneration;
|
|
4093
|
+
const probe = this.probeNotificationContract(generation);
|
|
4094
|
+
this.contractProbe = probe;
|
|
4095
|
+
return probe;
|
|
4096
|
+
}
|
|
4097
|
+
async probeNotificationContract(generation) {
|
|
4098
|
+
for (let attempt = 0; attempt < CONTRACT_PROBE_ATTEMPTS; attempt++) {
|
|
4099
|
+
let response = null;
|
|
4100
|
+
try {
|
|
4101
|
+
response = await this.request({ op: "capabilities" });
|
|
4102
|
+
} catch {
|
|
4103
|
+
}
|
|
4104
|
+
if (generation !== this.contractGeneration) return;
|
|
4105
|
+
if (response?.status === 200) {
|
|
4106
|
+
const body = response.body;
|
|
4107
|
+
this.applyNotificationContract(
|
|
4108
|
+
body?.result?.features?.notification_contract
|
|
4109
|
+
);
|
|
4110
|
+
this.contractAnswered = true;
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
if (attempt + 1 < CONTRACT_PROBE_ATTEMPTS) {
|
|
4114
|
+
await new Promise(
|
|
4115
|
+
(resolve) => setTimeout(resolve, CONTRACT_PROBE_RETRY_MS)
|
|
4116
|
+
);
|
|
4117
|
+
if (generation !== this.contractGeneration) return;
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
/**
|
|
4122
|
+
* Block until a capability read has ANSWERED against the current backend,
|
|
4123
|
+
* or until a fresh bounded probe has failed here and now.
|
|
4124
|
+
*
|
|
4125
|
+
* Issuing the probe itself when none has answered is what makes the legacy
|
|
4126
|
+
* path safe by construction rather than by call-site discipline. The failure
|
|
4127
|
+
* the review reproduced is a read fired before the backend existed: it is
|
|
4128
|
+
* not enough to move that call later, because any read can lose its race
|
|
4129
|
+
* with a backend that is still starting, and a swallowed one leaves a 0 that
|
|
4130
|
+
* reads exactly like an old backend. Re-asking at the moment the answer is
|
|
4131
|
+
* actually needed cannot be beaten by a slow start.
|
|
4132
|
+
*
|
|
4133
|
+
* Loops rather than awaiting once because a backend rotation can start a
|
|
4134
|
+
* new probe while we are waiting on the old one, and the legacy path must
|
|
4135
|
+
* never be decided on the previous backend's answer.
|
|
4136
|
+
*/
|
|
4137
|
+
async awaitContract() {
|
|
4138
|
+
if (!this.contractAnswered) this.refreshNotificationContract();
|
|
4139
|
+
let awaited = null;
|
|
4140
|
+
while (this.contractProbe !== awaited) {
|
|
4141
|
+
awaited = this.contractProbe;
|
|
4142
|
+
await awaited;
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
/**
|
|
4146
|
+
* Record the contract version a backend reported.
|
|
4147
|
+
*
|
|
4148
|
+
* Being told the value directly IS an answer, so this also stops the legacy
|
|
4149
|
+
* path issuing a probe of its own and overwriting it.
|
|
4150
|
+
*
|
|
4151
|
+
* Bumps the generation for the same reason a probe does: this is the newest
|
|
4152
|
+
* reading of the backend, so an older probe still in flight must not settle
|
|
4153
|
+
* on top of it.
|
|
4154
|
+
*/
|
|
4155
|
+
setNotificationContract(version2) {
|
|
4156
|
+
this.contractGeneration++;
|
|
4157
|
+
this.applyNotificationContract(version2);
|
|
4158
|
+
this.contractAnswered = true;
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Normalise a reported contract value. Both callers are ANSWERS and both
|
|
4162
|
+
* set `contractAnswered` themselves; nothing else may write this field.
|
|
4163
|
+
* That invariant is the fix for R2-1, so a third caller that resets the
|
|
4164
|
+
* value without answering re-opens it.
|
|
4165
|
+
*/
|
|
4166
|
+
applyNotificationContract(version2) {
|
|
4167
|
+
this.notificationContract = typeof version2 === "number" && Number.isFinite(version2) && version2 > 0 ? version2 : 0;
|
|
4168
|
+
}
|
|
3905
4169
|
/**
|
|
3906
4170
|
* Renderer heartbeat. Forwarded to the backend as the watch lease for the
|
|
3907
4171
|
* renderer's active subscription; `can_notify` is asserted only when main
|
|
@@ -3930,21 +4194,103 @@ class DesktopNotifier {
|
|
|
3930
4194
|
if (frame.type === "snapshot") {
|
|
3931
4195
|
this.epochs.set(sessionId2, frame.payload.frontend.epoch);
|
|
3932
4196
|
const gate = frame.payload.frontend.snapshot.pending_gate;
|
|
3933
|
-
if (gate) this.gate(sessionId2, gate
|
|
4197
|
+
if (gate) this.gate(sessionId2, gate);
|
|
3934
4198
|
return;
|
|
3935
4199
|
}
|
|
3936
4200
|
if (frame.type === "frontend.update") {
|
|
3937
4201
|
this.epochs.set(sessionId2, frame.payload.epoch);
|
|
3938
4202
|
const gate = frame.payload.changes.pending_gate;
|
|
3939
|
-
if (gate) this.gate(sessionId2, gate
|
|
4203
|
+
if (gate) this.gate(sessionId2, gate);
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
if (frame.type === "notification") {
|
|
4207
|
+
void this.composed(sessionId2, frame.payload);
|
|
3940
4208
|
return;
|
|
3941
4209
|
}
|
|
3942
4210
|
if (frame.type === "event") {
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
4211
|
+
if (String(frame.payload.type ?? "") !== "agent_end") return;
|
|
4212
|
+
void this.legacyTurn(sessionId2, frame.seq);
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* The legacy completion toast, raised only against a backend that composes
|
|
4217
|
+
* none of its own.
|
|
4218
|
+
*
|
|
4219
|
+
* DEFERRED, never raced. A backend advertising `notification_contract` owns
|
|
4220
|
+
* every completion toast, so this must stay silent against it or the user
|
|
4221
|
+
* gets two banners for one turn: the legacy one here plus the composed frame.
|
|
4222
|
+
* `agent_end` is published the moment the turn's last model call returns,
|
|
4223
|
+
* while the composed frame follows from the backend's 1 s attention poll
|
|
4224
|
+
* (design 6.1) — so the event reliably arrives FIRST, and any check that
|
|
4225
|
+
* reacts to a frame already in hand closes the window one frame too late.
|
|
4226
|
+
* Waiting for the capability read to settle is what closes it by
|
|
4227
|
+
* construction. The wait is bounded by `CONTRACT_PROBE_*` and only ever
|
|
4228
|
+
* applies before the first answer of a run.
|
|
4229
|
+
*
|
|
4230
|
+
* `agent_end` survives on this path only because dropping it too would leave
|
|
4231
|
+
* an old backend with no completion signal at all. It is still wrong (it
|
|
4232
|
+
* arrives while `task` children run), which is exactly what the
|
|
4233
|
+
* backend-composed path fixes. The event itself is NEVER filtered out of the
|
|
4234
|
+
* stream: the transcript reducer settles on it.
|
|
4235
|
+
*/
|
|
4236
|
+
async legacyTurn(sessionId2, seq) {
|
|
4237
|
+
await this.awaitContract();
|
|
4238
|
+
if (this.notificationContract > 0) return;
|
|
4239
|
+
this.turn(sessionId2, seq);
|
|
4240
|
+
}
|
|
4241
|
+
/**
|
|
4242
|
+
* Deliver one backend-composed notification.
|
|
4243
|
+
*
|
|
4244
|
+
* The order of these four checks is load-bearing and is the whole of 7.3.
|
|
4245
|
+
* Dedupe and the focus gate run BEFORE the claim so a toast we are not going
|
|
4246
|
+
* to show never burns the cross-surface claim; the claim runs immediately
|
|
4247
|
+
* before delivery so the claimant really is the deliverer.
|
|
4248
|
+
*/
|
|
4249
|
+
async composed(sessionId2, n) {
|
|
4250
|
+
if (!this.claim(n.dedupe_key)) return;
|
|
4251
|
+
if (n.focus_policy === "when_unfocused" && this.anyFocused()) return;
|
|
4252
|
+
if (n.completion_token) {
|
|
4253
|
+
const outcome = await this.claimDelivery(sessionId2, n.completion_token);
|
|
4254
|
+
if (outcome !== "won") {
|
|
4255
|
+
if (outcome === "failed") this.delivered.delete(n.dedupe_key);
|
|
4256
|
+
return;
|
|
3946
4257
|
}
|
|
3947
4258
|
}
|
|
4259
|
+
this.show(
|
|
4260
|
+
sessionId2,
|
|
4261
|
+
n.title,
|
|
4262
|
+
n.status,
|
|
4263
|
+
n.body,
|
|
4264
|
+
n.body_is_snippet,
|
|
4265
|
+
n.body_is_failure ?? false
|
|
4266
|
+
);
|
|
4267
|
+
}
|
|
4268
|
+
/**
|
|
4269
|
+
* Ask the backend whether this surface owns this completion's toast.
|
|
4270
|
+
*
|
|
4271
|
+
* Fails CLOSED on the TOAST: neither `lost` nor `failed` shows anything,
|
|
4272
|
+
* because the failure mode of failing open is the duplicate banner on every
|
|
4273
|
+
* surface this whole mechanism exists to prevent.
|
|
4274
|
+
*
|
|
4275
|
+
* The two non-winning outcomes are still reported separately, because they
|
|
4276
|
+
* differ in who owns the completion afterwards. `lost` means the backend
|
|
4277
|
+
* answered and another surface has it. `failed` means the backend was never
|
|
4278
|
+
* reached or never answered cleanly, so nothing was claimed anywhere and the
|
|
4279
|
+
* completion is still owed to somebody — see the caller.
|
|
4280
|
+
*/
|
|
4281
|
+
async claimDelivery(sessionId2, completionToken) {
|
|
4282
|
+
try {
|
|
4283
|
+
const response = await this.request({
|
|
4284
|
+
op: "sessions.notified",
|
|
4285
|
+
sessionId: sessionId2,
|
|
4286
|
+
completionToken
|
|
4287
|
+
});
|
|
4288
|
+
if (response.status !== 200) return "failed";
|
|
4289
|
+
const body = response.body;
|
|
4290
|
+
return body?.result?.claimed === true ? "won" : "lost";
|
|
4291
|
+
} catch {
|
|
4292
|
+
return "failed";
|
|
4293
|
+
}
|
|
3948
4294
|
}
|
|
3949
4295
|
anyFocused() {
|
|
3950
4296
|
for (const state of this.windows.values()) {
|
|
@@ -3952,16 +4298,59 @@ class DesktopNotifier {
|
|
|
3952
4298
|
}
|
|
3953
4299
|
return false;
|
|
3954
4300
|
}
|
|
3955
|
-
|
|
4301
|
+
/**
|
|
4302
|
+
* Raise the banner for a pending approval or question.
|
|
4303
|
+
*
|
|
4304
|
+
* A gate is worth a toast even when the window is focused: the user may be
|
|
4305
|
+
* reading another conversation in the same window.
|
|
4306
|
+
*
|
|
4307
|
+
* When the backend supplies `session_name`, the banner takes the SHAPE of a
|
|
4308
|
+
* completion banner — session name as the title, the gate's own title
|
|
4309
|
+
* leading the detail. Without it, a gate named only its category while
|
|
4310
|
+
* completions named their session, so the banner a user can safely ignore
|
|
4311
|
+
* identified itself and the banner holding a run hostage did not; with two
|
|
4312
|
+
* or three sessions running, that one cannot be triaged without clicking,
|
|
4313
|
+
* which is the action the user was deciding whether to take.
|
|
4314
|
+
*
|
|
4315
|
+
* The name is rendered ONLY as the backend sent it. `session_name` is
|
|
4316
|
+
* additive and optional: it is absent on an older backend and empty when the
|
|
4317
|
+
* `session_names_in_notifications()` privacy flag is off. The backend owns
|
|
4318
|
+
* that decision because only it can read the flag, so this app must never
|
|
4319
|
+
* resolve the name from a snapshot instead — doing so would leak a name the
|
|
4320
|
+
* user opted out of, on every gate, forever.
|
|
4321
|
+
*/
|
|
4322
|
+
gate(sessionId2, gate) {
|
|
4323
|
+
const { request_id: requestId2, kind, title, detail } = gate;
|
|
3956
4324
|
const key = `gate:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${requestId2}`;
|
|
3957
4325
|
if (!this.claim(key)) return;
|
|
3958
|
-
|
|
4326
|
+
const fallback = kind === "ask" ? "Question" : "Approval needed";
|
|
4327
|
+
const sessionName = gate.session_name?.trim() || "";
|
|
4328
|
+
if (!sessionName) {
|
|
4329
|
+
this.show(
|
|
4330
|
+
sessionId2,
|
|
4331
|
+
title.trim() || fallback,
|
|
4332
|
+
"",
|
|
4333
|
+
gateBody(kind, "", detail)
|
|
4334
|
+
);
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
this.show(
|
|
4338
|
+
sessionId2,
|
|
4339
|
+
sessionName,
|
|
4340
|
+
"",
|
|
4341
|
+
gateBody(kind, title.trim() || fallback, detail)
|
|
4342
|
+
);
|
|
3959
4343
|
}
|
|
4344
|
+
/**
|
|
4345
|
+
* Legacy completion toast: canned copy, reached only against a backend that
|
|
4346
|
+
* does not advertise `notification_contract`. Keyed on `session:epoch:seq`
|
|
4347
|
+
* as it always was — an old backend mints no `dedupe_key` to key on.
|
|
4348
|
+
*/
|
|
3960
4349
|
turn(sessionId2, seq) {
|
|
3961
4350
|
const key = `turn:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${seq}`;
|
|
3962
4351
|
if (!this.claim(key)) return;
|
|
3963
4352
|
if (this.anyFocused()) return;
|
|
3964
|
-
this.show(sessionId2, "Turn complete", "The agent finished its turn.");
|
|
4353
|
+
this.show(sessionId2, "Turn complete", "", "The agent finished its turn.");
|
|
3965
4354
|
}
|
|
3966
4355
|
claim(key) {
|
|
3967
4356
|
const now = Date.now();
|
|
@@ -3974,10 +4363,52 @@ class DesktopNotifier {
|
|
|
3974
4363
|
}
|
|
3975
4364
|
return true;
|
|
3976
4365
|
}
|
|
3977
|
-
|
|
4366
|
+
/**
|
|
4367
|
+
* Render one toast.
|
|
4368
|
+
*
|
|
4369
|
+
* `status` leads the body rather than riding `subtitle`. Electron's
|
|
4370
|
+
* `subtitle` option is documented macOS-only, so on Windows and Linux the
|
|
4371
|
+
* state category would simply vanish and those users would read a bare
|
|
4372
|
+
* snippet with no indication of whether the turn succeeded or failed. One
|
|
4373
|
+
* shape that degrades on no platform beats a second, platform-forked one.
|
|
4374
|
+
* The strings themselves are the backend's, unchanged; only the join is ours.
|
|
4375
|
+
*
|
|
4376
|
+
* The design doc's stated reason for keeping the status out of the title —
|
|
4377
|
+
* "macOS clips a title at ~43 characters" — is FALSE on a current macOS
|
|
4378
|
+
* banner: rendered frames show an 80-character title wrapping across two
|
|
4379
|
+
* lines, not clipping (design review round 1). The real constraint is a
|
|
4380
|
+
* total budget of roughly five text lines for title and body together, after
|
|
4381
|
+
* which macOS truncates the BODY with its own ellipsis. That is why the join
|
|
4382
|
+
* is spent sparingly below.
|
|
4383
|
+
*
|
|
4384
|
+
* WHETHER THE BODY NAMES THE OUTCOME ITSELF decides whether the status earns
|
|
4385
|
+
* its place, and the backend sends two flags for the two kinds of body that
|
|
4386
|
+
* do not. A model-written snippet describes the work, and the session's own
|
|
4387
|
+
* recorded failure text describes the cause ("anthropic: 429
|
|
4388
|
+
* rate_limit_error - credit balance too low"); neither says whether the turn
|
|
4389
|
+
* succeeded, so the category in front of it is the only thing that does. The
|
|
4390
|
+
* house bodies ("Task complete", "Stopped with an error") already name the
|
|
4391
|
+
* state, so prefixing them renders "Complete — Task complete": one fact
|
|
4392
|
+
* asserted twice in the two lines a banner gets, which is what every user
|
|
4393
|
+
* with the session-name privacy flag off would have received.
|
|
4394
|
+
*
|
|
4395
|
+
* Reading only `body_is_snippet` split the world in two where the backend
|
|
4396
|
+
* made it three, and it inverted the intent of the failure text (design
|
|
4397
|
+
* round 1, D4): a raw 429 envelope under a session name reads as routine log
|
|
4398
|
+
* noise, so the ONE banner that demands action was the one that never said
|
|
4399
|
+
* anything had gone wrong — and it was worse with the privacy flag ON (the
|
|
4400
|
+
* default, which is what produces the failure text) than off, where the user
|
|
4401
|
+
* at least got "Stopped with an error" (QA round 1, Q-1).
|
|
4402
|
+
*
|
|
4403
|
+
* These are the backend's own flags for exactly this difference, so this app
|
|
4404
|
+
* still re-words nothing (design 8.4). Gates pass `status = ""` and are
|
|
4405
|
+
* unaffected either way.
|
|
4406
|
+
*/
|
|
4407
|
+
show(sessionId2, title, status, body, isSnippet = false, isFailure = false) {
|
|
4408
|
+
const lead = (isSnippet || isFailure) && status ? `${status} — ` : "";
|
|
3978
4409
|
const notification = new electron.Notification({
|
|
3979
4410
|
title,
|
|
3980
|
-
body: body
|
|
4411
|
+
body: `${lead}${body}`.slice(0, MAX_BODY_CHARS),
|
|
3981
4412
|
silent: false
|
|
3982
4413
|
});
|
|
3983
4414
|
notification.on("click", () => {
|
|
@@ -5384,6 +5815,9 @@ electron.app.whenReady().then(async () => {
|
|
|
5384
5815
|
(input) => backendService.requestDesktop(input)
|
|
5385
5816
|
);
|
|
5386
5817
|
const desktopNotifier = new DesktopNotifier(() => mainWindow, sendDesktop);
|
|
5818
|
+
backendService.onBackendReady(() => {
|
|
5819
|
+
void desktopNotifier.refreshNotificationContract();
|
|
5820
|
+
});
|
|
5387
5821
|
backendService.observeStream((sessionId2, data) => {
|
|
5388
5822
|
try {
|
|
5389
5823
|
desktopNotifier.observe(sessionId2, JSON.parse(data));
|
|
@@ -5448,6 +5882,15 @@ electron.app.whenReady().then(async () => {
|
|
|
5448
5882
|
const normalizedPath = filePath.startsWith("~/") ? path.join(electron.app.getPath("home"), filePath.slice(2)) : filePath;
|
|
5449
5883
|
return fs.existsSync(normalizedPath);
|
|
5450
5884
|
});
|
|
5885
|
+
electron.ipcMain.handle("directory-exists", async (_, dirPath) => {
|
|
5886
|
+
const home = electron.app.getPath("home");
|
|
5887
|
+
const normalizedPath = dirPath === "~" ? home : dirPath.startsWith("~/") ? path.join(home, dirPath.slice(2)) : dirPath;
|
|
5888
|
+
try {
|
|
5889
|
+
return fs.statSync(normalizedPath, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
5890
|
+
} catch {
|
|
5891
|
+
return false;
|
|
5892
|
+
}
|
|
5893
|
+
});
|
|
5451
5894
|
electron.ipcMain.handle("show-open-dialog", async (_, options) => {
|
|
5452
5895
|
if (!mainWindow) {
|
|
5453
5896
|
logger.error(
|
package/out/preload/index.js
CHANGED
|
@@ -223,6 +223,12 @@ const api = {
|
|
|
223
223
|
saveFile: (filePath, content, encoding) => electron.ipcRenderer.invoke("save-file", filePath, content, encoding),
|
|
224
224
|
/** Checks if a file exists at the specified path */
|
|
225
225
|
fileExists: (filePath) => electron.ipcRenderer.invoke("file-exists", filePath),
|
|
226
|
+
/**
|
|
227
|
+
* Checks that a path exists AND is a directory. `fileExists` cannot answer
|
|
228
|
+
* this: it says yes for a regular file, so a working directory of
|
|
229
|
+
* `/etc/hosts` passed validation and failed at session creation instead.
|
|
230
|
+
*/
|
|
231
|
+
directoryExists: (dirPath) => electron.ipcRenderer.invoke("directory-exists", dirPath),
|
|
226
232
|
// Add methods for installer
|
|
227
233
|
ipcRenderer: {
|
|
228
234
|
send: (channel, ...args) => {
|