local-operator-ui 0.23.2 → 0.23.3

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/bin/local-operator-ui.js +11 -1
  2. package/out/main/index.js +1389 -112
  3. package/out/preload/index.js +79 -0
  4. package/out/renderer/assets/{_basePickBy-BVR8NHkD.js → _basePickBy-BcgsHs9n.js} +1 -1
  5. package/out/renderer/assets/{_baseUniq-onZkstHq.js → _baseUniq-BpL7wRRP.js} +1 -1
  6. package/out/renderer/assets/{agent-details-page-DC13Jibh.js → agent-details-page-XeWCJu5r.js} +1 -1
  7. package/out/renderer/assets/{agent-hub-page-iulwy94G.js → agent-hub-page-CnCjDZpC.js} +1 -1
  8. package/out/renderer/assets/{agents-page-BSegINKc.js → agents-page-Bdno1dWD.js} +2 -2
  9. package/out/renderer/assets/{arc-DAb06JtB.js → arc-BoPsrKtE.js} +1 -1
  10. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-sSe348HD.js → architectureDiagram-IEHRJDOE-C2WpBBPk.js} +1 -1
  11. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DB0hhoP8.js → blockDiagram-JOT3LUYC-CGcW4B3x.js} +1 -1
  12. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CcMWb5_5.js → c4Diagram-VJAJSXHY-o8n2wzDv.js} +1 -1
  13. package/out/renderer/assets/channel-urLqozUh.js +1 -0
  14. package/out/renderer/assets/{chunk-4BMEZGHF-SRkv1Dzw.js → chunk-4BMEZGHF-D-n0UX21.js} +1 -1
  15. package/out/renderer/assets/{chunk-A2AXSNBT-BBdcJhkk.js → chunk-A2AXSNBT-DfLH07_4.js} +1 -1
  16. package/out/renderer/assets/{chunk-AEK57VVT-BFLmAuIk.js → chunk-AEK57VVT-CL7wRm_N.js} +1 -1
  17. package/out/renderer/assets/{chunk-D6G4REZN--QHGDmI-.js → chunk-D6G4REZN-Cn9ko_6u.js} +1 -1
  18. package/out/renderer/assets/{chunk-RZ5BOZE2-Cy8U6l3Q.js → chunk-RZ5BOZE2-D2Yy6cfD.js} +1 -1
  19. package/out/renderer/assets/{chunk-XZIHB7SX-DFfpTr7O.js → chunk-XZIHB7SX-DADi_UCc.js} +1 -1
  20. package/out/renderer/assets/classDiagram-GIVACNV2-CdFb9UuH.js +1 -0
  21. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CdFb9UuH.js +1 -0
  22. package/out/renderer/assets/clone-R16IxTog.js +1 -0
  23. package/out/renderer/assets/{compact-pagination-DERWPPOM.js → compact-pagination-BJGKcOBo.js} +1 -1
  24. package/out/renderer/assets/{dagre-OKDRZEBW-DfjKcX6M.js → dagre-OKDRZEBW-CTUL3pXh.js} +1 -1
  25. package/out/renderer/assets/{diagram-SSKATNLV-Bm1j7Y3r.js → diagram-SSKATNLV-DN8A3Gxk.js} +1 -1
  26. package/out/renderer/assets/{diagram-VNBRO52H-BRx5TiDX.js → diagram-VNBRO52H-DMnKnMe_.js} +1 -1
  27. package/out/renderer/assets/{erDiagram-Q7BY3M3F-BSxbIo11.js → erDiagram-Q7BY3M3F-D_QMDQ8A.js} +1 -1
  28. package/out/renderer/assets/{flowDiagram-4HSFHLVR-R2ILhUKR.js → flowDiagram-4HSFHLVR-C3lOP5SJ.js} +1 -1
  29. package/out/renderer/assets/{ganttDiagram-APWFNJXF-Gd7TZJFy.js → ganttDiagram-APWFNJXF-C_OA497D.js} +1 -1
  30. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BAs2K2WV.js → gitGraphDiagram-7IBYFJ6S-CME4fotT.js} +1 -1
  31. package/out/renderer/assets/{graph-zm35SWuR.js → graph-StjGh6sY.js} +1 -1
  32. package/out/renderer/assets/icon-BgNKKXpS.css +1 -0
  33. package/out/renderer/assets/{icon-mKHwnRNs.js → icon-fm25vzsV.js} +1 -1
  34. package/out/renderer/assets/{index-DF2zdD6_.js → index-CrQVJ-Co.js} +365 -371
  35. package/out/renderer/assets/{index-DXx2j7ov.js → index-Dr1tzeVu.js} +1 -1
  36. package/out/renderer/assets/{index-CqIP2mt3.js → index-ir1HIEvx.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BcAWGJ1_.js → infoDiagram-PH2N3AL5-BDK-X71A.js} +1 -1
  38. package/out/renderer/assets/{installer-DcKqSMWJ.js → installer-BlFThduE.js} +1 -1
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-VfDkAwaY.js → journeyDiagram-U35MCT3I-BjNamBAK.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BVgW6OQK.js → kanban-definition-NDS4AKOZ-ggxQngdS.js} +1 -1
  41. package/out/renderer/assets/{layout-jfvNOQNg.js → layout-BwRA6JIM.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-yObswBd2.js → legacy-agents-page-6NVm9UCi.js} +4 -4
  43. package/out/renderer/assets/{mermaid.core-w7CiwIS9.js → mermaid.core-CfsR-egY.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-Ck5fBAvP.js → mindmap-definition-ALO5MXBD-DyShE6kV.js} +1 -1
  45. package/out/renderer/assets/{page-header-DmGvvMe3.js → page-header-DHRyHEm6.js} +1 -1
  46. package/out/renderer/assets/{parseISO-BfcDJeSX.js → parseISO-QkHFGs0Y.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-xuxf3Is5.js → pieDiagram-IB7DONF6-3wxJVvt5.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-B4UU5d5n.js → quadrantDiagram-7GDLP6J5-iNEHdUXN.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-BUD9nsvK.js → radar-MK3ICKWK-tbLyRbLt.js} +1 -1
  50. package/out/renderer/assets/radient-auth-buttons-CJi5xMEA.js +6 -0
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-0fsS7ohi.js → requirementDiagram-KVF5MWMF-CfXxmaaW.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CUjpA8gC.js → sankeyDiagram-QLVOVGJD-DaJoWNT6.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-xXMGrdrg.js → schedules-page-j6YSAreu.js} +8 -8
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CX6mfjEg.js → sequenceDiagram-X6HHIX6F-WF0xhSxd.js} +1 -1
  55. package/out/renderer/assets/settings-page-xzYs88jD.js +97 -0
  56. package/out/renderer/assets/{square-pen-717nTWtU.js → square-pen-D_sBefzl.js} +1 -1
  57. package/out/renderer/assets/{stateDiagram-DGXRK772-DlOrLmRF.js → stateDiagram-DGXRK772-YdZ2xuTI.js} +1 -1
  58. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CegpBrNz.js +1 -0
  59. package/out/renderer/assets/{timeline-definition-BDJGKUSR-B6_9nD67.js → timeline-definition-BDJGKUSR-DYaIE99w.js} +1 -1
  60. package/out/renderer/assets/{use-agent-like-mutation-Ckg2HQQI.js → use-agent-like-mutation-s4bRf1u_.js} +1 -1
  61. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-B2JoquLl.js → xychartDiagram-VJFVF3MP-DYQwMal7.js} +1 -1
  62. package/out/renderer/index.html +3 -3
  63. package/out/renderer/installer.html +3 -3
  64. package/package.json +52 -20
  65. package/out/renderer/assets/channel-Dl22yt_O.js +0 -1
  66. package/out/renderer/assets/classDiagram-GIVACNV2-BguqLYlr.js +0 -1
  67. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BguqLYlr.js +0 -1
  68. package/out/renderer/assets/clone-C0R1R8sa.js +0 -1
  69. package/out/renderer/assets/icon-DC3rStnR.css +0 -1
  70. package/out/renderer/assets/radient-auth-buttons-BtcEZuSY.js +0 -6
  71. package/out/renderer/assets/settings-page-PLBi7GEM.js +0 -91
  72. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CsUzDnIT.js +0 -1
package/out/main/index.js CHANGED
@@ -86,7 +86,16 @@ const teamFields = zod.z.object({
86
86
  }).strict();
87
87
  const chains = zod.z.record(zod.z.array(zod.z.string().max(1024)).max(100));
88
88
  const scheduleUnit = zod.z.enum(["minutes", "hours", "days"]);
89
- const variableKey = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
89
+ function isAddressableVariableKey(key) {
90
+ return !/^\.+$/.test(key);
91
+ }
92
+ const variableKey = zod.z.string().min(1).max(128).refine(
93
+ (key) => [...key].every(
94
+ (character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 && character !== "/" && character !== "\\"
95
+ )
96
+ ).refine(isAddressableVariableKey);
97
+ const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
98
+ const variableValue = zod.z.string().max(16384);
90
99
  const scheduleWrite = zod.z.object({
91
100
  is_active: zod.z.boolean().nullish(),
92
101
  one_time: zod.z.boolean().nullish(),
@@ -280,6 +289,89 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
280
289
  // `desktopRequestByteBudget`. Firing it costs the control budget and a
281
290
  // 2-byte body, which is what lets the renderer issue it from a keystroke.
282
291
  zod.z.object({ op: zod.z.literal("sessions.warm"), sessionId }).strict(),
292
+ /*
293
+ * A session's code memory: the names the session's own cells have left in its
294
+ * eval namespace.
295
+ *
296
+ * Session-addressed, because the runtime is what holds a namespace and the
297
+ * kernel it lives in is keyed by session id. The legacy
298
+ * `/v1/agents/{id}/execution-variables` route answered through the agent
299
+ * registry instead, which resolves agent-directory UUIDs - so a canonical
300
+ * session id could only ever 404 there, and the panel that taught "code
301
+ * memory" never loaded once.
302
+ */
303
+ zod.z.object({ op: zod.z.literal("sessions.variables.list"), sessionId }).strict(),
304
+ zod.z.object({
305
+ op: zod.z.literal("sessions.variables.create"),
306
+ sessionId,
307
+ key: variableKey,
308
+ value: variableValue,
309
+ type: variableType
310
+ }).strict(),
311
+ zod.z.object({
312
+ op: zod.z.literal("sessions.variables.update"),
313
+ sessionId,
314
+ key: variableKey,
315
+ value: variableValue,
316
+ type: variableType
317
+ }).strict(),
318
+ zod.z.object({
319
+ op: zod.z.literal("sessions.variables.delete"),
320
+ sessionId,
321
+ key: variableKey
322
+ }).strict(),
323
+ // Machine-wide desktop presence. NOT a watch lease and deliberately not
324
+ // shaped like one: it names no session, because the fact it carries is
325
+ // "somebody is at this machine's screen and this app can raise a banner",
326
+ // which is a property of the app rather than of any conversation. The
327
+ // backend reads it to decide whether a BACKGROUND completion is worth a
328
+ // banner here at all (a session runtime with no surface to present on
329
+ // otherwise toasts on its own host, where nobody may be).
330
+ //
331
+ // `subscription_id` is the LIVE feed subscription the presence belongs to,
332
+ // not a per-session watch subscription: the server holds the lease against
333
+ // that socket, so a dropped feed revokes the claim without waiting for a
334
+ // heartbeat to go stale. That is the whole reason presence is a route on the
335
+ // feed rather than a local file - a desktop paired to a backend on another
336
+ // host cannot write to that host's disk.
337
+ zod.z.object({
338
+ op: zod.z.literal("sessions.presence"),
339
+ subscriptionId: zod.z.string().regex(/^[a-f0-9]{1,64}$/),
340
+ canNotify: zod.z.boolean(),
341
+ /*
342
+ * WHY THE CLAIM CARRIES MORE THAN "a desktop is connected".
343
+ *
344
+ * The backend's delivery lease answers two separate questions from
345
+ * this one beat, and a claim that names neither is a claim that
346
+ * delivers NOTHING:
347
+ *
348
+ * - `can_notify_kinds` is what `delivers(kind)` reads. Empty (the
349
+ * default for a client that forgot) means rung 2 is never eligible
350
+ * for any completion, so rung 4 raises the runtime's own banner —
351
+ * earlier, and its claim advances the read watermark, so the
352
+ * clickable banner this app exists to raise never composes.
353
+ * The backend's own test for this is
354
+ * `test_a_claim_with_no_kinds_claims_nothing`: an app that does not
355
+ * advertise must not win the rung.
356
+ * - `window` (with `session_id`) is what `attended` reads. Without it
357
+ * this app counts as watching NOTHING, so a completion in the
358
+ * conversation on screen raises a banner — the inverse of rung 1,
359
+ * and a regression against the per-session flag it replaces.
360
+ *
361
+ * `window` is sent even when there is no window: a windowless app
362
+ * (macOS, alive in the dock) can still raise a banner but cannot be
363
+ * displaying anything, which is why `session_id` must be "" there
364
+ * rather than the last conversation the closed window held.
365
+ */
366
+ canNotifyKinds: zod.z.array(zod.z.enum(["complete", "error"])).max(4),
367
+ sessionId: zod.z.string().regex(/^([a-f0-9]{12})?$/),
368
+ window: zod.z.object({
369
+ exists: zod.z.boolean(),
370
+ focused: zod.z.boolean(),
371
+ visible: zod.z.boolean(),
372
+ minimized: zod.z.boolean()
373
+ }).strict()
374
+ }).strict(),
283
375
  // The legacy surface, reached through the same authenticated vocabulary as
284
376
  // everything else. These routes are gated in managed mode (agent inventory,
285
377
  // cwd paths, job history and conversation content are the same tenant's data
@@ -386,28 +478,6 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
386
478
  systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
387
479
  }).strict(),
388
480
  zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id$1 }).strict(),
389
- zod.z.object({ op: zod.z.literal("legacy.agent.variables.list"), agentId: id$1 }).strict(),
390
- zod.z.object({
391
- op: zod.z.literal("legacy.agent.variables.create"),
392
- agentId: id$1,
393
- variable: zod.z.record(zod.z.unknown())
394
- }).strict(),
395
- zod.z.object({
396
- op: zod.z.literal("legacy.agent.variables.get"),
397
- agentId: id$1,
398
- key: variableKey
399
- }).strict(),
400
- zod.z.object({
401
- op: zod.z.literal("legacy.agent.variables.update"),
402
- agentId: id$1,
403
- key: variableKey,
404
- variable: zod.z.record(zod.z.unknown())
405
- }).strict(),
406
- zod.z.object({
407
- op: zod.z.literal("legacy.agent.variables.delete"),
408
- agentId: id$1,
409
- key: variableKey
410
- }).strict(),
411
481
  zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id$1 }).strict(),
412
482
  zod.z.object({ op: zod.z.literal("commands.list") }).strict(),
413
483
  zod.z.object({
@@ -494,6 +564,25 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
494
564
  asideId: requestId
495
565
  }).strict(),
496
566
  zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
567
+ zod.z.object({
568
+ op: zod.z.literal("mcp.credentials.store"),
569
+ sessionId,
570
+ name: zod.z.string().min(1).max(256),
571
+ values: zod.z.record(zod.z.string().min(1).max(128), zod.z.string().min(1).max(32768)).refine(
572
+ (secrets) => Object.keys(secrets).length <= 32 && Object.values(secrets).reduce(
573
+ (total, value) => total + value.length,
574
+ 0
575
+ ) <= 65536,
576
+ // Mirrors the owner's own bound (`local_operator/mcp/credentials.py`),
577
+ // so an oversized paste is refused as a sentence rather than
578
+ // serialized into a request the control budget rejects as an
579
+ // opaque 413.
580
+ {
581
+ message: "Too many secret values, or too much secret text, for one MCP credential write."
582
+ }
583
+ ),
584
+ confirmedReplace: zod.z.array(zod.z.string().min(1).max(128)).max(32)
585
+ }).strict(),
497
586
  zod.z.object({
498
587
  op: zod.z.literal("mcp.control"),
499
588
  sessionId,
@@ -797,6 +886,27 @@ function desktopEndpoint(request) {
797
886
  can_notify: request.canNotify
798
887
  }
799
888
  };
889
+ case "sessions.presence":
890
+ return {
891
+ path: "/v1/desktop/presence",
892
+ method: "POST",
893
+ body: {
894
+ subscription_id: request.subscriptionId,
895
+ // The three fields the delivery lease reads beside it. Sent
896
+ // snake_case like the route's own model, and always sent: a
897
+ // defaulted claim is what made this app ineligible.
898
+ can_notify_kinds: request.canNotifyKinds,
899
+ session_id: request.sessionId,
900
+ window: request.window,
901
+ // `can_notify` means "can ATTEMPT delivery", never "the user will
902
+ // be reached": `Notification.isSupported()` knows nothing about
903
+ // macOS Focus/DND, Windows Focus Assist or a denied permission.
904
+ // The backend's suppression reads it as eligibility to try, and a
905
+ // claim never advances the read watermark, so a suppressed banner
906
+ // still leaves the durable unseen mark intact.
907
+ can_notify: request.canNotify
908
+ }
909
+ };
800
910
  case "sessions.warm":
801
911
  return {
802
912
  path: `/v1/desktop/sessions/${request.sessionId}/warm`,
@@ -807,6 +917,31 @@ function desktopEndpoint(request) {
807
917
  // makes a legal call answer 422.
808
918
  body: {}
809
919
  };
920
+ case "sessions.variables.list":
921
+ return {
922
+ path: `/v1/desktop/sessions/${request.sessionId}/variables`,
923
+ method: "GET"
924
+ };
925
+ case "sessions.variables.create":
926
+ return {
927
+ path: `/v1/desktop/sessions/${request.sessionId}/variables`,
928
+ method: "POST",
929
+ // The route's own body shape, not the op's: `{key, value, type}` on
930
+ // create and `{value, type}` on update, because the key is in the path
931
+ // once the variable exists and is immutable after that.
932
+ body: { key: request.key, value: request.value, type: request.type }
933
+ };
934
+ case "sessions.variables.update":
935
+ return {
936
+ path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
937
+ method: "PATCH",
938
+ body: { value: request.value, type: request.type }
939
+ };
940
+ case "sessions.variables.delete":
941
+ return {
942
+ path: `/v1/desktop/sessions/${request.sessionId}/variables/${encodeURIComponent(request.key)}`,
943
+ method: "DELETE"
944
+ };
810
945
  case "legacy.models": {
811
946
  const query = new URLSearchParams();
812
947
  if (request.provider) query.set("provider", request.provider);
@@ -920,33 +1055,6 @@ function desktopEndpoint(request) {
920
1055
  };
921
1056
  case "legacy.agent.download":
922
1057
  return { path: `/v1/agents/${request.agentId}/download`, method: "GET" };
923
- case "legacy.agent.variables.list":
924
- return {
925
- path: `/v1/agents/${request.agentId}/execution-variables`,
926
- method: "GET"
927
- };
928
- case "legacy.agent.variables.create":
929
- return {
930
- path: `/v1/agents/${request.agentId}/execution-variables`,
931
- method: "POST",
932
- body: request.variable
933
- };
934
- case "legacy.agent.variables.get":
935
- return {
936
- path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
937
- method: "GET"
938
- };
939
- case "legacy.agent.variables.update":
940
- return {
941
- path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
942
- method: "PATCH",
943
- body: request.variable
944
- };
945
- case "legacy.agent.variables.delete":
946
- return {
947
- path: `/v1/agents/${request.agentId}/execution-variables/${request.key}`,
948
- method: "DELETE"
949
- };
950
1058
  case "legacy.job.cancel":
951
1059
  return { path: `/v1/jobs/${request.jobId}`, method: "DELETE" };
952
1060
  case "commands.list":
@@ -1057,6 +1165,16 @@ function desktopEndpoint(request) {
1057
1165
  path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
1058
1166
  method: "GET"
1059
1167
  };
1168
+ case "mcp.credentials.store":
1169
+ return {
1170
+ path: `/v1/desktop/sessions/${request.sessionId}/mcp/credentials`,
1171
+ method: "POST",
1172
+ body: {
1173
+ name: request.name,
1174
+ values: request.values,
1175
+ confirmed_replace: request.confirmedReplace
1176
+ }
1177
+ };
1060
1178
  case "mcp.control":
1061
1179
  return {
1062
1180
  path: `/v1/desktop/sessions/${request.sessionId}/mcp`,
@@ -1139,6 +1257,341 @@ function desktopEndpoint(request) {
1139
1257
  }
1140
1258
  const MAX_PROBE_PATHS = 64;
1141
1259
  const MAX_FILE_READ_BYTES = 64 * 1024 * 1024;
1260
+ const OPEN_SESSION_FLAG = "--open-session";
1261
+ const OPEN_CATALOGUE_FLAG = "--open-catalogue";
1262
+ const SESSION_ID$2 = /^[a-f0-9]{12}$/;
1263
+ function readOpenSessionArgv(argv) {
1264
+ let found = null;
1265
+ for (let index = 0; index < argv.length; index += 1) {
1266
+ const argument = argv[index];
1267
+ if (argument === OPEN_SESSION_FLAG) {
1268
+ const next = argv[index + 1];
1269
+ if (next !== void 0) found = next;
1270
+ continue;
1271
+ }
1272
+ if (argument.startsWith(`${OPEN_SESSION_FLAG}=`)) {
1273
+ found = argument.slice(OPEN_SESSION_FLAG.length + 1);
1274
+ }
1275
+ }
1276
+ return found !== null && SESSION_ID$2.test(found) ? found : null;
1277
+ }
1278
+ function readLaunchTarget(argv) {
1279
+ const sessionId2 = readOpenSessionArgv(argv);
1280
+ if (sessionId2) return { kind: "session", sessionId: sessionId2 };
1281
+ if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
1282
+ return { kind: "restore" };
1283
+ }
1284
+ const FEED_NOTIFIABLE_KINDS = ["complete", "error"];
1285
+ const DEFAULT_HEARTBEAT_SECONDS = 15;
1286
+ const MIN_HEARTBEAT_SECONDS = 1;
1287
+ const MAX_HEARTBEAT_SECONDS = 60;
1288
+ const MISSED_HEARTBEATS = 3;
1289
+ const RECONNECT_MIN_MS = 1e3;
1290
+ const RECONNECT_MAX_MS = 3e4;
1291
+ const PRESENCE_INTERVAL_MS = 15e3;
1292
+ const WATCHDOG_TICK_MS$1 = 1e3;
1293
+ const SSE_DATA_PREFIX = /^ /;
1294
+ const MAX_FRAME_BYTES$2 = 8 * 1024 * 1024;
1295
+ class DesktopFeedRelay {
1296
+ constructor(backendUrl, token, options) {
1297
+ this.backendUrl = backendUrl;
1298
+ this.token = token;
1299
+ this.options = options;
1300
+ }
1301
+ controller = null;
1302
+ /**
1303
+ * The live body reader, so the watchdog can END the read rather than only
1304
+ * signal the fetch.
1305
+ *
1306
+ * Aborting the controller is not enough on its own to be sure of this: it
1307
+ * aborts a fetch in flight, and whether that also rejects a body read already
1308
+ * in progress is the platform's business (undici does; a test double need
1309
+ * not). `cancel()` is the reader's own guarantee — the pending `read()`
1310
+ * resolves `done` and the pump reconnects — so the watchdog does not depend
1311
+ * on a behaviour it cannot assert here.
1312
+ */
1313
+ reader = null;
1314
+ running = false;
1315
+ stopped = false;
1316
+ connected = false;
1317
+ subscriptionId = null;
1318
+ heartbeatMs = DEFAULT_HEARTBEAT_SECONDS * 1e3;
1319
+ lastFrameAt = 0;
1320
+ watchdog = null;
1321
+ presence = null;
1322
+ reconnectTimer = null;
1323
+ /**
1324
+ * Settles the promise `pause()` is parked on.
1325
+ *
1326
+ * The timer IS the only thing that would resolve it, so a `stop()` that only
1327
+ * cleared the timer left `pump()` awaiting a promise nothing could settle — a
1328
+ * leak nobody would find later (the relay refuses to restart, so it had no
1329
+ * visible symptom). Held so teardown ends the wait explicitly.
1330
+ */
1331
+ reconnectResolve = null;
1332
+ attempt = 0;
1333
+ /**
1334
+ * Single observer per kind, not a subscriber set.
1335
+ *
1336
+ * The relay has exactly two consumers and both are main's: one fan-out that
1337
+ * puts a frame in front of the notifier and (when a window exists) in front
1338
+ * of the renderer, and one that mirrors the connection state to the sidebar.
1339
+ * A set would suggest that consumers can come and go independently, and the
1340
+ * lifecycle here is the opposite of that — the observers are re-attached
1341
+ * whenever the relay is rebuilt for a rotated backend URL, which is a thing
1342
+ * this class cannot do for itself.
1343
+ */
1344
+ frameObserver = null;
1345
+ stateObserver = null;
1346
+ get isConnected() {
1347
+ return this.connected;
1348
+ }
1349
+ /** True when this relay can authenticate at all. */
1350
+ get available() {
1351
+ return Boolean(this.token);
1352
+ }
1353
+ observe(observer) {
1354
+ this.frameObserver = observer;
1355
+ }
1356
+ watchState(observer) {
1357
+ this.stateObserver = observer;
1358
+ observer?.({ connected: this.connected });
1359
+ }
1360
+ /**
1361
+ * Open the feed if the backend supports it. Idempotent, and safe to call
1362
+ * again on every backend-ready transition: a restart or an
1363
+ * external-backend rotation re-runs the capability gate against the backend
1364
+ * that is actually there now.
1365
+ */
1366
+ async start() {
1367
+ if (this.running || this.stopped) return;
1368
+ if (!this.token) {
1369
+ this.setConnected(false);
1370
+ return;
1371
+ }
1372
+ this.running = true;
1373
+ let features;
1374
+ try {
1375
+ const response = await this.options.request({ op: "capabilities" });
1376
+ const body = response.body;
1377
+ features = body?.result?.features;
1378
+ } catch {
1379
+ this.running = false;
1380
+ this.setConnected(false);
1381
+ return;
1382
+ }
1383
+ if (this.stopped) return;
1384
+ if (!(Number(features?.desktop_feed ?? 0) >= 1)) {
1385
+ this.running = false;
1386
+ this.setConnected(false);
1387
+ return;
1388
+ }
1389
+ void this.pump();
1390
+ }
1391
+ /** Tear everything down. Idempotent. */
1392
+ stop() {
1393
+ this.stopped = true;
1394
+ this.running = false;
1395
+ if (this.reconnectTimer) {
1396
+ clearTimeout(this.reconnectTimer);
1397
+ this.reconnectTimer = null;
1398
+ }
1399
+ this.reconnectResolve?.();
1400
+ this.reconnectResolve = null;
1401
+ this.endSocket();
1402
+ this.setConnected(false);
1403
+ }
1404
+ /** Abort the socket and its timers, leaving `stopped` alone. */
1405
+ endSocket() {
1406
+ if (this.watchdog) {
1407
+ clearInterval(this.watchdog);
1408
+ this.watchdog = null;
1409
+ }
1410
+ if (this.presence) {
1411
+ clearInterval(this.presence);
1412
+ this.presence = null;
1413
+ }
1414
+ this.subscriptionId = null;
1415
+ this.controller?.abort();
1416
+ this.controller = null;
1417
+ }
1418
+ /** Read until the socket ends, then pace a reconnect. */
1419
+ async pump() {
1420
+ while (!this.stopped) {
1421
+ await this.readOnce();
1422
+ if (this.stopped) return;
1423
+ this.setConnected(false);
1424
+ await this.pause();
1425
+ }
1426
+ }
1427
+ pause() {
1428
+ const delay = Math.min(
1429
+ RECONNECT_MAX_MS,
1430
+ RECONNECT_MIN_MS * 2 ** Math.min(this.attempt, 5)
1431
+ );
1432
+ this.attempt += 1;
1433
+ return new Promise((resolve) => {
1434
+ this.reconnectResolve = resolve;
1435
+ this.reconnectTimer = setTimeout(() => {
1436
+ this.reconnectTimer = null;
1437
+ this.reconnectResolve = null;
1438
+ resolve();
1439
+ }, delay);
1440
+ this.reconnectTimer.unref?.();
1441
+ });
1442
+ }
1443
+ async readOnce() {
1444
+ const controller = new AbortController();
1445
+ this.controller = controller;
1446
+ const url = new URL("/v1/desktop/events", this.backendUrl);
1447
+ try {
1448
+ const response = await fetch(url, {
1449
+ headers: {
1450
+ Accept: "text/event-stream",
1451
+ Authorization: `Bearer ${this.token}`
1452
+ },
1453
+ redirect: "error",
1454
+ signal: controller.signal
1455
+ });
1456
+ if (!response.ok || !response.body) {
1457
+ return;
1458
+ }
1459
+ this.attempt = 0;
1460
+ this.setConnected(true);
1461
+ this.startWatchdog();
1462
+ const reader = response.body.getReader();
1463
+ this.reader = reader;
1464
+ const decoder = new TextDecoder();
1465
+ let buffer = "";
1466
+ for (; ; ) {
1467
+ const { done, value } = await reader.read();
1468
+ if (done) break;
1469
+ this.lastFrameAt = Date.now();
1470
+ buffer += decoder.decode(value, { stream: true });
1471
+ for (; ; ) {
1472
+ const boundary = buffer.indexOf("\n\n");
1473
+ if (boundary < 0) {
1474
+ if (buffer.length > MAX_FRAME_BYTES$2) {
1475
+ reader.cancel().catch(() => void 0);
1476
+ return;
1477
+ }
1478
+ break;
1479
+ }
1480
+ const record = buffer.slice(0, boundary);
1481
+ buffer = buffer.slice(boundary + 2);
1482
+ for (const line of record.split("\n")) {
1483
+ if (!line.startsWith("data:")) continue;
1484
+ this.dispatch(line.slice(5).replace(SSE_DATA_PREFIX, ""));
1485
+ }
1486
+ }
1487
+ }
1488
+ } catch {
1489
+ } finally {
1490
+ if (this.controller === controller) this.controller = null;
1491
+ this.reader = null;
1492
+ if (this.watchdog) {
1493
+ clearInterval(this.watchdog);
1494
+ this.watchdog = null;
1495
+ }
1496
+ if (this.presence) {
1497
+ clearInterval(this.presence);
1498
+ this.presence = null;
1499
+ }
1500
+ this.subscriptionId = null;
1501
+ }
1502
+ }
1503
+ dispatch(data) {
1504
+ let frame;
1505
+ try {
1506
+ frame = JSON.parse(data);
1507
+ } catch {
1508
+ return;
1509
+ }
1510
+ if (frame.type === "open") {
1511
+ this.subscriptionId = frame.payload.subscription_id;
1512
+ if (typeof frame.payload.heartbeat_seconds === "number") {
1513
+ this.heartbeatMs = Math.min(
1514
+ MAX_HEARTBEAT_SECONDS,
1515
+ Math.max(
1516
+ this.options.heartbeatFloorSeconds ?? MIN_HEARTBEAT_SECONDS,
1517
+ frame.payload.heartbeat_seconds
1518
+ )
1519
+ ) * 1e3;
1520
+ }
1521
+ this.startPresence();
1522
+ }
1523
+ try {
1524
+ this.frameObserver?.(frame);
1525
+ } catch {
1526
+ }
1527
+ }
1528
+ /**
1529
+ * Tear the socket down when it has gone quiet for three heartbeats.
1530
+ *
1531
+ * A half-open socket reports no error, no end and no data: the reader simply
1532
+ * never resolves. Without this the app would look connected while no
1533
+ * completion could reach it, which is the silent-failure state this whole
1534
+ * change exists to remove.
1535
+ */
1536
+ startWatchdog() {
1537
+ this.lastFrameAt = Date.now();
1538
+ if (this.watchdog) clearInterval(this.watchdog);
1539
+ this.watchdog = setInterval(() => {
1540
+ if (Date.now() - this.lastFrameAt < this.heartbeatMs * MISSED_HEARTBEATS)
1541
+ return;
1542
+ void this.reader?.cancel().catch(() => void 0);
1543
+ this.controller?.abort();
1544
+ }, WATCHDOG_TICK_MS$1);
1545
+ this.watchdog.unref?.();
1546
+ }
1547
+ /**
1548
+ * Renew the presence lease for as long as this subscription is live.
1549
+ *
1550
+ * The first beat is immediate: the backend holds the lease against the
1551
+ * subscription, so waiting a full interval would leave a window in which the
1552
+ * app can deliver a banner while the backend still believes nobody is there
1553
+ * and toasts on its own host instead.
1554
+ */
1555
+ startPresence() {
1556
+ if (this.presence) clearInterval(this.presence);
1557
+ const beat = () => {
1558
+ const subscriptionId = this.subscriptionId;
1559
+ if (!subscriptionId) return;
1560
+ const context = this.options.presenceContext?.() ?? {
1561
+ sessionId: "",
1562
+ window: {
1563
+ exists: false,
1564
+ focused: false,
1565
+ visible: false,
1566
+ minimized: false
1567
+ }
1568
+ };
1569
+ void this.options.beatPresence({
1570
+ subscriptionId,
1571
+ canNotify: true,
1572
+ // Always the FEED's kinds, never a window's: this app delivers a
1573
+ // completion window or no window, which is why the feed exists.
1574
+ canNotifyKinds: FEED_NOTIFIABLE_KINDS,
1575
+ sessionId: context.sessionId,
1576
+ window: context.window
1577
+ }).catch(() => void 0);
1578
+ };
1579
+ beat();
1580
+ this.presence = setInterval(
1581
+ beat,
1582
+ this.options.presenceIntervalMs ?? PRESENCE_INTERVAL_MS
1583
+ );
1584
+ this.presence.unref?.();
1585
+ }
1586
+ setConnected(connected) {
1587
+ if (this.connected === connected) return;
1588
+ this.connected = connected;
1589
+ try {
1590
+ this.stateObserver?.({ connected });
1591
+ } catch {
1592
+ }
1593
+ }
1594
+ }
1142
1595
  const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
1143
1596
  const MAX_UPLOAD_BYTES = 16 * 1024 * 1024;
1144
1597
  const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
@@ -1335,7 +1788,7 @@ const DESKTOP_STREAM_DETAIL = {
1335
1788
  */
1336
1789
  refusedWithoutStatus: "The event stream was refused."
1337
1790
  };
1338
- const SESSION_ID = /^[a-f0-9]{12}$/;
1791
+ const SESSION_ID$1 = /^[a-f0-9]{12}$/;
1339
1792
  const SAFE_ID = /^[a-zA-Z0-9_-]{1,128}$/;
1340
1793
  function transportFailureDetail(error) {
1341
1794
  if (error instanceof Error && error.name === "AbortError") {
@@ -1345,11 +1798,14 @@ function transportFailureDetail(error) {
1345
1798
  if (code === "ECONNREFUSED") return DESKTOP_STREAM_DETAIL.serverDown;
1346
1799
  return DESKTOP_STREAM_DETAIL.connectionFailed;
1347
1800
  }
1348
- const MAX_FRAME_BYTES = 8 * 1024 * 1024;
1801
+ const SILENCE_TIMEOUT_MS = 45e3;
1802
+ const WATCHDOG_TICK_MS = 1e3;
1803
+ const MAX_FRAME_BYTES$1 = 8 * 1024 * 1024;
1349
1804
  class DesktopStreamRelay {
1350
- constructor(backendUrl, token) {
1805
+ constructor(backendUrl, token, timings = {}) {
1351
1806
  this.backendUrl = backendUrl;
1352
1807
  this.token = token;
1808
+ this.timings = timings;
1353
1809
  }
1354
1810
  streams = /* @__PURE__ */ new Map();
1355
1811
  /** Frame observer for main-owned side effects (notifications). It sees
@@ -1364,7 +1820,7 @@ class DesktopStreamRelay {
1364
1820
  return Boolean(this.token);
1365
1821
  }
1366
1822
  subscribe(args, emit) {
1367
- if (!SESSION_ID.test(args.sessionId)) {
1823
+ if (!SESSION_ID$1.test(args.sessionId)) {
1368
1824
  throw new Error("Invalid session for streaming.");
1369
1825
  }
1370
1826
  if (args.epoch !== void 0 && !SAFE_ID.test(args.epoch)) {
@@ -1421,41 +1877,63 @@ class DesktopStreamRelay {
1421
1877
  emit({
1422
1878
  streamId,
1423
1879
  kind: "error",
1424
- detail: DESKTOP_STREAM_DETAIL.refused(response.status)
1880
+ detail: DESKTOP_STREAM_DETAIL.refused(response.status),
1881
+ // Carried so the renderer can tell "this conversation is gone"
1882
+ // from "this transport is down". Both are refusals; only one of
1883
+ // them is about the session, and the two need different words.
1884
+ status: response.status
1425
1885
  });
1426
1886
  return;
1427
1887
  }
1428
1888
  const reader = response.body.getReader();
1429
1889
  const decoder = new TextDecoder();
1430
1890
  let buffer = "";
1431
- for (; ; ) {
1432
- const { done, value } = await reader.read();
1433
- if (done) break;
1434
- buffer += decoder.decode(value, { stream: true });
1891
+ let lastActivity = Date.now();
1892
+ const silenceTimeoutMs = this.timings.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS;
1893
+ const watchdog = setInterval(() => {
1894
+ if (Date.now() - lastActivity < silenceTimeoutMs) return;
1895
+ clearInterval(watchdog);
1896
+ emit({
1897
+ streamId,
1898
+ kind: "error",
1899
+ detail: DESKTOP_STREAM_DETAIL.ended
1900
+ });
1901
+ void reader.cancel().catch(() => void 0);
1902
+ }, this.timings.watchdogTickMs ?? WATCHDOG_TICK_MS);
1903
+ watchdog.unref?.();
1904
+ try {
1435
1905
  for (; ; ) {
1436
- const boundary = buffer.indexOf("\n\n");
1437
- if (boundary < 0) {
1438
- if (buffer.length > MAX_FRAME_BYTES) {
1439
- emit({
1440
- streamId,
1441
- kind: "error",
1442
- detail: DESKTOP_STREAM_DETAIL.frameBudget
1443
- });
1444
- reader.cancel().catch(() => void 0);
1445
- return;
1906
+ const { done, value } = await reader.read();
1907
+ if (done) break;
1908
+ lastActivity = Date.now();
1909
+ buffer += decoder.decode(value, { stream: true });
1910
+ for (; ; ) {
1911
+ const boundary = buffer.indexOf("\n\n");
1912
+ if (boundary < 0) {
1913
+ if (buffer.length > MAX_FRAME_BYTES$1) {
1914
+ emit({
1915
+ streamId,
1916
+ kind: "error",
1917
+ detail: DESKTOP_STREAM_DETAIL.frameBudget
1918
+ });
1919
+ reader.cancel().catch(() => void 0);
1920
+ return;
1921
+ }
1922
+ break;
1446
1923
  }
1447
- break;
1448
- }
1449
- const record = buffer.slice(0, boundary);
1450
- buffer = buffer.slice(boundary + 2);
1451
- for (const line of record.split("\n")) {
1452
- if (line.startsWith("data:")) {
1453
- const data = line.slice(5).replace(/^ /, "");
1454
- this.observer?.(sessionId2, data);
1455
- emit({ streamId, kind: "data", data });
1924
+ const record = buffer.slice(0, boundary);
1925
+ buffer = buffer.slice(boundary + 2);
1926
+ for (const line of record.split("\n")) {
1927
+ if (line.startsWith("data:")) {
1928
+ const data = line.slice(5).replace(/^ /, "");
1929
+ this.observer?.(sessionId2, data);
1930
+ emit({ streamId, kind: "data", data });
1931
+ }
1456
1932
  }
1457
1933
  }
1458
1934
  }
1935
+ } finally {
1936
+ clearInterval(watchdog);
1459
1937
  }
1460
1938
  emit({ streamId, kind: "end" });
1461
1939
  } catch (error) {
@@ -2835,6 +3313,27 @@ class BackendServiceManager {
2835
3313
  /** Survives relay recreation so notifications never silently detach
2836
3314
  * when the backend URL rotates. */
2837
3315
  streamObserver = null;
3316
+ /**
3317
+ * The machine-wide desktop feed relay, rebuilt when the backend URL rotates
3318
+ * for the same reason the stream relay is: a subscription must never pin a
3319
+ * stale origin. Held HERE rather than in `index.ts` because the desktop
3320
+ * bearer lives in this class and is never exposed — the feed needs it to
3321
+ * carry an SSE connection and to beat the presence lease.
3322
+ */
3323
+ feedRelay = null;
3324
+ feedRelayUrl = "";
3325
+ /** Both survive relay recreation, so a URL rotation cannot silently detach
3326
+ * the banner path or leave the sidebar reading a stale connection state. */
3327
+ feedFrameObserver = null;
3328
+ feedStateObserver = null;
3329
+ /**
3330
+ * The window/focus/displayed-session answers the presence claim carries.
3331
+ *
3332
+ * Set by main once a window can exist — before that a windowless app is the
3333
+ * honest answer, which is also the reference's own default for a client that
3334
+ * has not said anything yet.
3335
+ */
3336
+ presenceContext = null;
2838
3337
  /**
2839
3338
  * Called every time the backend becomes reachable and authenticated.
2840
3339
  *
@@ -2895,6 +3394,60 @@ class BackendServiceManager {
2895
3394
  this.streamObserver = observer;
2896
3395
  this.streamRelay?.observe(observer);
2897
3396
  }
3397
+ /**
3398
+ * The machine-wide feed, rebuilt when the backend URL rotates.
3399
+ *
3400
+ * Lazily constructed for the same reason `getStreamRelay` is: the desktop
3401
+ * token is minted inside `start()`, so a relay built at `app.whenReady()`
3402
+ * would capture `null` and never authenticate. Callers reach for it from the
3403
+ * backend-ready hook, which is the first moment the token exists.
3404
+ */
3405
+ getDesktopFeedRelay() {
3406
+ if (!this.feedRelay || this.feedRelayUrl !== this.backendUrl) {
3407
+ this.feedRelay?.stop();
3408
+ this.feedRelay = new DesktopFeedRelay(
3409
+ this.backendUrl,
3410
+ this.desktopToken,
3411
+ {
3412
+ request: (input) => this.requestDesktop(input),
3413
+ // The presence beat is a contract op, so it travels the same
3414
+ // authenticated transport as every other control and the token
3415
+ // never leaves this class.
3416
+ beatPresence: (presence) => this.requestDesktop({
3417
+ op: "sessions.presence",
3418
+ ...presence
3419
+ }),
3420
+ // What this app can say about its own window. Supplied by main
3421
+ // (it owns the window and the notifier's displayed session) and
3422
+ // read at each beat rather than captured, so a window that opens
3423
+ // or closes mid-lease is reported without a new relay.
3424
+ presenceContext: () => this.presenceContext?.() ?? null
3425
+ }
3426
+ );
3427
+ this.feedRelay.observe(this.feedFrameObserver);
3428
+ this.feedRelay.watchState(this.feedStateObserver);
3429
+ this.feedRelayUrl = this.backendUrl;
3430
+ }
3431
+ return this.feedRelay;
3432
+ }
3433
+ /**
3434
+ * Supply the presence claim's window state.
3435
+ *
3436
+ * Separate from `observeDesktopFeed` because it is a different question
3437
+ * asked of a different owner: the observers are consumers of frames, and
3438
+ * this is main's answer about itself. Applied to a relay that already
3439
+ * exists, so the ordering between "start the feed" and "make a window" does
3440
+ * not decide whether the claim is complete.
3441
+ */
3442
+ providePresenceContext(context) {
3443
+ this.presenceContext = context;
3444
+ }
3445
+ observeDesktopFeed(frameObserver, stateObserver) {
3446
+ this.feedFrameObserver = frameObserver;
3447
+ this.feedStateObserver = stateObserver;
3448
+ this.feedRelay?.observe(frameObserver);
3449
+ this.feedRelay?.watchState(stateObserver);
3450
+ }
2898
3451
  requestDesktop(input) {
2899
3452
  return requestDesktop(input, this.backendUrl, this.desktopToken);
2900
3453
  }
@@ -8422,7 +8975,7 @@ function relayedBytes(value) {
8422
8975
  if (!(buffer instanceof ArrayBuffer)) return null;
8423
8976
  return new Uint8Array(buffer, byteOffset, byteLength);
8424
8977
  }
8425
- function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier) {
8978
+ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifier, noteLeftSession) {
8426
8979
  const opened = /* @__PURE__ */ new Map();
8427
8980
  function authorize(event) {
8428
8981
  const owner = window();
@@ -8457,6 +9010,15 @@ function registerDesktopIPC(window, expectedUrl, request, streams, media, notifi
8457
9010
  focused: args.focused
8458
9011
  });
8459
9012
  });
9013
+ electron.ipcMain.handle("desktop-watch-release", (event, input) => {
9014
+ authorize(event);
9015
+ const args = input;
9016
+ if (typeof args?.sessionId !== "string") {
9017
+ throw new Error("Invalid watch release.");
9018
+ }
9019
+ notifier.releaseWatch(event.sender.id, args.sessionId);
9020
+ noteLeftSession?.(args.sessionId);
9021
+ });
8460
9022
  }
8461
9023
  if (media) {
8462
9024
  electron.ipcMain.handle("desktop-media", (event, input, bytes) => {
@@ -8557,11 +9119,26 @@ function gateBody(kind, gateTitle, gateDetail) {
8557
9119
  }
8558
9120
  return subject || GATE_BODIES[kind] || GATE_BODIES.approval;
8559
9121
  }
9122
+ function isBurstDigest(n) {
9123
+ return (n.burst_count ?? 0) > 1 || (n.session_ids?.length ?? 0) > 1;
9124
+ }
9125
+ function digestMemberCount(n) {
9126
+ return (n.member_tokens ?? []).filter(
9127
+ (member) => !!member?.session_id && !!member?.completion_token
9128
+ ).length;
9129
+ }
9130
+ const SILENT_HOST = {
9131
+ windowAlive: () => true,
9132
+ noteDisplayed: () => void 0,
9133
+ reopen: () => void 0
9134
+ };
9135
+ const RENDERER_REPORT_TTL_MS = 45e3;
8560
9136
  class DesktopNotifier {
8561
- constructor(window, request, windowRaise = "focus") {
9137
+ constructor(window, request, windowRaise = "focus", host = SILENT_HOST) {
8562
9138
  this.window = window;
8563
9139
  this.request = request;
8564
9140
  this.windowRaise = windowRaise;
9141
+ this.host = host;
8565
9142
  }
8566
9143
  delivered = /* @__PURE__ */ new Map();
8567
9144
  /** Last known frontend epoch per session, to key dedupe on owner epoch. */
@@ -8730,12 +9307,24 @@ class DesktopNotifier {
8730
9307
  * renderer's active subscription; `can_notify` is asserted only when main
8731
9308
  * can really deliver. A focused window is interactive; a visible-but-
8732
9309
  * unfocused one still watches but is not; a hidden one is neither.
9310
+ *
9311
+ * ``windowId`` IS THE WEB CONTENTS ID (``event.sender.id``, from the IPC
9312
+ * handler), and the map is keyed on it because that is what a close cleanup can
9313
+ * still name after the window is gone. Every reader must use the SAME id — see
9314
+ * `presence()`, where reading the ``BrowserWindow`` id instead made the
9315
+ * machine-wide presence report an empty session id for a window it was
9316
+ * claiming was focused on a conversation (review round 2, R2-3).
8733
9317
  */
8734
9318
  async heartbeat(windowId, args) {
8735
9319
  this.windows.set(windowId, {
8736
9320
  visible: args.visible,
8737
- focused: args.focused
9321
+ focused: args.focused,
9322
+ session: args.sessionId,
9323
+ // THE REPORT'S AGE, part of the record rather than a separate map
9324
+ // (R2-4): a state that cannot expire is a state that outlives its pane.
9325
+ seenAt: Date.now()
8738
9326
  });
9327
+ this.host.noteDisplayed(args.sessionId);
8739
9328
  return this.request({
8740
9329
  op: "sessions.watch",
8741
9330
  sessionId: args.sessionId,
@@ -8744,10 +9333,52 @@ class DesktopNotifier {
8744
9333
  canNotify: this.canNotify
8745
9334
  });
8746
9335
  }
9336
+ /**
9337
+ * Forget a window's watch state on close.
9338
+ *
9339
+ * This had NO CALLER while `mainWindow.on("closed")` nulled the window
9340
+ * without telling the notifier, and `webContents` ids are not reused — so
9341
+ * after one window close the map held `{visible: true, focused: true}` for
9342
+ * the life of the process and every `when_unfocused` completion was
9343
+ * suppressed for good. That is the strongest candidate for the operator's
9344
+ * "I don't reliably get notified", and the fix is this call plus the
9345
+ * liveness read in `anyDisplayingFocused`.
9346
+ */
8747
9347
  forgetWindow(windowId) {
8748
9348
  this.windows.delete(windowId);
8749
9349
  }
8750
- /** Called by the stream relay for every parsed frame it forwards. */
9350
+ /**
9351
+ * Withdraw the DISPLAYED conversation when the pane that reported it leaves.
9352
+ *
9353
+ * THE NAVIGATION HALF OF R2-4. `forgetWindow` runs when a window closes, and
9354
+ * the report now expires on its own after `RENDERER_REPORT_TTL_MS` — but
9355
+ * navigating from a conversation to the catalogue or Settings stops the watch
9356
+ * and unsubscribes its stream while leaving the window, and therefore the map
9357
+ * entry, exactly where it was. Main's own feed then kept renewing "showing A"
9358
+ * every beat and the backend, which treats fresh presence as authoritative,
9359
+ * suppressed A's banner while no pane displayed it.
9360
+ *
9361
+ * IDENTITY-SAFE BY CONSTRUCTION, which is the part a naive clear gets wrong: a
9362
+ * cleanup runs asynchronously after the pane has already changed, so an
9363
+ * unconditional clear would wipe the session a DEEPER pane has since
9364
+ * subscribed to. This withdraws only the report that names the conversation
9365
+ * being left.
9366
+ */
9367
+ releaseWatch(windowId, sessionId2) {
9368
+ const state = this.windows.get(windowId);
9369
+ if (!state) return;
9370
+ if (!sessionId2 || state.session !== sessionId2) return;
9371
+ this.windows.set(windowId, { ...state, session: "" });
9372
+ }
9373
+ /**
9374
+ * Called by the stream relay for every parsed frame it forwards, and by the
9375
+ * machine-wide feed for the frames it carries.
9376
+ *
9377
+ * ONE method for both sources on purpose. A `notification` frame's payload is
9378
+ * byte-identical whichever way it arrived — same `dedupe_key`, minted by the
9379
+ * backend — so two entry points would be two places to keep that true, and
9380
+ * the pair would eventually be delivered twice instead of once.
9381
+ */
8751
9382
  observe(sessionId2, frame) {
8752
9383
  if (!this.canNotify) return;
8753
9384
  if (this.windowRaise === "never") return;
@@ -8808,16 +9439,47 @@ class DesktopNotifier {
8808
9439
  */
8809
9440
  async composed(sessionId2, n) {
8810
9441
  if (!this.claim(n.dedupe_key)) return;
8811
- if (n.focus_policy === "when_unfocused" && this.anyFocused()) return;
9442
+ if (n.focus_policy === "when_unfocused" && this.anyDisplayingFocused(sessionId2))
9443
+ return;
8812
9444
  if (n.completion_token) {
8813
9445
  const outcome = await this.claimDelivery(sessionId2, n.completion_token);
8814
9446
  if (outcome !== "won") {
8815
9447
  if (outcome === "failed") this.delivered.delete(n.dedupe_key);
8816
9448
  return;
8817
9449
  }
9450
+ } else if (digestMemberCount(n) > 0 || isBurstDigest(n)) {
9451
+ const won = await this.claimDigestMembers(n);
9452
+ if (won === 0 && digestMemberCount(n) > 0) {
9453
+ this.delivered.delete(n.dedupe_key);
9454
+ return;
9455
+ }
8818
9456
  }
8819
9457
  this.show(
8820
- sessionId2,
9458
+ /*
9459
+ * A BURST DIGEST is not about one conversation (R1-2). The backend
9460
+ * caps per-tick banners and publishes ONE frame for the remainder,
9461
+ * carrying `burst_count` and `session_ids` and setting `session_id`
9462
+ * to the LAST overflow member — it must name something for the frame
9463
+ * to be a valid session frame at all. Routing its click to that id
9464
+ * opened one arbitrary member of the burst, which is the "the click
9465
+ * did not land where I expected" defect this change exists to
9466
+ * remove, so a digest opens the catalogue instead.
9467
+ *
9468
+ * Detected on the fields themselves rather than on a title string:
9469
+ * the strings are the backend's to word, and a surface that matched
9470
+ * on them would route a re-worded digest to a random conversation.
9471
+ *
9472
+ * A ONE-MEMBER DIGEST LANDS ON ITS OWN CONVERSATION, and that is a
9473
+ * decision rather than a fall-through (review round 3, R3-1). The frame
9474
+ * above is the case the claim branch now recognises and this one does
9475
+ * not: `burst_count` is 1 and it names exactly one conversation, so the
9476
+ * banner says one thing and the click goes there — the operator's own
9477
+ * request ("a click landing on the exact conversation") read literally.
9478
+ * Folding the member marker into `isBurstDigest` would send it to the
9479
+ * catalogue and lose that navigation for no gain in correctness, since
9480
+ * it is the CLAIM that has to happen for every member, not the routing.
9481
+ */
9482
+ isBurstDigest(n) ? null : sessionId2,
8821
9483
  n.title,
8822
9484
  n.status,
8823
9485
  n.body,
@@ -8825,6 +9487,36 @@ class DesktopNotifier {
8825
9487
  n.body_is_failure ?? false
8826
9488
  );
8827
9489
  }
9490
+ /**
9491
+ * Claim a burst digest's members, and report how many this surface won.
9492
+ *
9493
+ * One `sessions.notified` per member, through the SAME atomic claim a single
9494
+ * frame uses — that is the point of the contract: the arbitration lives in one
9495
+ * place (the backend's `claim_delivery`), so a digest and an individual banner
9496
+ * for one of its members cannot both win.
9497
+ *
9498
+ * Members are claimed SEQUENTIALLY rather than in parallel, deliberately: this
9499
+ * runs on the delivery path with a banner's worth of latency behind it, and a
9500
+ * burst is capped at a handful of members by the backend's own ceiling.
9501
+ *
9502
+ * A `failed` claim is treated as NOT won, which makes the whole digest fail
9503
+ * closed when the backend is unreachable — the same choice the single-frame
9504
+ * path makes, and for the same reason: showing the banner anyway would risk a
9505
+ * second banner for completions another surface may also have reached.
9506
+ */
9507
+ async claimDigestMembers(n) {
9508
+ const members = n.member_tokens ?? [];
9509
+ let won = 0;
9510
+ for (const member of members) {
9511
+ if (!member?.session_id || !member?.completion_token) continue;
9512
+ const outcome = await this.claimDelivery(
9513
+ member.session_id,
9514
+ member.completion_token
9515
+ );
9516
+ if (outcome === "won") won += 1;
9517
+ }
9518
+ return won;
9519
+ }
8828
9520
  /**
8829
9521
  * Ask the backend whether this surface owns this completion's toast.
8830
9522
  *
@@ -8852,12 +9544,91 @@ class DesktopNotifier {
8852
9544
  return "failed";
8853
9545
  }
8854
9546
  }
8855
- anyFocused() {
8856
- for (const state of this.windows.values()) {
8857
- if (state.visible && state.focused) return true;
9547
+ /**
9548
+ * Whether a LIVE window is displaying `sessionId` in the foreground.
9549
+ *
9550
+ * THE completion gate, and the predicate is deliberately two facts rather
9551
+ * than one. `anyFocused()` alone answers "is the app in front", which is not
9552
+ * the question: with the app focused on session A, a completion in session B
9553
+ * was suppressed and announced to nobody — and rung 3 had already deferred
9554
+ * to rung 2, so nothing else could raise it either. That is the operator's
9555
+ * reported symptom, made permanent by the presence mechanism itself (B1).
9556
+ *
9557
+ * Only a window showing THIS conversation is a reason not to banner, because
9558
+ * only then is the completion already on screen.
9559
+ *
9560
+ * Dead windows are filtered rather than assumed gone. `forgetWindow` runs on
9561
+ * `closed`, but a renderer process that dies takes its `webContents` id with
9562
+ * it and no `closed` event reaches this map, and `webContents` ids are not
9563
+ * reused — so an unfiltered stale `{visible: true, focused: true}` entry
9564
+ * suppresses every completion for the life of the process.
9565
+ */
9566
+ anyDisplayingFocused(sessionId2) {
9567
+ for (const [id2, state] of this.windows) {
9568
+ if (!state.visible || !state.focused) continue;
9569
+ if (state.session !== sessionId2) continue;
9570
+ if (!this.host.windowAlive(id2)) continue;
9571
+ if (Date.now() - state.seenAt > RENDERER_REPORT_TTL_MS) continue;
9572
+ return true;
8858
9573
  }
8859
9574
  return false;
8860
9575
  }
9576
+ /**
9577
+ * What this app can say about itself for the machine-wide presence claim.
9578
+ *
9579
+ * The claim is not "a desktop is connected" - the backend already knows that
9580
+ * from the socket. It is the two answers only the app has, and both are
9581
+ * read by the delivery ladder: `session_id` + `window` decide `attended`
9582
+ * (rung 1's "a watching surface already has it"), and a MISSING window is not
9583
+ * a missing value - it means this app is watching nothing, which is the
9584
+ * correct answer for the operator's own windowless case and the wrong answer
9585
+ * for a window that is merely minimised.
9586
+ *
9587
+ * `sessionId` comes from this notifier's own heartbeat map rather than from
9588
+ * the renderer, so it cannot disagree with the completion gate above: the
9589
+ * same value that suppresses a banner is the value that tells the backend
9590
+ * not to raise someone else's. An app that names a conversation it is not
9591
+ * showing suppresses the banner for a conversation nobody can see.
9592
+ *
9593
+ * Dead windows answer "no window" rather than "last known window": a
9594
+ * destroyed window has no `is*()` to read, and reporting its stale state
9595
+ * would advertise a surface that cannot display anything.
9596
+ *
9597
+ * THE SESSION IS READ BY THE ID THE WRITER USED (review round 2, R2-3).
9598
+ * `heartbeat` keys this map on `event.sender.id` — the WEB CONTENTS id —
9599
+ * because that is what the IPC event carries and what the close cleanup
9600
+ * captures. This accessor read `window.id`, the BrowserWindow id, which merely
9601
+ * happens to be equal early in a session and drifts as soon as anything else
9602
+ * is created (the browser host's own webContents, a second window). It then
9603
+ * reported a focused, visible window with `session_id: ""`, so the backend
9604
+ * could not recognise the conversation actually on screen and bannered it as
9605
+ * background.
9606
+ */
9607
+ presence() {
9608
+ const window = this.window();
9609
+ if (!window || window.isDestroyed()) {
9610
+ return {
9611
+ sessionId: "",
9612
+ window: {
9613
+ exists: false,
9614
+ focused: false,
9615
+ visible: false,
9616
+ minimized: false
9617
+ }
9618
+ };
9619
+ }
9620
+ const reported = this.windows.get(window.webContents.id);
9621
+ const current = reported && Date.now() - reported.seenAt <= RENDERER_REPORT_TTL_MS;
9622
+ return {
9623
+ sessionId: current ? reported.session : "",
9624
+ window: {
9625
+ exists: true,
9626
+ focused: window.isFocused(),
9627
+ visible: window.isVisible(),
9628
+ minimized: window.isMinimized()
9629
+ }
9630
+ };
9631
+ }
8861
9632
  /**
8862
9633
  * Raise the banner for a pending approval or question.
8863
9634
  *
@@ -8909,7 +9680,7 @@ class DesktopNotifier {
8909
9680
  turn(sessionId2, seq) {
8910
9681
  const key = `turn:${sessionId2}:${this.epochs.get(sessionId2) ?? ""}:${seq}`;
8911
9682
  if (!this.claim(key)) return;
8912
- if (this.anyFocused()) return;
9683
+ if (this.anyDisplayingFocused(sessionId2)) return;
8913
9684
  this.show(sessionId2, "Turn complete", "", "The agent finished its turn.");
8914
9685
  }
8915
9686
  claim(key) {
@@ -8973,9 +9744,12 @@ class DesktopNotifier {
8973
9744
  });
8974
9745
  notification.on("click", () => {
8975
9746
  const target2 = this.window();
8976
- if (!target2) return;
8977
- raiseWindow(target2, this.windowRaise);
9747
+ if (!target2 || target2.isDestroyed()) {
9748
+ this.host.reopen(sessionId2);
9749
+ return;
9750
+ }
8978
9751
  target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
9752
+ raiseWindow(target2, this.windowRaise);
8979
9753
  });
8980
9754
  notification.show();
8981
9755
  }
@@ -12728,6 +13502,311 @@ ${pipRun.stderr.trim()}`,
12728
13502
  }
12729
13503
  }
12730
13504
  }
13505
+ const AUTH_TIMEOUT_MS = 5e3;
13506
+ const MAX_FRAME_BYTES = 64 * 1024;
13507
+ const SESSION_ID = /^[a-f0-9]{12}$/;
13508
+ class ViewerEndpoint {
13509
+ constructor(host, key) {
13510
+ this.host = host;
13511
+ this.key = key;
13512
+ }
13513
+ server = null;
13514
+ port = 0;
13515
+ get boundPort() {
13516
+ return this.port;
13517
+ }
13518
+ /**
13519
+ * Bind on loopback and answer the port.
13520
+ *
13521
+ * IDEMPOTENT, and that is load-bearing: a window can be recreated, and the
13522
+ * app's own `activate` path runs again after every close. A second bind
13523
+ * would leak a listener, and a leaked listener is worse than a leaked
13524
+ * source because the record then advertises a port nothing reads.
13525
+ */
13526
+ start() {
13527
+ if (this.server) return Promise.resolve(this.port);
13528
+ return new Promise((resolve, reject) => {
13529
+ const server = node_net.createServer((socket) => this.onConnection(socket));
13530
+ server.on("error", reject);
13531
+ server.listen(0, "127.0.0.1", () => {
13532
+ const address = server.address();
13533
+ if (address === null || typeof address === "string") {
13534
+ server.close();
13535
+ reject(new Error("The viewer endpoint could not resolve its port."));
13536
+ return;
13537
+ }
13538
+ this.server = server;
13539
+ this.port = address.port;
13540
+ server.removeAllListeners("error");
13541
+ server.on("error", () => void 0);
13542
+ resolve(this.port);
13543
+ });
13544
+ });
13545
+ }
13546
+ /** Stop serving. Idempotent, and safe on a listener that never bound. */
13547
+ close() {
13548
+ const server = this.server;
13549
+ this.server = null;
13550
+ this.port = 0;
13551
+ if (!server) return;
13552
+ try {
13553
+ server.close();
13554
+ } catch {
13555
+ }
13556
+ }
13557
+ onConnection(socket) {
13558
+ let authenticated = false;
13559
+ let buffer = "";
13560
+ let closed = false;
13561
+ let chain = Promise.resolve();
13562
+ const finish = () => {
13563
+ closed = true;
13564
+ socket.destroy();
13565
+ };
13566
+ const authTimer = setTimeout(() => {
13567
+ if (!authenticated) finish();
13568
+ }, AUTH_TIMEOUT_MS);
13569
+ socket.setEncoding("utf8");
13570
+ socket.on("error", finish);
13571
+ socket.on("close", () => clearTimeout(authTimer));
13572
+ socket.on("data", (chunk) => {
13573
+ if (closed) return;
13574
+ buffer += chunk;
13575
+ if (buffer.length > MAX_FRAME_BYTES) {
13576
+ finish();
13577
+ return;
13578
+ }
13579
+ for (; ; ) {
13580
+ const newline = buffer.indexOf("\n");
13581
+ if (newline < 0) break;
13582
+ const line = buffer.slice(0, newline);
13583
+ buffer = buffer.slice(newline + 1);
13584
+ chain = chain.then(
13585
+ () => this.onLine(line, socket, {
13586
+ isAuthenticated: () => authenticated,
13587
+ markAuthenticated: () => {
13588
+ authenticated = true;
13589
+ clearTimeout(authTimer);
13590
+ },
13591
+ finish
13592
+ }).catch(() => void 0)
13593
+ );
13594
+ }
13595
+ });
13596
+ }
13597
+ async onLine(line, socket, state) {
13598
+ let frame;
13599
+ try {
13600
+ frame = JSON.parse(line);
13601
+ } catch {
13602
+ return;
13603
+ }
13604
+ if (typeof frame !== "object" || frame === null) return;
13605
+ const record = frame;
13606
+ if (!state.isAuthenticated()) {
13607
+ const key = typeof record.key === "string" ? record.key : "";
13608
+ if (!constantTimeEqual(key, this.key)) {
13609
+ state.finish();
13610
+ return;
13611
+ }
13612
+ state.markAuthenticated();
13613
+ return;
13614
+ }
13615
+ const op = typeof record.op === "string" ? record.op : "";
13616
+ const req = record.req;
13617
+ let reply;
13618
+ try {
13619
+ reply = { op: "ack", req, detail: await this.apply(op, record) };
13620
+ } catch (error) {
13621
+ reply = {
13622
+ op: "error",
13623
+ req,
13624
+ detail: error instanceof Error ? error.message : "refused"
13625
+ };
13626
+ }
13627
+ try {
13628
+ socket.write(`${JSON.stringify(reply)}
13629
+ `);
13630
+ } catch {
13631
+ state.finish();
13632
+ }
13633
+ }
13634
+ async apply(op, frame) {
13635
+ if (op === "resume_session") {
13636
+ const sessionId2 = String(frame.session_id ?? "");
13637
+ if (!SESSION_ID.test(sessionId2)) {
13638
+ throw new Error("resume_session needs a session id");
13639
+ }
13640
+ return await this.host.resumeSession(sessionId2);
13641
+ }
13642
+ if (op === "focus_window") {
13643
+ return await this.host.focusWindow();
13644
+ }
13645
+ throw new Error(`unknown viewer op: ${op || "(none)"}`);
13646
+ }
13647
+ }
13648
+ function constantTimeEqual(actual, expected) {
13649
+ const a = Buffer.from(actual, "utf8");
13650
+ const b = Buffer.from(expected, "utf8");
13651
+ if (a.length !== b.length) {
13652
+ node_crypto.timingSafeEqual(b, b);
13653
+ return false;
13654
+ }
13655
+ return node_crypto.timingSafeEqual(a, b);
13656
+ }
13657
+ const VIEWER_RUN_DIRNAME = "run/viewers";
13658
+ const VIEWER_PROTOCOL = 1;
13659
+ const FOCUS_WINDOW_CAPABILITY = "focus-window-v1";
13660
+ const DESKTOP_NOTIFY_CAPABILITY = "desktop-notify-v1";
13661
+ const VIEWER_HEARTBEAT_INTERVAL_MS = 15e3;
13662
+ function viewerRunDir(env = process.env) {
13663
+ const root = env.LOCAL_OPERATOR_CONFIG_DIR || path$1.join(os.homedir(), ".local-operator");
13664
+ return path$1.join(root, VIEWER_RUN_DIRNAME);
13665
+ }
13666
+ function publishViewerRecord(record, dir = viewerRunDir()) {
13667
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
13668
+ fs.chmodSync(dir, 448);
13669
+ const target2 = path$1.join(dir, `${record.pid}.json`);
13670
+ const temporary = path$1.join(
13671
+ dir,
13672
+ `.${record.pid}.${node_crypto.randomBytes(6).toString("hex")}.tmp`
13673
+ );
13674
+ fs.writeFileSync(temporary, JSON.stringify(record), { mode: 384 });
13675
+ fs.renameSync(temporary, target2);
13676
+ return target2;
13677
+ }
13678
+ function unpublishViewerRecord(pid, dir = viewerRunDir()) {
13679
+ try {
13680
+ fs.unlinkSync(path$1.join(dir, `${pid}.json`));
13681
+ } catch {
13682
+ }
13683
+ }
13684
+ class ViewerRecordPublisher {
13685
+ constructor(dir = viewerRunDir(), pid = process.pid) {
13686
+ this.dir = dir;
13687
+ this.record = {
13688
+ pid,
13689
+ surface: "desktop",
13690
+ // 0 until the listener binds. A record advertising port 0 is not
13691
+ // dialable, which is the honest answer during the gap and the reason
13692
+ // the endpoint stamps the real port before anything else.
13693
+ control_port: 0,
13694
+ // The whole authorization story: 32 random bytes, 0600 on disk.
13695
+ control_key: node_crypto.randomBytes(32).toString("hex"),
13696
+ current_session: "",
13697
+ // True even with no window: `resume_session` RECREATES the window and
13698
+ // then navigates (B3), so routing a click here is still the cheapest
13699
+ // landing site on a machine where the app is alive in the dock. What
13700
+ // the record must not claim while no window exists is a CONVERSATION
13701
+ // (`current_session` stays `""`), because that field is what lets a
13702
+ // click skip the switch.
13703
+ can_switch: true,
13704
+ focused_at: 0,
13705
+ protocol: VIEWER_PROTOCOL,
13706
+ started_at: Date.now() / 1e3,
13707
+ heartbeat_at: Date.now() / 1e3,
13708
+ capabilities: [FOCUS_WINDOW_CAPABILITY, DESKTOP_NOTIFY_CAPABILITY]
13709
+ };
13710
+ }
13711
+ record;
13712
+ timer = null;
13713
+ stopped = false;
13714
+ /**
13715
+ * Whether anything has been written yet.
13716
+ *
13717
+ * `setControlPort` lands BEFORE `start` in the app's own ordering (the port
13718
+ * only exists once the listener has bound, and the listener is what the
13719
+ * record advertises), so a publisher that wrote on every setter would put a
13720
+ * port-0 record in the directory for a moment — and a reader that dialled it
13721
+ * would spend its whole dial timeout on nothing. Mutations accumulate; the
13722
+ * first write is `start`'s.
13723
+ */
13724
+ started = false;
13725
+ get controlKey() {
13726
+ return this.record.control_key;
13727
+ }
13728
+ /** Record the port the control listener actually bound, and publish. */
13729
+ setControlPort(port) {
13730
+ this.record.control_port = port;
13731
+ this.publish();
13732
+ }
13733
+ /**
13734
+ * Record which conversation is on screen now.
13735
+ *
13736
+ * An early return on an unchanged value keeps the 15 s beat from being a
13737
+ * disk write at navigation cadence: a re-render that re-asserts the same
13738
+ * session costs nothing.
13739
+ */
13740
+ noteSession(sessionId2) {
13741
+ if (this.record.current_session === sessionId2) return;
13742
+ this.record.current_session = sessionId2;
13743
+ this.publish();
13744
+ }
13745
+ /**
13746
+ * Withdraw the report that THIS conversation is on screen, because the pane
13747
+ * that made it has left it (QA round 2, Q1).
13748
+ *
13749
+ * `current_session` is the record's answer to "which conversation should a
13750
+ * banner click land on", so leaving it naming a conversation the user has
13751
+ * navigated away from is not a stale label — it is a wrong destination the
13752
+ * backend then confirms: `needs_switch` is false for a match, so the click is
13753
+ * answered with a raise, that branch reports success, and no later rung of the
13754
+ * click ladder is tried. The user gets whatever the window happens to show
13755
+ * (the catalogue, Settings) instead of the conversation they were called back
13756
+ * to, and this branch's own presence withdrawal is what makes that click
13757
+ * `focus_policy: "always"` — the case the feature exists for.
13758
+ *
13759
+ * IDENTITY-SAFE, like the notifier's own `releaseWatch` and for the same
13760
+ * reason: the release runs from a renderer cleanup, after the pane has already
13761
+ * changed, so an unconditional clear would wipe a session a DEEPER pane has
13762
+ * since reported. An EMPTY id withdraws nothing — `""` is this record's own
13763
+ * "nothing is on screen" value, so a caller sending it is not naming a
13764
+ * conversation to leave.
13765
+ *
13766
+ * Losing the report is self-healing rather than lossy: a pane that still holds
13767
+ * the conversation re-asserts it on its next heartbeat, which is the same
13768
+ * 15 s beat that wrote it.
13769
+ */
13770
+ releaseSession(sessionId2) {
13771
+ if (!sessionId2 || this.record.current_session !== sessionId2) return;
13772
+ this.record.current_session = "";
13773
+ this.publish();
13774
+ }
13775
+ /** Stamp the focus GAIN edge only. A blur carries no routing information. */
13776
+ noteFocused() {
13777
+ this.record.focused_at = Date.now() / 1e3;
13778
+ this.publish();
13779
+ }
13780
+ /** Begin the beat, and write the record for the first time. Idempotent,
13781
+ * because a window can be recreated. */
13782
+ start() {
13783
+ if (this.timer || this.stopped) return;
13784
+ this.started = true;
13785
+ this.publish();
13786
+ this.timer = setInterval(
13787
+ () => this.publish(),
13788
+ VIEWER_HEARTBEAT_INTERVAL_MS
13789
+ );
13790
+ this.timer.unref?.();
13791
+ }
13792
+ /** Stop beating and remove the record. Safe to call twice. */
13793
+ stop() {
13794
+ this.stopped = true;
13795
+ if (this.timer) {
13796
+ clearInterval(this.timer);
13797
+ this.timer = null;
13798
+ }
13799
+ unpublishViewerRecord(this.record.pid, this.dir);
13800
+ }
13801
+ publish() {
13802
+ if (this.stopped || !this.started) return;
13803
+ this.record.heartbeat_at = Date.now() / 1e3;
13804
+ try {
13805
+ publishViewerRecord(this.record, this.dir);
13806
+ } catch {
13807
+ }
13808
+ }
13809
+ }
12731
13810
  const WINDOW_MODES = ["normal", "headless", "inactive"];
12732
13811
  const WINDOW_MODE_ENV = "LOCAL_OPERATOR_UI_WINDOW_MODE";
12733
13812
  const WINDOW_SIZE_ENV = "LOCAL_OPERATOR_UI_WINDOW_SIZE";
@@ -12852,16 +13931,16 @@ function createApplicationMenu() {
12852
13931
  const isDev = Boolean(process.env.ELECTRON_RENDERER_URL);
12853
13932
  const zoomInHandler = () => {
12854
13933
  if (mainWindow) {
12855
- const webContents = mainWindow.webContents;
12856
- const currentZoom = webContents.getZoomFactor();
12857
- webContents.setZoomFactor(currentZoom + 0.1);
13934
+ const webContents2 = mainWindow.webContents;
13935
+ const currentZoom = webContents2.getZoomFactor();
13936
+ webContents2.setZoomFactor(currentZoom + 0.1);
12858
13937
  }
12859
13938
  };
12860
13939
  const zoomOutHandler = () => {
12861
13940
  if (mainWindow) {
12862
- const webContents = mainWindow.webContents;
12863
- const currentZoom = webContents.getZoomFactor();
12864
- webContents.setZoomFactor(currentZoom - 0.1);
13941
+ const webContents2 = mainWindow.webContents;
13942
+ const currentZoom = webContents2.getZoomFactor();
13943
+ webContents2.setZoomFactor(currentZoom - 0.1);
12865
13944
  }
12866
13945
  };
12867
13946
  const actualSizeHandler = () => {
@@ -12986,7 +14065,7 @@ function createApplicationMenu() {
12986
14065
  const menu = electron.Menu.buildFromTemplate(template);
12987
14066
  electron.Menu.setApplicationMenu(menu);
12988
14067
  }
12989
- function createWindow() {
14068
+ function createWindow(initialSession = null, openCatalogue = false) {
12990
14069
  const mainWindow2 = new electron.BrowserWindow({
12991
14070
  width: windowLaunch.width,
12992
14071
  height: windowLaunch.height,
@@ -13033,16 +14112,33 @@ function createWindow() {
13033
14112
  // than from anything this app controls, so hold the page at full
13034
14113
  // rate explicitly instead of trusting the platform. `normal`
13035
14114
  // keeps Electron's own default.
13036
- backgroundThrottling: windowLaunch.backgroundThrottling
14115
+ backgroundThrottling: windowLaunch.backgroundThrottling,
14116
+ /*
14117
+ * The conversation this window is being created FOR, in the renderer
14118
+ * process's own argv so preload can read it before the first paint (B3).
14119
+ *
14120
+ * Omitted entirely rather than set to an empty value on an ordinary
14121
+ * launch: the preload parses a flag out of argv, and an empty one would
14122
+ * be a value it then has to decide means "no session".
14123
+ *
14124
+ * `openCatalogue` is the THIRD intent (R2-1): a burst digest's click has
14125
+ * no single conversation to name, and "no flag at all" already means
14126
+ * "restore what you had open" — so it needs a flag of its own rather than
14127
+ * sharing that absence.
14128
+ */
14129
+ ...launchArgumentFlags(initialSession, openCatalogue)
13037
14130
  }
13038
14131
  });
14132
+ if (initialSession) holdPresentUntilConversation(mainWindow2, initialSession);
13039
14133
  mainWindow2.webContents.on("console-message", (_event, level, message) => {
13040
14134
  if (level < 2) return;
13041
14135
  const write = level >= 3 ? logger.error : logger.warn;
13042
14136
  write.call(logger, `[renderer] ${message}`, LogFileType.BACKEND);
13043
14137
  });
13044
14138
  mainWindow2.on("ready-to-show", () => {
13045
- presentWindow(mainWindow2, windowLaunch.show);
14139
+ if (!heldForConversation.has(mainWindow2.id)) {
14140
+ presentWindow(mainWindow2, windowLaunch.show);
14141
+ }
13046
14142
  if (windowLaunch.mode === "normal") return;
13047
14143
  setTimeout(() => {
13048
14144
  if (!mainWindow2 || mainWindow2.isDestroyed()) return;
@@ -13118,6 +14214,7 @@ function createWindow() {
13118
14214
  } else {
13119
14215
  mainWindow2.loadFile(path$1.join(__dirname, "../renderer/index.html"));
13120
14216
  }
14217
+ attachBrowserHostToWindow?.(mainWindow2);
13121
14218
  return mainWindow2;
13122
14219
  }
13123
14220
  const backendService = new BackendServiceManager();
@@ -13145,18 +14242,57 @@ if (windowLaunch.mode !== "normal") {
13145
14242
  }
13146
14243
  let mainWindow = null;
13147
14244
  let activeUpdateService = null;
14245
+ const launchTarget = readLaunchTarget(process.argv);
14246
+ const launchSession = launchTarget.kind === "session" ? launchTarget.sessionId : null;
14247
+ const launchCatalogue = launchTarget.kind === "catalogue";
14248
+ function launchArgumentFlags(initialSession, openCatalogue) {
14249
+ if (initialSession) {
14250
+ return { additionalArguments: [`${OPEN_SESSION_FLAG}=${initialSession}`] };
14251
+ }
14252
+ if (openCatalogue) return { additionalArguments: [OPEN_CATALOGUE_FLAG] };
14253
+ return {};
14254
+ }
14255
+ let queuedSession = null;
14256
+ let viewerRecord = null;
14257
+ let viewerEndpoint = null;
14258
+ let openConversationInWindow = null;
14259
+ let attachBrowserHostToWindow = null;
14260
+ const CONVERSATION_PAINT_FALLBACK_MS = 3e3;
14261
+ const heldForConversation = /* @__PURE__ */ new Map();
14262
+ function holdPresentUntilConversation(window, session) {
14263
+ const timer = setTimeout(() => {
14264
+ releaseHeldWindow(window.id);
14265
+ }, CONVERSATION_PAINT_FALLBACK_MS);
14266
+ timer.unref?.();
14267
+ heldForConversation.set(window.id, { session, timer });
14268
+ }
14269
+ function releaseHeldWindow(windowId) {
14270
+ const held = heldForConversation.get(windowId);
14271
+ if (!held) return false;
14272
+ clearTimeout(held.timer);
14273
+ heldForConversation.delete(windowId);
14274
+ const window = electron.BrowserWindow.fromId(windowId);
14275
+ if (window && !window.isDestroyed()) presentWindow(window, windowLaunch.show);
14276
+ return true;
14277
+ }
14278
+ function releaseHeldWindowFor(session) {
14279
+ for (const [windowId, held] of heldForConversation) {
14280
+ if (held.session !== session) continue;
14281
+ releaseHeldWindow(windowId);
14282
+ }
14283
+ }
13148
14284
  const zoomInFromEvent = () => {
13149
14285
  if (mainWindow) {
13150
- const webContents = mainWindow.webContents;
13151
- const currentZoom = webContents.getZoomFactor();
13152
- webContents.setZoomFactor(currentZoom + 0.1);
14286
+ const webContents2 = mainWindow.webContents;
14287
+ const currentZoom = webContents2.getZoomFactor();
14288
+ webContents2.setZoomFactor(currentZoom + 0.1);
13153
14289
  }
13154
14290
  };
13155
14291
  const zoomOutFromEvent = () => {
13156
14292
  if (mainWindow) {
13157
- const webContents = mainWindow.webContents;
13158
- const currentZoom = webContents.getZoomFactor();
13159
- webContents.setZoomFactor(currentZoom - 0.1);
14293
+ const webContents2 = mainWindow.webContents;
14294
+ const currentZoom = webContents2.getZoomFactor();
14295
+ webContents2.setZoomFactor(currentZoom - 0.1);
13160
14296
  }
13161
14297
  };
13162
14298
  const actualSizeFromEvent = () => {
@@ -13170,6 +14306,12 @@ if (!gotTheLock) {
13170
14306
  electron.app.quit();
13171
14307
  } else {
13172
14308
  electron.app.on("second-instance", (_event, commandLine) => {
14309
+ const requested = readOpenSessionArgv(commandLine);
14310
+ if (requested) {
14311
+ if (openConversationInWindow) openConversationInWindow(requested);
14312
+ else queuedSession = requested;
14313
+ return;
14314
+ }
13173
14315
  if (mainWindow) {
13174
14316
  raiseWindow(mainWindow, windowLaunch.show);
13175
14317
  }
@@ -13194,10 +14336,45 @@ electron.app.whenReady().then(async () => {
13194
14336
  const desktopNotifier = new DesktopNotifier(
13195
14337
  () => mainWindow,
13196
14338
  sendDesktop,
13197
- windowLaunch.show
14339
+ windowLaunch.show,
14340
+ {
14341
+ /*
14342
+ * Whether a window we have a heartbeat from is still there.
14343
+ *
14344
+ * `forgetWindow` is called on `closed`, which covers every ordinary
14345
+ * close — but a renderer process that dies takes its `webContents` id
14346
+ * with it and no `closed` event reaches the notifier, and those ids
14347
+ * are not reused. Filtering on liveness is what makes a stale
14348
+ * `{visible: true, focused: true}` entry unable to suppress every
14349
+ * completion for the rest of the run.
14350
+ */
14351
+ windowAlive: (windowId) => {
14352
+ const contents = electron.webContents.fromId(windowId);
14353
+ return Boolean(contents && !contents.isDestroyed());
14354
+ },
14355
+ // A banner click with no window recreates one and navigates it. The
14356
+ // queue lives in `openSessionInWindow` because window creation does.
14357
+ reopen: (sessionId2) => openSessionInWindow(sessionId2),
14358
+ /*
14359
+ * The renderer's report that this conversation is on screen. Two
14360
+ * consumers, and neither is delivery:
14361
+ *
14362
+ * - the viewer record's `current_session`, which is how a later click
14363
+ * routes here and how it skips a redundant switch;
14364
+ * - the held first present of a click-created window, where this report
14365
+ * IS the "conversation applied" milestone `ready-to-show` is not
14366
+ * (B3).
14367
+ */
14368
+ noteDisplayed: (sessionId2) => {
14369
+ viewerRecord?.noteSession(sessionId2);
14370
+ releaseHeldWindowFor(sessionId2);
14371
+ }
14372
+ }
13198
14373
  );
13199
14374
  backendService.onBackendReady(() => {
13200
14375
  void desktopNotifier.refreshNotificationContract();
14376
+ void backendService.getDesktopFeedRelay().start();
14377
+ void publishViewerPresence();
13201
14378
  });
13202
14379
  backendService.observeStream((sessionId2, data) => {
13203
14380
  try {
@@ -13206,13 +14383,85 @@ electron.app.whenReady().then(async () => {
13206
14383
  }
13207
14384
  });
13208
14385
  const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
14386
+ backendService.observeDesktopFeed(
14387
+ (frame) => {
14388
+ if (frame.type === "notification") {
14389
+ desktopNotifier.observe(frame.session_id, frame);
14390
+ return;
14391
+ }
14392
+ const window = mainWindow;
14393
+ if (!window || window.isDestroyed()) return;
14394
+ window.webContents.send("desktop-feed-frame", frame);
14395
+ },
14396
+ (state) => {
14397
+ const window = mainWindow;
14398
+ if (!window || window.isDestroyed()) return;
14399
+ window.webContents.send("desktop-feed-state", state);
14400
+ }
14401
+ );
14402
+ backendService.providePresenceContext(() => desktopNotifier.presence());
14403
+ electron.ipcMain.on("desktop-feed-watch", () => {
14404
+ const window = mainWindow;
14405
+ if (!window || window.isDestroyed()) return;
14406
+ window.webContents.send("desktop-feed-state", {
14407
+ connected: backendService.getDesktopFeedRelay().isConnected
14408
+ });
14409
+ });
14410
+ const record = new ViewerRecordPublisher();
14411
+ const endpoint2 = new ViewerEndpoint(
14412
+ {
14413
+ resumeSession: async (sessionId2) => {
14414
+ openSessionInWindow(sessionId2);
14415
+ return `showing ${sessionId2}`;
14416
+ },
14417
+ focusWindow: async () => {
14418
+ const window = mainWindow;
14419
+ if (window && !window.isDestroyed()) {
14420
+ raiseWindow(window, windowLaunch.show);
14421
+ return "raised the window";
14422
+ }
14423
+ setupMainWindowWithUpdateService();
14424
+ return "opened a window";
14425
+ }
14426
+ },
14427
+ record.controlKey
14428
+ );
14429
+ viewerRecord = record;
14430
+ viewerEndpoint = endpoint2;
14431
+ let viewerPublished = false;
14432
+ async function publishViewerPresence() {
14433
+ if (viewerPublished) return;
14434
+ let features;
14435
+ try {
14436
+ const response = await backendService.requestDesktop({
14437
+ op: "capabilities"
14438
+ });
14439
+ const body = response.body;
14440
+ features = body?.result?.features;
14441
+ } catch {
14442
+ return;
14443
+ }
14444
+ if (!(Number(features?.desktop_presence ?? 0) >= 1)) return;
14445
+ try {
14446
+ const port = await endpoint2.start();
14447
+ record.setControlPort(port);
14448
+ record.start();
14449
+ viewerPublished = true;
14450
+ } catch (error) {
14451
+ logger.warn(
14452
+ `Viewer endpoint did not bind, so no record is published: ${String(error)}`,
14453
+ LogFileType.BACKEND
14454
+ );
14455
+ }
14456
+ }
13209
14457
  registerDesktopIPC(
13210
14458
  () => mainWindow,
13211
14459
  rendererUrl,
13212
14460
  sendDesktop,
13213
14461
  () => backendService.getStreamRelay(),
13214
14462
  (input, bytes) => backendService.requestDesktopMedia(input, bytes),
13215
- desktopNotifier
14463
+ desktopNotifier,
14464
+ (sessionId2) => viewerRecord?.releaseSession(sessionId2)
13216
14465
  );
13217
14466
  electron.ipcMain.handle("open-file", async (_, filePath) => {
13218
14467
  try {
@@ -13522,8 +14771,9 @@ electron.app.whenReady().then(async () => {
13522
14771
  }
13523
14772
  createApplicationMenu();
13524
14773
  let updateService = null;
13525
- function setupMainWindowWithUpdateService() {
13526
- mainWindow = createWindow();
14774
+ function setupMainWindowWithUpdateService(initialSession = null, openCatalogue = false) {
14775
+ mainWindow = createWindow(initialSession, openCatalogue);
14776
+ const windowId = mainWindow.webContents.id;
13527
14777
  if (mainWindow) {
13528
14778
  mainWindow.webContents.on("before-input-event", (event, input) => {
13529
14779
  const isCmdOrCtrl = input.control || input.meta;
@@ -13552,8 +14802,17 @@ electron.app.whenReady().then(async () => {
13552
14802
  updateService.dispose();
13553
14803
  updateService = null;
13554
14804
  }
14805
+ desktopNotifier.forgetWindow(windowId);
14806
+ if (electron.BrowserWindow.getAllWindows().length === 0)
14807
+ viewerRecord?.noteSession("");
14808
+ if (heldForConversation.has(windowId)) {
14809
+ const held = heldForConversation.get(windowId);
14810
+ if (held) clearTimeout(held.timer);
14811
+ heldForConversation.delete(windowId);
14812
+ }
13555
14813
  mainWindow = null;
13556
14814
  });
14815
+ mainWindow.on("focus", () => viewerRecord?.noteFocused());
13557
14816
  updateService.setupIpcHandlers();
13558
14817
  updateService.handlePlatformSpecifics();
13559
14818
  setTimeout(() => {
@@ -13599,12 +14858,28 @@ electron.app.whenReady().then(async () => {
13599
14858
  );
13600
14859
  }
13601
14860
  }
13602
- setupMainWindowWithUpdateService();
13603
- if (mainWindow) await startBrowserHostForWindow(mainWindow);
14861
+ function openSessionInWindow(sessionId2) {
14862
+ const window = mainWindow;
14863
+ if (window && !window.isDestroyed()) {
14864
+ window.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
14865
+ raiseWindow(window, windowLaunch.show);
14866
+ return;
14867
+ }
14868
+ setupMainWindowWithUpdateService(sessionId2, sessionId2 === null);
14869
+ }
14870
+ openConversationInWindow = openSessionInWindow;
14871
+ attachBrowserHostToWindow = (window) => {
14872
+ void startBrowserHostForWindow(window);
14873
+ };
14874
+ setupMainWindowWithUpdateService(launchSession, launchCatalogue);
14875
+ if (queuedSession) {
14876
+ const queued = queuedSession;
14877
+ queuedSession = null;
14878
+ openSessionInWindow(queued);
14879
+ }
13604
14880
  electron.app.on("activate", () => {
13605
14881
  if (electron.BrowserWindow.getAllWindows().length === 0) {
13606
14882
  setupMainWindowWithUpdateService();
13607
- if (mainWindow) void startBrowserHostForWindow(mainWindow);
13608
14883
  }
13609
14884
  });
13610
14885
  }).catch((error) => {
@@ -13640,6 +14915,8 @@ const QUIT_FAILSAFE_MARGIN_MS = 5e3;
13640
14915
  const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
13641
14916
  let backendQuitPending = false;
13642
14917
  electron.app.on("will-quit", (event) => {
14918
+ viewerEndpoint?.close();
14919
+ viewerRecord?.stop();
13643
14920
  if (backendService.isOwnedCleanupComplete()) return;
13644
14921
  event.preventDefault();
13645
14922
  if (backendQuitPending) return;