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.
Files changed (72) hide show
  1. package/out/main/index.js +461 -18
  2. package/out/preload/index.js +6 -0
  3. package/out/renderer/assets/{_basePickBy-Bw7mQqmp.js → _basePickBy-C3hLSrXE.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BnazTPmS.js → _baseUniq-DvVdHboz.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-BtWGsmjK.js → agent-details-page-6irHwYok.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-w823LFh-.js → agent-hub-page-t3NZyvhe.js} +1 -1
  7. package/out/renderer/assets/{agents-page-B7ZWTJyU.js → agents-page-Cu3tV_JM.js} +2 -2
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-D2ubQTOC.js → architectureDiagram-IEHRJDOE-Dy32lq21.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-ByU_ahgT.js → blockDiagram-JOT3LUYC-D9CCHreU.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-BC3KE4lQ.js → c4Diagram-VJAJSXHY-D6JYul4W.js} +1 -1
  11. package/out/renderer/assets/channel-CLzqtgQj.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-yCczBk2C.js → chunk-4BMEZGHF-CWZ3r21W.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-hQtr2CYL.js → chunk-A2AXSNBT-_jdJLEL4.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-BF7FMjiN.js → chunk-AEK57VVT-BRqxxp9X.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-D1ZkK01q.js → chunk-D6G4REZN-mvuW_LPO.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-CF-6knpx.js → chunk-RZ5BOZE2-B2kzBW8d.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-wxUXF_xN.js → chunk-XZIHB7SX-CqZmhFu_.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-Bu04i3t4.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bu04i3t4.js +1 -0
  20. package/out/renderer/assets/clone-DHy7FuoP.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-D9Y6NSdy.js → compact-pagination-BJa5bxJe.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-CmhmcUUf.js → dagre-OKDRZEBW-BLi9lW3T.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-Zehvro6p.js → diagram-SSKATNLV-DQa3WYjU.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-BarSLhrS.js → diagram-VNBRO52H-C99A8WuH.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DPeXV5YU.js → erDiagram-Q7BY3M3F-DLWwdshb.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CN5KLBDn.js → flowDiagram-4HSFHLVR-CHDQ3e9O.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BZoKoMXG.js → ganttDiagram-APWFNJXF-G4Rf9Yhs.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-qg18taCn.js → gitGraphDiagram-7IBYFJ6S-2JN_jG8T.js} +1 -1
  29. package/out/renderer/assets/{graph-OhiwQ8kq.js → graph-BR85xHbv.js} +1 -1
  30. package/out/renderer/assets/icon-2KHv58kf.css +1 -0
  31. package/out/renderer/assets/{icon-DREivh6T.js → icon-2OdwuA9u.js} +32 -27
  32. package/out/renderer/assets/{index-BAltHHlD.js → index-BIyDfbOQ.js} +1 -1
  33. package/out/renderer/assets/{index-kC3Gu5qg.js → index-BuhuPWhs.js} +1 -1
  34. package/out/renderer/assets/{index-D5Z_uzU-.js → index-_UzVA1oU.js} +338 -301
  35. package/out/renderer/assets/index-aW0Bo_1a.css +1 -0
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-D9zrB_S2.js → infoDiagram-PH2N3AL5-xVzynFeN.js} +1 -1
  37. package/out/renderer/assets/installer-CwpKGBgz.js +16 -0
  38. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CiKGQ8ZZ.js → journeyDiagram-U35MCT3I-C8-eHlWx.js} +1 -1
  39. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CMVH6spE.js → kanban-definition-NDS4AKOZ-C2Oyzo6d.js} +1 -1
  40. package/out/renderer/assets/{layout-neDpz9Oy.js → layout-mlSyc_4q.js} +1 -1
  41. package/out/renderer/assets/legacy-agents-page-CTGA2lul.js +43 -0
  42. package/out/renderer/assets/{mermaid.core-1bIbhsqM.js → mermaid.core-BvFuUEpc.js} +5 -5
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-COKGXFAt.js → mindmap-definition-ALO5MXBD-DWnloutf.js} +1 -1
  44. package/out/renderer/assets/{page-header-CtwhpX97.js → page-header-C-9zjakn.js} +1 -1
  45. package/out/renderer/assets/{parseISO-DoGRsp53.js → parseISO-Bp3fMoKU.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-G5934Pyw.js → pieDiagram-IB7DONF6-B990Tpnk.js} +1 -1
  47. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CXu2hVFd.js → quadrantDiagram-7GDLP6J5-BYeOekBR.js} +1 -1
  48. package/out/renderer/assets/{radar-MK3ICKWK-C99cc5bu.js → radar-MK3ICKWK-DcxkUK5Z.js} +1 -1
  49. package/out/renderer/assets/{radient-auth-buttons-D3fZKkWZ.js → radient-auth-buttons-CK9Icvbq.js} +1 -1
  50. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BWEMU0o3.js → requirementDiagram-KVF5MWMF-D_tf_1lk.js} +1 -1
  51. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-IBOCcssn.js → sankeyDiagram-QLVOVGJD-DlDfWN8k.js} +1 -1
  52. package/out/renderer/assets/{schedules-page-D4a05UgL.js → schedules-page-AQ9gMlkR.js} +1 -1
  53. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CqE6tvSG.js → sequenceDiagram-X6HHIX6F-wr3-RcoT.js} +1 -1
  54. package/out/renderer/assets/{settings-page-BAZmRT08.js → settings-page-BSjdxTFW.js} +29 -29
  55. package/out/renderer/assets/{square-pen-C0syRM6p.js → square-pen-2Yxvf_S9.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-ZQrc2AeF.js → stateDiagram-DGXRK772-BLD6Vmgd.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsGkljza.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-C8wsYt9c.js → timeline-definition-BDJGKUSR-B-JeF04j.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-DdWy2oKh.js → use-agent-like-mutation-tstZNIUn.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-pTqLOs-T.js → xychartDiagram-VJFVF3MP-DuFpzjW6.js} +1 -1
  61. package/out/renderer/index.html +21 -6
  62. package/out/renderer/installer.html +3 -3
  63. package/package.json +2 -2
  64. package/out/renderer/assets/channel-CBntux_E.js +0 -1
  65. package/out/renderer/assets/classDiagram-GIVACNV2-7UDlKgfd.js +0 -1
  66. package/out/renderer/assets/classDiagram-v2-COTLJTTW-7UDlKgfd.js +0 -1
  67. package/out/renderer/assets/clone-DkhUgY-v.js +0 -1
  68. package/out/renderer/assets/icon-CvlWlLiK.css +0 -1
  69. package/out/renderer/assets/index-DikLkf3S.css +0 -1
  70. package/out/renderer/assets/installer-BRKNGt5t.js +0 -21
  71. package/out/renderer/assets/legacy-agents-page-Bjs1Z9g8.js +0 -43
  72. 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(2e5),
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(2e5).optional(),
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(1e6)
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) > 262144) {
1283
+ if (body && Buffer.byteLength(body) > desktopRequestByteBudget(request.op)) {
1218
1284
  return {
1219
1285
  status: 413,
1220
- body: { detail: "This desktop request is too large." }
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.request_id, gate.title, gate.detail);
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.request_id, gate.title, gate.detail);
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
- const eventType = String(frame.payload.type ?? "");
3944
- if (eventType === "agent_end" || eventType === "turn_end") {
3945
- this.turn(sessionId2, frame.seq);
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
- gate(sessionId2, requestId2, title, detail) {
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
- this.show(sessionId2, title || "Approval needed", detail);
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
- show(sessionId2, title, body) {
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.slice(0, 240),
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(
@@ -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) => {