local-operator-ui 0.30.4 → 0.30.6

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 (96) hide show
  1. package/out/main/index.js +755 -45
  2. package/out/preload/index.js +40 -2
  3. package/out/renderer/assets/{_basePickBy-BduPy4cz.js → _basePickBy-D5b3yDx8.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BGTlTsEB.js → _baseUniq-D1tkhcv6.js} +1 -1
  5. package/out/renderer/assets/agent-details-page-DZh__Ocy.js +6 -0
  6. package/out/renderer/assets/{agent-hub-page-BDM6zZJa.js → agent-hub-page-C_kDnp60.js} +2 -2
  7. package/out/renderer/assets/agents-page-CnB9CXuW.js +2 -0
  8. package/out/renderer/assets/{arc-YyjsKCXW.js → arc-CUyHzDaA.js} +1 -1
  9. package/out/renderer/assets/architectureDiagram-IEHRJDOE-Cvo28P8g.js +36 -0
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CJJ5DGO8.js → blockDiagram-JOT3LUYC-CCMUOsjA.js} +2 -2
  11. package/out/renderer/assets/browser-page-DcAY6eY-.js +1 -0
  12. package/out/renderer/assets/browser-webauthn-prompt-Bz42nxS6.js +1 -0
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-QmNhEpT8.js → c4Diagram-VJAJSXHY-DqacZ02T.js} +2 -2
  14. package/out/renderer/assets/channel-CNjcqKwB.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-aWh5mqIN.js → chunk-4BMEZGHF-Vs_Om413.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-DK0bOlJH.js → chunk-A2AXSNBT-nlUPemkk.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-F_IShvts.js → chunk-AEK57VVT-BvAdLttG.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-EGDs836r.js → chunk-D6G4REZN-B47lDjp3.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-DIwE4msi.js → chunk-RZ5BOZE2-BtJp8S0M.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-rSpcq2V9.js → chunk-XZIHB7SX-9C073wve.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-CQVISGwS.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CQVISGwS.js +1 -0
  23. package/out/renderer/assets/clone-C38ets0J.js +1 -0
  24. package/out/renderer/assets/compact-pagination-DqAqETGQ.js +1 -0
  25. package/out/renderer/assets/console-mirror-BVW-_zui.js +44 -0
  26. package/out/renderer/assets/console-mirror-DOrYoP_4.css +32 -0
  27. package/out/renderer/assets/consoleCapture-BIeBPj-4.js +1 -0
  28. package/out/renderer/assets/dagre-OKDRZEBW-aOl_nKn1.js +4 -0
  29. package/out/renderer/assets/diagram-SSKATNLV-BuelnlQq.js +43 -0
  30. package/out/renderer/assets/diagram-VNBRO52H-CnvD7FYh.js +24 -0
  31. package/out/renderer/assets/erDiagram-Q7BY3M3F-DiuVMmfr.js +60 -0
  32. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DeQYRbf5.js → flowDiagram-4HSFHLVR-BKzqYFvF.js} +2 -2
  33. package/out/renderer/assets/{ganttDiagram-APWFNJXF-Budm35bY.js → ganttDiagram-APWFNJXF-BR8sfw6V.js} +2 -2
  34. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-DBUM9S0w.js +65 -0
  35. package/out/renderer/assets/{graph-CIgE8XnB.js → graph-By8JgBue.js} +1 -1
  36. package/out/renderer/assets/icon-BQh0NRL5.js +146 -0
  37. package/out/renderer/assets/{index-DHXh-mqr.js → index-Bv85BxaP.js} +424 -404
  38. package/out/renderer/assets/index-Q1Oi0B95.js +55 -0
  39. package/out/renderer/assets/index-kvxi4VOW.js +7 -0
  40. package/out/renderer/assets/index-n0ogWXxx.css +1 -0
  41. package/out/renderer/assets/index-up32o88v.js +1 -0
  42. package/out/renderer/assets/infoDiagram-PH2N3AL5-BhD7ac-z.js +2 -0
  43. package/out/renderer/assets/{installer-Bso3Len8.js → installer-CbTFoEpL.js} +3 -3
  44. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CXxWQvb8.js → journeyDiagram-U35MCT3I-Dv2mu_Re.js} +2 -2
  45. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-D2Qvkjd3.js → kanban-definition-NDS4AKOZ-BqBdPTZf.js} +2 -2
  46. package/out/renderer/assets/{layout-CXFK_RLr.js → layout-DzuHNJry.js} +1 -1
  47. package/out/renderer/assets/{legacy-agents-page-fk9A2KCy.js → legacy-agents-page-B1YsuHQM.js} +3 -3
  48. package/out/renderer/assets/{mermaid.core-BTDPvPUu.js → mermaid.core-DymZo21v.js} +19 -19
  49. package/out/renderer/assets/mindmap-definition-ALO5MXBD-BMN7owbo.js +95 -0
  50. package/out/renderer/assets/page-header-Cc0XdSll.js +1 -0
  51. package/out/renderer/assets/{parseISO-BX-A8H4g.js → parseISO-0hNehfLH.js} +1 -1
  52. package/out/renderer/assets/{pieDiagram-IB7DONF6-CDJ5lTvh.js → pieDiagram-IB7DONF6-D0dvCyyn.js} +2 -2
  53. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-C44iJSRU.js → quadrantDiagram-7GDLP6J5-Bkb3lcZe.js} +2 -2
  54. package/out/renderer/assets/{radar-MK3ICKWK-Bf3GK10p.js → radar-MK3ICKWK-2JuG8irb.js} +1 -1
  55. package/out/renderer/assets/radient-auth-buttons-C2cd-JNV.js +6 -0
  56. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D9L1R9mL.js → requirementDiagram-KVF5MWMF-DE_-TcrO.js} +2 -2
  57. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-BoMvFI-6.js +10 -0
  58. package/out/renderer/assets/{schedules-page-C6Pe5BMb.js → schedules-page-CQpjyrDH.js} +9 -9
  59. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-EETFBORC.js → sequenceDiagram-X6HHIX6F-BMcSZEau.js} +2 -2
  60. package/out/renderer/assets/{settings-page-7BIhvNZH.js → settings-page-CEH0ix19.js} +66 -71
  61. package/out/renderer/assets/stateDiagram-DGXRK772-B6DVk8hu.js +1 -0
  62. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DTKEGiyu.js +1 -0
  63. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BDIU-Gh1.js → timeline-definition-BDJGKUSR-CkMFtRgw.js} +2 -2
  64. package/out/renderer/assets/use-agent-like-mutation-C7OkrJmI.js +1 -0
  65. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-n42jyRsD.js → xychartDiagram-VJFVF3MP-uj7ccV7g.js} +2 -2
  66. package/out/renderer/console-capture.html +20 -0
  67. package/out/renderer/index.html +6 -3
  68. package/out/renderer/installer.html +4 -3
  69. package/package.json +6 -3
  70. package/out/renderer/assets/agent-details-page-DETxiXgI.js +0 -6
  71. package/out/renderer/assets/agents-page-zGvNTMvA.js +0 -2
  72. package/out/renderer/assets/architectureDiagram-IEHRJDOE-DiMikFMG.js +0 -36
  73. package/out/renderer/assets/browser-page-DsnGwh0W.js +0 -1
  74. package/out/renderer/assets/browser-webauthn-prompt-Dbu_n4pi.js +0 -1
  75. package/out/renderer/assets/channel-CbHU_evo.js +0 -1
  76. package/out/renderer/assets/classDiagram-GIVACNV2-BEJqfK5z.js +0 -1
  77. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BEJqfK5z.js +0 -1
  78. package/out/renderer/assets/clone-Dam3TybT.js +0 -1
  79. package/out/renderer/assets/compact-pagination-D7_tjOog.js +0 -1
  80. package/out/renderer/assets/dagre-OKDRZEBW-C8JusHg_.js +0 -4
  81. package/out/renderer/assets/diagram-SSKATNLV-BOhK9ONH.js +0 -43
  82. package/out/renderer/assets/diagram-VNBRO52H-CkKYNFGe.js +0 -24
  83. package/out/renderer/assets/erDiagram-Q7BY3M3F-DWTCogvy.js +0 -60
  84. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-Bjx12O5P.js +0 -65
  85. package/out/renderer/assets/icon-BOQTqj_c.css +0 -1
  86. package/out/renderer/assets/icon-CywPuGM1.js +0 -200
  87. package/out/renderer/assets/index-BsO8-vUG.js +0 -7
  88. package/out/renderer/assets/index-Cf0Eu5SW.js +0 -1
  89. package/out/renderer/assets/infoDiagram-PH2N3AL5-6yn0Mooz.js +0 -2
  90. package/out/renderer/assets/mindmap-definition-ALO5MXBD-C08cGhM5.js +0 -95
  91. package/out/renderer/assets/page-header-1ahBpu1G.js +0 -1
  92. package/out/renderer/assets/radient-auth-buttons-BM5810fR.js +0 -6
  93. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-Cd_MZhfZ.js +0 -10
  94. package/out/renderer/assets/stateDiagram-DGXRK772-sufJokfN.js +0 -1
  95. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DxTzb4QV.js +0 -1
  96. package/out/renderer/assets/use-agent-like-mutation-Bvabdhre.js +0 -1
package/out/main/index.js CHANGED
@@ -255,7 +255,21 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
255
255
  }).strict(),
256
256
  zod.z.object({
257
257
  op: zod.z.literal("sessions.list"),
258
- limit: zod.z.number().int().min(1).max(500).optional()
258
+ limit: zod.z.number().int().min(1).max(500).optional(),
259
+ /*
260
+ * Whether ARCHIVED conversations belong in the answer.
261
+ *
262
+ * ABSENT MEANS `false`, and that default is a compatibility promise rather
263
+ * than a preference: a client that predates archiving sends no such field
264
+ * and must keep the list it always had rather than acquiring rows it has no
265
+ * way to mark, filter or restore. This app sends `true` and partitions the
266
+ * archived rows out of every default list itself (see `fetchSessions` in the
267
+ * canonical sessions store and `visibleRows` in `features/chat/chat-archived`),
268
+ * which is what lets ONE fetch serve both the hidden list and the two
269
+ * questions a list that hides them cannot answer: the open conversation's own
270
+ * archived state, and an unarchive control on a row found by search.
271
+ */
272
+ include_archived: zod.z.boolean().optional()
259
273
  }).strict(),
260
274
  zod.z.object({
261
275
  // Content search over the store: name, id, exact conversation body, and
@@ -271,7 +285,61 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
271
285
  // closed vocabulary stays closed: no caller can send a request whose
272
286
  // answer it would have to discard.
273
287
  q: zod.z.string().min(1).max(SESSION_SEARCH_MAX_CHARS),
274
- limit: zod.z.number().int().min(1).max(500).optional()
288
+ limit: zod.z.number().int().min(1).max(500).optional(),
289
+ /*
290
+ * Whether the SCAN admits archived conversations, absent meaning `false`
291
+ * (the same compatibility promise `sessions.list` states).
292
+ *
293
+ * The sidebar's "Include archived" control is the only writer, and the flag is
294
+ * in the query's cache key on this side because the two answers to one query
295
+ * are different questions: without that, toggling the control off would serve
296
+ * the answer that carries the archived hits and the rows would linger - the
297
+ * stale-row failure `chat-search.test.mjs` pins.
298
+ */
299
+ include_archived: zod.z.boolean().optional()
300
+ }).strict(),
301
+ /*
302
+ * Archive or unarchive ONE conversation: `POST /v1/desktop/sessions/{id}/archive`.
303
+ *
304
+ * DESIRED STATE ON THE WIRE (`archived: true|false`), never a bare toggle, for
305
+ * the reason the pin op states beside its own field: over HTTP a toggle is not
306
+ * idempotent, and a request retried after a dropped response would flip the
307
+ * conversation back. Idempotent by construction here rather than by a receipt:
308
+ * re-archiving an archived conversation writes what is already there, so the op
309
+ * carries no `requestId` (the same at-most-once trade `sessions.warm` makes for
310
+ * a call that already is).
311
+ *
312
+ * Archiving is RECOVERABLE and therefore never confirmed: it hides the
313
+ * conversation from the default lists and from search, and unarchiving restores
314
+ * it. Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
315
+ * a boolean and a 12-char id are not prose.
316
+ */
317
+ zod.z.object({
318
+ op: zod.z.literal("sessions.archive"),
319
+ sessionId,
320
+ archived: zod.z.boolean()
321
+ }).strict(),
322
+ /*
323
+ * Delete ONE conversation PERMANENTLY: `DELETE /v1/desktop/sessions/{id}`.
324
+ *
325
+ * `confirmed: true` is required and is not a receipt: it is the wire's own echo
326
+ * of the user's answer to a danger dialog, so a caller that has not asked cannot
327
+ * express this request at all (a missing field is a 422 here, by name, rather
328
+ * than a delete nobody confirmed). The route refuses the delete of a session
329
+ * that is LIVE with a 409 and a sentence naming the guard - the one refusal this
330
+ * surface renders inside the dialog that asked, which is why the op needs no
331
+ * failure vocabulary of its own.
332
+ *
333
+ * It removes exactly the addressed conversation and NOT its subagent children;
334
+ * a surface that has children to mention says so in its own copy rather than
335
+ * implying a wider blast radius. No `requestId`: the delete is not retried by
336
+ * this client, and a retry of a delete cannot be owed an answer - the second
337
+ * call's honest answer is 404 and the outcome the user asked for either way.
338
+ */
339
+ zod.z.object({
340
+ op: zod.z.literal("sessions.delete"),
341
+ sessionId,
342
+ confirmed: zod.z.literal(true)
275
343
  }).strict(),
276
344
  zod.z.object({
277
345
  op: zod.z.literal("sessions.create"),
@@ -1254,7 +1322,10 @@ function desktopEndpoint(request) {
1254
1322
  };
1255
1323
  case "sessions.list":
1256
1324
  return {
1257
- path: `/v1/desktop/sessions?limit=${request.limit ?? 100}`,
1325
+ // Omitted when false, for the reason `sessions.search`'s own query
1326
+ // states: the pre-flag request is what an older backend must keep
1327
+ // seeing, and `false` is the route's default anyway.
1328
+ path: `/v1/desktop/sessions?limit=${request.limit ?? 100}${request.include_archived ? "&include_archived=true" : ""}`,
1258
1329
  method: "GET"
1259
1330
  };
1260
1331
  case "sessions.search": {
@@ -1262,11 +1333,29 @@ function desktopEndpoint(request) {
1262
1333
  q: request.q,
1263
1334
  limit: String(request.limit ?? SESSION_SEARCH_DEFAULT_LIMIT)
1264
1335
  });
1336
+ if (request.include_archived) query.set("include_archived", "true");
1265
1337
  return {
1266
1338
  path: `/v1/desktop/sessions/search?${query}`,
1267
1339
  method: "GET"
1268
1340
  };
1269
1341
  }
1342
+ case "sessions.archive":
1343
+ return {
1344
+ path: `/v1/desktop/sessions/${request.sessionId}/archive`,
1345
+ method: "POST",
1346
+ // The desired state, never a toggle: see the op's own comment for why a
1347
+ // retried toggle is the bug this shape exists to make impossible.
1348
+ body: { archived: request.archived }
1349
+ };
1350
+ case "sessions.delete":
1351
+ return {
1352
+ path: `/v1/desktop/sessions/${request.sessionId}`,
1353
+ method: "DELETE",
1354
+ // The user's own confirmation, echoed on the wire: the route requires
1355
+ // it, so this op cannot be reached without a dialog having been
1356
+ // answered (see the op's comment).
1357
+ body: { confirmed: true }
1358
+ };
1270
1359
  case "sessions.create":
1271
1360
  return {
1272
1361
  path: "/v1/desktop/sessions",
@@ -10346,7 +10435,8 @@ const CONSOLE_ERROR_CODES = [
10346
10435
  "secure_input_active",
10347
10436
  "console_unavailable",
10348
10437
  "invalid_grid",
10349
- "capture_unavailable"
10438
+ "capture_unavailable",
10439
+ "console_capture_full"
10350
10440
  ];
10351
10441
  const PROTO_VERSION = 1;
10352
10442
  const METHODS$1 = [
@@ -10553,6 +10643,375 @@ class ConsoleError extends BridgeCommandError {
10553
10643
  super(code, message, data);
10554
10644
  }
10555
10645
  }
10646
+ const DEFAULT_IDLE_MS = 3e4;
10647
+ const SETTLE_TIMEOUT_MS = 3e4;
10648
+ const MIN_CONTENT_DISTINCT_COLOURS = 8;
10649
+ const MIN_CONTENT_PIXELS = 32;
10650
+ const RETRY_DELAY_MS = 120;
10651
+ class ConsoleCaptureView {
10652
+ constructor(options) {
10653
+ this.options = options;
10654
+ }
10655
+ window = null;
10656
+ busy = false;
10657
+ reapTimer = null;
10658
+ /** Whether a capture is in flight right now, for the refusal's own message. */
10659
+ get inFlight() {
10660
+ return this.busy;
10661
+ }
10662
+ /**
10663
+ * Whether the view is on a reap timer. Public because it is the class's stated
10664
+ * contract ("nothing lingers for a session that has stopped asking") and the only
10665
+ * way to assert it from outside without waiting out the idle window: code review
10666
+ * round 1's B2 was exactly a refusal that left no timer armed.
10667
+ */
10668
+ get reapArmed() {
10669
+ return this.reapTimer !== null;
10670
+ }
10671
+ async capture(request) {
10672
+ if (this.busy) {
10673
+ throw new ConsoleError(
10674
+ "console_capture_full",
10675
+ "a console capture is already running; one capture view exists at a time",
10676
+ { surface: request.surface }
10677
+ );
10678
+ }
10679
+ this.busy = true;
10680
+ try {
10681
+ const window = await this.ensureWindow();
10682
+ return await this.feed(window.webContents, request);
10683
+ } finally {
10684
+ this.busy = false;
10685
+ this.scheduleReap();
10686
+ }
10687
+ }
10688
+ /** Release the view now. Called on quit, and by the idle timer. */
10689
+ dispose() {
10690
+ if (this.reapTimer) {
10691
+ clearTimeout(this.reapTimer);
10692
+ this.reapTimer = null;
10693
+ }
10694
+ const window = this.window;
10695
+ this.window = null;
10696
+ this.measured = false;
10697
+ if (window && !window.isDestroyed()) window.destroy();
10698
+ }
10699
+ /**
10700
+ * Create the view if it is not up, and hand back its window.
10701
+ *
10702
+ * The size is a placeholder: the page measures its own cell and the window is
10703
+ * resized to the grid before the bytes are fed, because the frame's determinism
10704
+ * depends on the grid being the record's rather than on the window's default.
10705
+ */
10706
+ async ensureWindow() {
10707
+ if (this.reapTimer) {
10708
+ clearTimeout(this.reapTimer);
10709
+ this.reapTimer = null;
10710
+ }
10711
+ if (this.window && !this.window.isDestroyed()) {
10712
+ if (this.measured) return this.window;
10713
+ return this.measure(this.window);
10714
+ }
10715
+ this.measured = false;
10716
+ const window = new electron.BrowserWindow({
10717
+ // `show: false` and `paintWhenInitiallyHidden` (Electron's default) is the
10718
+ // measured-working pair: the page paints so there is a frame to capture,
10719
+ // and nothing is ever presented, focused or raised - which is the whole
10720
+ // requirement the design states for the capture view.
10721
+ show: false,
10722
+ width: 1e3,
10723
+ height: 600,
10724
+ webPreferences: {
10725
+ preload: this.options.preload,
10726
+ contextIsolation: true,
10727
+ nodeIntegration: false,
10728
+ sandbox: false,
10729
+ // The reconstruction is unattended and single-shot: a throttled
10730
+ // background renderer would make the settle wait a matter of luck.
10731
+ backgroundThrottling: false
10732
+ }
10733
+ });
10734
+ this.window = window;
10735
+ window.webContents.on("console-message", (...args) => {
10736
+ const event = args[0];
10737
+ const level = event?.level ?? args[1];
10738
+ const message = (typeof event?.message === "string" ? event.message : void 0) ?? (typeof args[2] === "string" ? args[2] : void 0) ?? "";
10739
+ const severity = String(level ?? "info").toLowerCase();
10740
+ if (severity !== "warning" && severity !== "error" && severity !== "2") {
10741
+ return;
10742
+ }
10743
+ this.options.log(`[console] capture view: ${message}`);
10744
+ });
10745
+ await window.loadURL(this.options.url);
10746
+ return this.measure(window);
10747
+ }
10748
+ /**
10749
+ * THE MEASUREMENT HANDSHAKE, once per window rather than once per attempt.
10750
+ *
10751
+ * The page owns the cell metrics, because it owns the font: main has no way to
10752
+ * measure a face, and the frame is a function of (bytes, grid, theme, font,
10753
+ * renderer), so the grid's pixel size has to come from the same place the
10754
+ * glyphs do. Cached for the life of the view - a second capture in the idle
10755
+ * window pays neither the launch nor the measurement - which is why the cache is
10756
+ * guarded by `measured` rather than by the window's existence.
10757
+ */
10758
+ async measure(window) {
10759
+ const measured = await waitForCapture(
10760
+ window.webContents,
10761
+ "console-capture-measured",
10762
+ () => window.webContents.send("console-capture-measure"),
10763
+ this.options.settleTimeoutMs
10764
+ );
10765
+ this.cellWidth = numberOr(measured.cellWidth, 8);
10766
+ this.cellHeight = numberOr(measured.cellHeight, 16);
10767
+ this.measured = true;
10768
+ return window;
10769
+ }
10770
+ /** Whether the live window has answered the measurement handshake. */
10771
+ measured = false;
10772
+ cellWidth = 8;
10773
+ cellHeight = 16;
10774
+ /**
10775
+ * THE FEED'S IDENTITY, and it is what makes the frame a function of the record (Q-11).
10776
+ *
10777
+ * The capture page remounts the mirror on this number, which is the whole of the
10778
+ * reconstruction's determinism: the page's terminal is built from a record, so a SECOND
10779
+ * feed must start from a fresh terminal rather than write into the first one's. A number
10780
+ * that identifies the ATTEMPT (`1`, then `2` on a retry) is not an identity across
10781
+ * requests — it repeats — so the page never remounted and the new bytes were appended to
10782
+ * the old grid. QA measured what that produced: a 1-line surface captured after an 8-line
10783
+ * one came back as both (212 rows = 192 + 20), a repeat capture appended again (232), and
10784
+ * three different surfaces — one of them with an empty record — returned BYTE-IDENTICAL
10785
+ * frames. Main is the only authority for a surface (§3(a)), and this counter is how the
10786
+ * mirror is told which one it is looking at.
10787
+ */
10788
+ feedSeq = 0;
10789
+ /**
10790
+ * Feed one surface's record, wait for the page to paint, and photograph it.
10791
+ *
10792
+ * Two attempts at most, and the SECOND attempt re-feeds the same bytes rather
10793
+ * than capturing again: a stale first frame is a paint that had not landed, but a
10794
+ * frame that is still blank after a re-feed is a real failure and is reported as
10795
+ * one instead of being retried until it works.
10796
+ */
10797
+ async feed(contents, request) {
10798
+ const window = this.window;
10799
+ if (window && !window.isDestroyed()) {
10800
+ window.setContentSize(
10801
+ Math.max(1, Math.round(request.cols * this.cellWidth)),
10802
+ Math.max(1, Math.round(request.rows * this.cellHeight))
10803
+ );
10804
+ }
10805
+ let last = { bytes: 0, renderer: "unknown" };
10806
+ for (let attempt = 1; attempt <= 2; attempt++) {
10807
+ this.feedSeq += 1;
10808
+ const nonce = this.feedSeq;
10809
+ const settled = await waitForCapture(
10810
+ contents,
10811
+ "console-capture-settled",
10812
+ () => contents.send("console-capture-feed", {
10813
+ nonce,
10814
+ surface: request.surface,
10815
+ cols: request.cols,
10816
+ rows: request.rows,
10817
+ theme: request.theme,
10818
+ bytes_base64: Buffer.from(request.bytes).toString("base64")
10819
+ }),
10820
+ this.options.settleTimeoutMs
10821
+ );
10822
+ const renderer = stringOr(
10823
+ settled.renderer,
10824
+ "unknown"
10825
+ );
10826
+ if (renderer !== "dom") {
10827
+ throw new ConsoleError(
10828
+ "capture_unavailable",
10829
+ `the capture view painted with the ${renderer} renderer; this path requires the DOM renderer`,
10830
+ { rendered: "offscreen" }
10831
+ );
10832
+ }
10833
+ const image2 = await contents.capturePage();
10834
+ const png = image2.toPNG();
10835
+ last = { bytes: png.length, renderer };
10836
+ const recordIsEmpty = request.bytes.length === 0;
10837
+ if (recordIsEmpty || hasTerminalContent(image2.toBitmap())) {
10838
+ this.scheduleReap();
10839
+ this.options.log(
10840
+ `[console] captured surface ${request.surface} offscreen at ${request.cols}x${request.rows} (${renderer} renderer, ${png.length} B, attempt ${attempt})`
10841
+ );
10842
+ return { png, renderer, attempts: attempt };
10843
+ }
10844
+ this.options.log(
10845
+ `[console] offscreen capture of ${request.surface} came back blank (${png.length} B, attempt ${attempt}); re-feeding the record`
10846
+ );
10847
+ await delay$1(RETRY_DELAY_MS);
10848
+ }
10849
+ throw new ConsoleError(
10850
+ "capture_unavailable",
10851
+ `the offscreen capture of this surface produced a blank frame twice (last ${last.bytes} B, renderer ${last.renderer})`,
10852
+ { rendered: "offscreen" }
10853
+ );
10854
+ }
10855
+ /** Reap the view after the idle window, so a session that has stopped capturing
10856
+ * does not keep a renderer process alive for the life of the app. */
10857
+ scheduleReap() {
10858
+ if (this.reapTimer) clearTimeout(this.reapTimer);
10859
+ this.reapTimer = setTimeout(() => {
10860
+ this.reapTimer = null;
10861
+ const window = this.window;
10862
+ this.window = null;
10863
+ if (window && !window.isDestroyed()) window.destroy();
10864
+ }, this.options.idleMs ?? DEFAULT_IDLE_MS);
10865
+ }
10866
+ }
10867
+ const waitForCapture = (contents, channel, onWaiting, timeoutMs = SETTLE_TIMEOUT_MS) => new Promise((resolve, reject) => {
10868
+ const listener = (event, payload) => {
10869
+ if (event.sender !== contents) return;
10870
+ clearTimeout(timer);
10871
+ electron.ipcMain.removeListener(channel, listener);
10872
+ resolve(payload ?? {});
10873
+ };
10874
+ const timer = setTimeout(() => {
10875
+ electron.ipcMain.removeListener(channel, listener);
10876
+ reject(
10877
+ new ConsoleError(
10878
+ "capture_unavailable",
10879
+ `the capture view did not report ${channel} within ${timeoutMs} ms`,
10880
+ { rendered: "offscreen" }
10881
+ )
10882
+ );
10883
+ }, timeoutMs);
10884
+ electron.ipcMain.on(channel, listener);
10885
+ onWaiting();
10886
+ });
10887
+ const hasTerminalContent = (bitmap) => {
10888
+ if (bitmap.length < 8) return false;
10889
+ const first = bitmap.subarray(0, 4);
10890
+ const colours = /* @__PURE__ */ new Set();
10891
+ let nonModal = 0;
10892
+ for (let i = 0; i + 4 <= bitmap.length; i += 4) {
10893
+ colours.add(
10894
+ bitmap[i] << 24 | bitmap[i + 1] << 16 | bitmap[i + 2] << 8 | bitmap[i + 3]
10895
+ );
10896
+ if (bitmap[i] !== first[0] || bitmap[i + 1] !== first[1] || bitmap[i + 2] !== first[2] || bitmap[i + 3] !== first[3]) {
10897
+ nonModal += 1;
10898
+ }
10899
+ if (colours.size >= MIN_CONTENT_DISTINCT_COLOURS && nonModal >= MIN_CONTENT_PIXELS) {
10900
+ return true;
10901
+ }
10902
+ }
10903
+ return false;
10904
+ };
10905
+ const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
10906
+ const stringOr = (value, fallback) => typeof value === "string" && value ? value : fallback;
10907
+ const delay$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10908
+ const SAME_COMPLETION_MS = 3e3;
10909
+ function consoleCompletionHooks(deps) {
10910
+ const now = deps.now ?? (() => Date.now());
10911
+ const exits = /* @__PURE__ */ new Map();
10912
+ const exitKey = (surface) => {
10913
+ const listing = deps.host()?.state();
10914
+ const row = listing?.surfaces?.find((entry) => entry.surface === surface);
10915
+ const epoch = typeof row?.exit_epoch === "number" ? row.exit_epoch : void 0;
10916
+ const count = epoch ?? (exits.get(surface) ?? 0) + 1;
10917
+ exits.set(surface, count);
10918
+ return `console:exit:${surface}:${count}`;
10919
+ };
10920
+ const lastBanner = /* @__PURE__ */ new Map();
10921
+ const announce = (notice, kind) => {
10922
+ const notifier = deps.notifier();
10923
+ if (!notifier) return;
10924
+ lastBanner.set(notice.surface, { at: now(), kind });
10925
+ notifier.consoleCompletion(notice);
10926
+ };
10927
+ const describe2 = (surface) => {
10928
+ const entry = deps.registry.find(surface);
10929
+ if (!entry) return null;
10930
+ return {
10931
+ sessionId: entry.record.sessionId,
10932
+ command: entry.record.command.trim() || surface
10933
+ };
10934
+ };
10935
+ const displayed = (surface) => {
10936
+ const state = deps.host()?.state();
10937
+ return state?.displayed_surface === surface;
10938
+ };
10939
+ const completionFor = (surface, exitCode, key) => {
10940
+ const described = describe2(surface);
10941
+ if (!described) return null;
10942
+ return {
10943
+ sessionId: described.sessionId,
10944
+ surface,
10945
+ surfaceName: described.command,
10946
+ exitCode,
10947
+ displayed: displayed(surface),
10948
+ key
10949
+ };
10950
+ };
10951
+ return {
10952
+ /*
10953
+ * RUNG 2 OF THE LADDER (§12.1): a command finished inside a persistent shell,
10954
+ * carried by the shell's own OSC 133 `D` mark.
10955
+ *
10956
+ * THE MARK IS ALSO WHAT THE BLIP RIDES, and the two are one signal rather
10957
+ * than two: the renderer learns about it by refetching the listing the host
10958
+ * has already updated (`last_mark`), and the banner is raised here only when
10959
+ * the user is not looking at the surface at all. The blip is deliberately NOT
10960
+ * computed here — a mark that the pane cleared when the user looked at it must
10961
+ * not be re-marked by a later fetch, and only the renderer knows when the
10962
+ * user was looking.
10963
+ *
10964
+ * WHAT THIS RUNG CANNOT SEE is named in the design and repeated so it is not
10965
+ * mistaken for a bug: it needs the shell to emit prompt marks (the app ships a
10966
+ * snippet the user may source, and never writes to their dotfiles), the parse
10967
+ * is a byte scan rather than an emulator feature, and a program can spoof the
10968
+ * sequence — whose worst case is one spurious banner.
10969
+ */
10970
+ onMark: (surface, mark) => {
10971
+ if (mark.kind !== "command-finished") return;
10972
+ const notice = completionFor(
10973
+ surface,
10974
+ mark.exitCode,
10975
+ // The mark's own offset in the log is what makes two marks
10976
+ // distinguishable: a shell that runs the same command twice emits two
10977
+ // `D` marks with the same status, at two offsets.
10978
+ `console:mark:${surface}:${mark.offset}`
10979
+ );
10980
+ if (notice) announce(notice, "mark");
10981
+ },
10982
+ /*
10983
+ * RUNG 1: the surface's process exited — the one signal that is always
10984
+ * available, exactly once per surface, carrying the exit code (§12.1).
10985
+ *
10986
+ * THE KEY IS `(surface, exit_epoch)`, the design's own pair, read from the
10987
+ * host's listing (see `exitKey`). It is what the host's own comment expects the
10988
+ * notifier to dedupe on, it survives a relaunch because the retention layer
10989
+ * persists it, and it is what makes two exits two completions even with the
10990
+ * same code.
10991
+ *
10992
+ * A MARK-AND-THEN-EXIT IS ONE COMPLETION. A shell that exits from a prompt
10993
+ * emits its `D` mark and then the process exit, and both are the same thing
10994
+ * happening once; a second banner for it would be the "notify twice" the
10995
+ * claim discipline exists to prevent. The window is small and named rather
10996
+ * than a guess about content.
10997
+ */
10998
+ onExit: ({
10999
+ surface,
11000
+ exitCode
11001
+ }) => {
11002
+ const key = exitKey(surface);
11003
+ const previous = lastBanner.get(surface);
11004
+ if (previous !== void 0 && previous.kind === "mark" && now() - previous.at < SAME_COMPLETION_MS) {
11005
+ deps.log(
11006
+ `[console] surface ${surface} exited ${exitCode} right after its own completion mark; one banner, not two`
11007
+ );
11008
+ return;
11009
+ }
11010
+ const notice = completionFor(surface, exitCode, key);
11011
+ if (notice) announce(notice, "exit");
11012
+ }
11013
+ };
11014
+ }
10556
11015
  const RETAIN_BYTES = 4 * 1024 * 1024;
10557
11016
  const CEILING_BYTES = 16 * 1024 * 1024;
10558
11017
  class ByteLog {
@@ -11055,6 +11514,7 @@ class ConsoleRegistry {
11055
11514
  live: true,
11056
11515
  retain: input.retain,
11057
11516
  secure: false,
11517
+ lastActor: null,
11058
11518
  sizing: input.sizing
11059
11519
  };
11060
11520
  const entry = { record, runtime: null };
@@ -11167,9 +11627,9 @@ const MAX_ROWS = 200;
11167
11627
  const SCROLLBACK_LINES = 5e3;
11168
11628
  const MAX_INPUT_BYTES = 1 << 18;
11169
11629
  const OUTPUT_COALESCE_MS = 16;
11630
+ const LIVE_PENDING_MAX_BYTES = 1 << 20;
11170
11631
  const PERSIST_FLUSH_MS = 250;
11171
11632
  const PERSIST_FLUSH_BYTES = 256 * 1024;
11172
- const MIN_FRAME_BYTES = 2e3;
11173
11633
  const FRAME_RETRY_DELAY_MS = 120;
11174
11634
  const CLOSE_GRACE_MS = 1500;
11175
11635
  const SURFACE_ENV_MARKER = "LOCAL_OPERATOR_CONSOLE_SURFACE";
@@ -11264,6 +11724,8 @@ class ConsoleHost {
11264
11724
  scanner: new Osc133Scanner(),
11265
11725
  subscribers: /* @__PURE__ */ new Set(),
11266
11726
  pending: [],
11727
+ pendingBytes: 0,
11728
+ droppedLiveBytes: 0,
11267
11729
  pendingTimer: null,
11268
11730
  persistTimer: null,
11269
11731
  seq: 0,
@@ -11372,6 +11834,8 @@ class ConsoleHost {
11372
11834
  scanner: new Osc133Scanner(),
11373
11835
  subscribers: /* @__PURE__ */ new Set(),
11374
11836
  pending: [],
11837
+ pendingBytes: 0,
11838
+ droppedLiveBytes: 0,
11375
11839
  pendingTimer: null,
11376
11840
  persistTimer: null,
11377
11841
  seq: 0,
@@ -11409,9 +11873,28 @@ class ConsoleHost {
11409
11873
  exit_code: record.exitCode,
11410
11874
  last_activity: record.lastActivity,
11411
11875
  live: record.live,
11412
- agent_owned: record.origin === "agent"
11876
+ agent_owned: record.origin === "agent",
11877
+ // The co-pilot cell (§13.4): who touched the pty last, which `origin`
11878
+ // deliberately does not answer.
11879
+ last_actor: record.lastActor
11413
11880
  };
11414
11881
  }
11882
+ /**
11883
+ * Record who drove the pty, and push a change ONLY on a transition.
11884
+ *
11885
+ * The push is what the pane's listing refetches on, and a push per keystroke
11886
+ * would be one IPC frame for every character a user types. The mark only has
11887
+ * two values, so a comparison is the whole of the coalescing it needs.
11888
+ *
11889
+ * A REFUSED OR MISSING ACTOR IS THE AGENT PATH, which is the default rather
11890
+ * than a guess: every caller inside main other than the renderer's own IPC is
11891
+ * the RPC dispatch, and the renderer's path names `"user"` explicitly.
11892
+ */
11893
+ markActor(entry, actor) {
11894
+ if (entry.record.lastActor === actor) return;
11895
+ entry.record.lastActor = actor;
11896
+ this.options.onChanged?.();
11897
+ }
11415
11898
  /** `console_status`. */
11416
11899
  status(surface) {
11417
11900
  const entry = this.registry.require(surface);
@@ -11428,6 +11911,14 @@ class ConsoleHost {
11428
11911
  rows: entry.record.rows,
11429
11912
  live: entry.record.live,
11430
11913
  truncated: runtime?.log.truncated ?? false,
11914
+ // The LIVE VIEW's own loss, which is not the log's `truncated`: the ring
11915
+ // drops history it was told to bound, this drops frames a subscriber could
11916
+ // not take. Two numbers because they answer two different questions, and a
11917
+ // caller reasoning about a pane's fidelity needs this one.
11918
+ //
11919
+ // MONOTONIC for the life of the surface: it accumulates and does not reset,
11920
+ // so two readings compare and neither means "how far behind the pane is now".
11921
+ dropped_live_bytes: runtime?.droppedLiveBytes ?? 0,
11431
11922
  modes: grid?.modes ?? null,
11432
11923
  cursor: grid?.cursor ?? null,
11433
11924
  last_activity: entry.record.lastActivity,
@@ -11468,19 +11959,16 @@ class ConsoleHost {
11468
11959
  }
11469
11960
  /**
11470
11961
  * `console_screenshot`: the app's own window, cropped to the pane's reported
11471
- * rect, photographed with `capturePage` (design 13.2's first row).
11472
- *
11473
- * WHAT THIS DOES NOT DO, and it is the design's own first cut item (§17.3): the
11474
- * offscreen capture view — a hidden renderer replaying the record — is PR B's
11475
- * (§17.1), so a surface with no displayed pane is refused with
11476
- * `capture_unavailable` rather than answered with a frame that would not be of
11477
- * that surface. The `rendered` field is kept so the gap is visible instead of
11478
- * implied.
11962
+ * rect, photographed with `capturePage` (design 13.2's first row); or, when no
11963
+ * pane is displaying the surface, the capture view's reconstruction from the
11964
+ * record (the second and third rows), which is what makes the `rendered` field a
11965
+ * distinction rather than a label nothing produces.
11479
11966
  *
11480
11967
  * The assert-and-retry is the spike's measured trap and not a hopeful
11481
11968
  * `setTimeout`: on a hidden window the FIRST `capturePage` came back blank
11482
11969
  * (9,866 B against 27,869 B for the settled frame), and a headless rig — which
11483
- * is what every capture in this repo runs in — is exactly that case.
11970
+ * is what every capture in this repo runs in — is exactly that case. The
11971
+ * offscreen path repeats the same discipline in its own module.
11484
11972
  */
11485
11973
  async screenshot(surface) {
11486
11974
  const entry = this.registry.require(surface);
@@ -11488,11 +11976,34 @@ class ConsoleHost {
11488
11976
  this.refuseWhenSecure(entry);
11489
11977
  const report = runtime.rect;
11490
11978
  if (this.displayed !== surface || !report || !report.visible) {
11491
- throw new ConsoleError(
11492
- "capture_unavailable",
11493
- "no pane is displaying this surface, and this app version cannot reconstruct a frame offscreen",
11494
- { rendered: null }
11495
- );
11979
+ const capture = this.options.captureOffscreen;
11980
+ if (!capture) {
11981
+ throw new ConsoleError(
11982
+ "capture_unavailable",
11983
+ "no pane is displaying this surface, and this host has no capture view to reconstruct a frame with",
11984
+ { rendered: null }
11985
+ );
11986
+ }
11987
+ const grid2 = runtime.emulator.grid;
11988
+ const frame = await capture({
11989
+ surface,
11990
+ cols: grid2.cols,
11991
+ rows: grid2.rows,
11992
+ theme: runtime.theme,
11993
+ bytes: runtime.log.read(0).bytes
11994
+ });
11995
+ return {
11996
+ image_base64: frame.png.toString("base64"),
11997
+ cols: grid2.cols,
11998
+ rows: grid2.rows,
11999
+ rendered: "offscreen",
12000
+ theme: runtime.theme,
12001
+ live: entry.record.live,
12002
+ // Which renderer painted the reconstruction, reported rather than
12003
+ // assumed: the DOM pin is what makes an offscreen frame possible at all.
12004
+ renderer: frame.renderer,
12005
+ attempts: frame.attempts
12006
+ };
11496
12007
  }
11497
12008
  const window = this.options.window();
11498
12009
  if (!window || window.isDestroyed()) {
@@ -11517,11 +12028,44 @@ class ConsoleHost {
11517
12028
  live: entry.record.live
11518
12029
  };
11519
12030
  }
12031
+ /**
12032
+ * A displayed frame, retried once, and REFUSED when it has no content twice (Q-2).
12033
+ *
12034
+ * There is no byte floor any more, here or on the offscreen path, and the two
12035
+ * findings are why: the floor was not a guard (QA round 1's Q-2 — a blank 1600x800
12036
+ * capture is a few kilobytes of one colour, so the cell certifying "a screenshot is
12037
+ * the app's own window, cropped to the pane's rect" was green on a uniform field
12038
+ * while the offscreen frame from the same run had 252 colours) AND it refused real
12039
+ * captures from the other side (QA round 2's Q-7 — a live console that had printed
12040
+ * less than a screenful was answered as blank). `hasTerminalContent` asks the
12041
+ * question both findings reduce to, once, for both paths.
12042
+ *
12043
+ * A FRAME WITH NO CONTENT IS REFUSED RATHER THAN RETURNED, which is the design's own
12044
+ * answer for the offscreen path (§13.2's `capture_unavailable`): one flat field is
12045
+ * not a picture of a terminal, and handing it back would be the class of false
12046
+ * evidence this contract exists to avoid. A real pane is never flat — the DOM
12047
+ * renderer paints antialiased text and a cursor — and the empty-terminal case is the
12048
+ * offscreen path's problem too, so this is not a new ceiling.
12049
+ */
11520
12050
  async captureWithRetry(window, rect) {
11521
- const first = framePng(await window.webContents.capturePage(rect));
11522
- if (first.length >= MIN_FRAME_BYTES) return first;
11523
- await delay(FRAME_RETRY_DELAY_MS);
11524
- return framePng(await window.webContents.capturePage(rect));
12051
+ let bytes = 0;
12052
+ for (let attempt = 1; attempt <= 2; attempt++) {
12053
+ const image2 = await window.webContents.capturePage(rect);
12054
+ const png = framePng(image2);
12055
+ bytes = png.length;
12056
+ if (hasTerminalContent(image2.toBitmap())) {
12057
+ return png;
12058
+ }
12059
+ this.options.log(
12060
+ `[console] displayed capture came back blank (${png.length} B, attempt ${attempt})`
12061
+ );
12062
+ if (attempt === 1) await delay(FRAME_RETRY_DELAY_MS);
12063
+ }
12064
+ throw new ConsoleError(
12065
+ "capture_unavailable",
12066
+ `the displayed capture of this surface produced a blank frame twice (last ${bytes} B)`,
12067
+ { rendered: "displayed" }
12068
+ );
11525
12069
  }
11526
12070
  /**
11527
12071
  * `console_input`: bytes into the pty.
@@ -11530,7 +12074,7 @@ class ConsoleHost {
11530
12074
  * record's LIVE mode says so** (§10.5), which is why this is a main-side
11531
12075
  * decision: the mode is read at the moment of the call, from the record.
11532
12076
  */
11533
- async input(surface, payload) {
12077
+ async input(surface, payload, actor = "agent") {
11534
12078
  const entry = this.registry.require(surface);
11535
12079
  const runtime = this.requireRuntime(entry);
11536
12080
  this.requireRunning(entry);
@@ -11542,13 +12086,14 @@ class ConsoleHost {
11542
12086
  { accepted: 0, limit: MAX_INPUT_BYTES }
11543
12087
  );
11544
12088
  }
12089
+ this.markActor(entry, actor);
11545
12090
  const bracketed = payload.paste === true && runtime.emulator.grid.modes.bracketedPaste;
11546
12091
  const bytes = bracketed ? wrapBracketedPaste(raw) : raw;
11547
12092
  this.writeToPty(entry, runtime, bytes);
11548
12093
  return { accepted: true, bytes: bytes.length };
11549
12094
  }
11550
12095
  /** `console_keys`: named keys through the one encoder (§10.5). */
11551
- async keys(surface, names) {
12096
+ async keys(surface, names, actor = "agent") {
11552
12097
  const entry = this.registry.require(surface);
11553
12098
  const runtime = this.requireRuntime(entry);
11554
12099
  this.requireRunning(entry);
@@ -11567,6 +12112,7 @@ class ConsoleHost {
11567
12112
  { accepted: [...NAMED_KEYS], key: unknown }
11568
12113
  );
11569
12114
  }
12115
+ this.markActor(entry, actor);
11570
12116
  const sequences = [];
11571
12117
  for (const bytes of encoded) {
11572
12118
  if (!bytes) continue;
@@ -11772,11 +12318,24 @@ class ConsoleHost {
11772
12318
  broadcast(entry, runtime, bytes) {
11773
12319
  if (runtime.subscribers.size === 0) return;
11774
12320
  runtime.pending.push(bytes);
12321
+ runtime.pendingBytes += bytes.length;
12322
+ while (runtime.pendingBytes > LIVE_PENDING_MAX_BYTES && runtime.pending.length > 1) {
12323
+ const oldest = runtime.pending.shift();
12324
+ if (!oldest) break;
12325
+ runtime.pendingBytes -= oldest.length;
12326
+ runtime.droppedLiveBytes += oldest.length;
12327
+ if (runtime.droppedLiveBytes === oldest.length) {
12328
+ this.options.log(
12329
+ `[console] surface ${redactSurface(entry.record.surface)} is behind by more than ${LIVE_PENDING_MAX_BYTES} B; the live view drops its oldest frames (the record keeps every byte)`
12330
+ );
12331
+ }
12332
+ }
11775
12333
  if (runtime.pendingTimer) return;
11776
12334
  runtime.pendingTimer = setTimeout(() => {
11777
12335
  runtime.pendingTimer = null;
11778
12336
  const pending = runtime.pending;
11779
12337
  runtime.pending = [];
12338
+ runtime.pendingBytes = 0;
11780
12339
  if (pending.length === 0) return;
11781
12340
  const joined = concat(pending);
11782
12341
  runtime.seq += 1;
@@ -11811,6 +12370,11 @@ class ConsoleHost {
11811
12370
  `[console] surface ${redactSurface(surface)} exited ${exitCode}`
11812
12371
  );
11813
12372
  this.options.onChanged?.();
12373
+ this.options.onExit?.({
12374
+ surface,
12375
+ sessionId: entry.record.sessionId,
12376
+ exitCode
12377
+ });
11814
12378
  }
11815
12379
  /** Apply a grid change: clamp, compare, and only then touch the record and the
11816
12380
  * pty — so one clamp-crossing is one `TIOCSWINSZ` and one `SIGWINCH` (§8.2). */
@@ -12204,11 +12768,15 @@ async function dispatchConsole(host, method, params) {
12204
12768
  { surface }
12205
12769
  );
12206
12770
  }
12207
- return host.input(surface, {
12208
- text,
12209
- bytes,
12210
- paste: optionalBoolean(params.paste, "paste")
12211
- });
12771
+ return host.input(
12772
+ surface,
12773
+ {
12774
+ text,
12775
+ bytes,
12776
+ paste: optionalBoolean(params.paste, "paste")
12777
+ },
12778
+ "agent"
12779
+ );
12212
12780
  }
12213
12781
  case "console_keys": {
12214
12782
  const surface = requiredSurface(params.surface);
@@ -12220,7 +12788,7 @@ async function dispatchConsole(host, method, params) {
12220
12788
  {}
12221
12789
  );
12222
12790
  }
12223
- return host.keys(surface, keys);
12791
+ return host.keys(surface, keys, "agent");
12224
12792
  }
12225
12793
  case "console_resize": {
12226
12794
  const surface = requiredSurface(params.surface);
@@ -12630,19 +13198,31 @@ const CONSOLE_IPC_CHANNELS = [
12630
13198
  "console-unsubscribe"
12631
13199
  ];
12632
13200
  function registerConsoleIpc(options) {
12633
- function authorize(event) {
13201
+ function assertTrustedSender(event) {
12634
13202
  const owner = options.window();
12635
13203
  if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
12636
13204
  throw new Error("This window cannot use the console.");
12637
13205
  }
13206
+ }
13207
+ function authorize(event) {
13208
+ assertTrustedSender(event);
12638
13209
  const host = options.host();
12639
13210
  if (!host) throw new Error("The console is not running.");
12640
13211
  return host;
12641
13212
  }
12642
- electron.ipcMain.handle(
12643
- "console-state",
12644
- (event, sessionId2) => authorize(event).state(optionalString(sessionId2, "sessionId"))
12645
- );
13213
+ electron.ipcMain.handle("console-state", (event, sessionId2) => {
13214
+ assertTrustedSender(event);
13215
+ const host = options.host();
13216
+ if (!host) {
13217
+ return {
13218
+ available: false,
13219
+ surfaces: [],
13220
+ reason: options.unavailable?.reason ?? "unavailable",
13221
+ detail: options.unavailable?.detail ?? null
13222
+ };
13223
+ }
13224
+ return host.state(optionalString(sessionId2, "sessionId"));
13225
+ });
12646
13226
  electron.ipcMain.handle("console-create-surface", (event, request) => {
12647
13227
  const host = authorize(event);
12648
13228
  const input = request && typeof request === "object" ? request : {};
@@ -12684,7 +13264,7 @@ function registerConsoleIpc(options) {
12684
13264
  if (typeof text !== "string") {
12685
13265
  throw new Error("Console input must be a string.");
12686
13266
  }
12687
- return host.input(stringOrThrow(surface, "surface"), { text });
13267
+ return host.input(stringOrThrow(surface, "surface"), { text }, "user");
12688
13268
  }
12689
13269
  );
12690
13270
  electron.ipcMain.handle(
@@ -12694,7 +13274,11 @@ function registerConsoleIpc(options) {
12694
13274
  if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
12695
13275
  throw new Error("Console keys must be a list of names.");
12696
13276
  }
12697
- return host.keys(stringOrThrow(surface, "surface"), keys);
13277
+ return host.keys(
13278
+ stringOrThrow(surface, "surface"),
13279
+ keys,
13280
+ "user"
13281
+ );
12698
13282
  }
12699
13283
  );
12700
13284
  electron.ipcMain.handle(
@@ -12971,27 +13555,36 @@ async function startConsoleHost(options) {
12971
13555
  );
12972
13556
  }
12973
13557
  const { log } = options;
13558
+ const registerRefusal = (reason, detail) => {
13559
+ registerConsoleIpc({
13560
+ window: () => options.window,
13561
+ expectedUrl: options.expectedUrl,
13562
+ host: () => null,
13563
+ unavailable: { reason, detail },
13564
+ log
13565
+ });
13566
+ };
12974
13567
  if (!consoleHostEnabled()) {
12975
13568
  log(
12976
13569
  `[console] disabled by ${CONSOLE_HOST_ENV}; no surfaces will be created and the record carries console: false`
12977
13570
  );
13571
+ registerRefusal("disabled", `${CONSOLE_HOST_ENV} is off`);
12978
13572
  return {
12979
13573
  ok: false,
12980
13574
  reason: "disabled",
12981
13575
  detail: `${CONSOLE_HOST_ENV} is off`,
12982
- stop: async () => {
12983
- }
13576
+ stop: async () => unregisterConsoleIpc()
12984
13577
  };
12985
13578
  }
12986
13579
  const load = loadNodePty();
12987
13580
  if (!load.ok) {
12988
13581
  log(`[console] node-pty is unavailable: ${load.reason}`);
13582
+ registerRefusal("pty_unavailable", load.reason);
12989
13583
  return {
12990
13584
  ok: false,
12991
13585
  reason: "pty_unavailable",
12992
13586
  detail: load.reason,
12993
- stop: async () => {
12994
- }
13587
+ stop: async () => unregisterConsoleIpc()
12995
13588
  };
12996
13589
  }
12997
13590
  if (load.helper.healed) {
@@ -13004,19 +13597,43 @@ async function startConsoleHost(options) {
13004
13597
  path$1.join(configDir, "run", "ui-console", "history")
13005
13598
  );
13006
13599
  const registry = new ConsoleRegistry();
13600
+ let hostRef = null;
13601
+ const completion = consoleCompletionHooks({
13602
+ host: () => hostRef,
13603
+ registry,
13604
+ notifier: () => options.notifier ?? null,
13605
+ log,
13606
+ now: options.now
13607
+ });
13608
+ const capture = options.consoleCaptureUrl && options.preloadPath ? new ConsoleCaptureView({
13609
+ url: options.consoleCaptureUrl,
13610
+ preload: options.preloadPath,
13611
+ log
13612
+ }) : null;
13007
13613
  const host = new ConsoleHost(registry, {
13008
13614
  window: () => options.window,
13009
13615
  log,
13010
13616
  history,
13011
13617
  now: options.now,
13618
+ captureOffscreen: capture ? (request) => capture.capture(request) : void 0,
13012
13619
  spawn: options.spawn ?? ((spawnOptions) => spawnPty(load.module, spawnOptions, (report) => {
13013
13620
  log(
13014
13621
  `[console] restored the exec bit on ${report.path ?? "spawn-helper"}`
13015
13622
  );
13016
13623
  })),
13624
+ /*
13625
+ * ONE PUSHER, and it is A's. `broadcastConsoleState` was already the app's
13626
+ * single "the projection moved" signal when this branch arrived; the
13627
+ * completion seam below therefore does NOT push a second copy of the same
13628
+ * frame. It only raises a banner, which is the half a push cannot express —
13629
+ * and the host calls `onChanged` for a mark and an exit too, so the renderer
13630
+ * still refetches on both (see `ConsoleHostOptions.onExit`).
13631
+ */
13017
13632
  onChanged: () => {
13018
13633
  broadcastConsoleState(options.window);
13019
13634
  },
13635
+ onMark: completion.onMark,
13636
+ onExit: completion.onExit,
13020
13637
  onReveal: (request) => {
13021
13638
  const window = options.window;
13022
13639
  if (window.isDestroyed()) return;
@@ -13044,6 +13661,7 @@ async function startConsoleHost(options) {
13044
13661
  `[console] history GC removed ${collected.removedSurfaces} surface(s) from ${collected.removedSessions.length} session(s) older than the window`
13045
13662
  );
13046
13663
  }
13664
+ hostRef = host;
13047
13665
  registerConsoleIpc({
13048
13666
  window: () => options.window,
13049
13667
  expectedUrl: options.expectedUrl,
@@ -13066,6 +13684,7 @@ async function startConsoleHost(options) {
13066
13684
  },
13067
13685
  stop: async () => {
13068
13686
  unregisterConsoleIpc();
13687
+ capture?.dispose();
13069
13688
  host.dispose();
13070
13689
  log("[console] host stopped");
13071
13690
  }
@@ -20037,6 +20656,18 @@ async function startBrowserHost(options) {
20037
20656
  window: options.window,
20038
20657
  expectedUrl: options.expectedUrl,
20039
20658
  appVersion: options.appVersion,
20659
+ notifier: options.notifier,
20660
+ /*
20661
+ * The capture view's document and preload, forwarded rather than re-derived:
20662
+ * only the app knows where its own bundle lives, and a console host that
20663
+ * guessed would answer a screenshot with a blank frame rather than with its
20664
+ * own typed refusal. WITHOUT THESE TWO the host builds no capture view at all,
20665
+ * which the live proof rig caught as `capture_unavailable` on a surface with
20666
+ * no displayed pane — the exact shape of a seam that is wired in tests and
20667
+ * forgotten in the one call site that matters.
20668
+ */
20669
+ consoleCaptureUrl: options.consoleCaptureUrl,
20670
+ preloadPath: options.preloadPath,
20040
20671
  log
20041
20672
  });
20042
20673
  const sessionKey = mintSessionKey();
@@ -20313,6 +20944,9 @@ function createSessionCookieQuitHold(deps) {
20313
20944
  return true;
20314
20945
  };
20315
20946
  }
20947
+ const INDEX_DOCUMENT_SUFFIX = /\/?index\.html(\?.*)?$/;
20948
+ const CAPTURE_DOCUMENT = "console-capture.html";
20949
+ const consoleCaptureUrlFor = (rendererUrl) => INDEX_DOCUMENT_SUFFIX.test(rendererUrl) ? rendererUrl.replace(INDEX_DOCUMENT_SUFFIX, `/${CAPTURE_DOCUMENT}`) : `${rendererUrl.replace(/\/$/, "")}/${CAPTURE_DOCUMENT}`;
20316
20950
  const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
20317
20951
  const FOREGROUND_RECEIPT_OPS = /* @__PURE__ */ new Set([
20318
20952
  "sessions.seen",
@@ -21429,6 +22063,68 @@ class DesktopNotifier {
21429
22063
  gateBody(kind, title.trim() || fallback, detail)
21430
22064
  );
21431
22065
  }
22066
+ /**
22067
+ * Raise the banner for a console surface's completion (design 12.3, R14).
22068
+ *
22069
+ * THE ONE PUBLIC ENTRY THIS FEATURE ADDS, routing into the same `show(...)`
22070
+ * choke point every other banner uses rather than constructing its own
22071
+ * `Notification`. A second raiser would duplicate the TTL dedupe map, the window
22072
+ * state, the raise policy and the click path - four things this feature has no
22073
+ * opinion about - so it borrows all four and states only its own two facts: what
22074
+ * it says, and which surface a click should select.
22075
+ *
22076
+ * THE ELIGIBILITY LADDER (§12.3), in order, mirroring the backend's so the two
22077
+ * cannot disagree about the same user:
22078
+ *
22079
+ * 1. this run may present nothing (`windowRaise === "never"`, i.e. `headless`)
22080
+ * -> nothing. The same single gate `observe` uses, ahead of any claim, so a
22081
+ * banner that will not be shown never burns a completion claim.
22082
+ * 2. the console pane is displayed ON THIS SURFACE and the window is focused ->
22083
+ * no banner. The user is looking at the thing the banner would announce, and
22084
+ * the in-app blip is already the whole signal there.
22085
+ * 3. otherwise -> the banner.
22086
+ *
22087
+ * `displayed` arrives from the console host rather than from the renderer,
22088
+ * because that is the half of the question this process actually owns (the
22089
+ * renderer reports its own focus through the presence heartbeat, which is a
22090
+ * session-level fact and not a surface-level one).
22091
+ *
22092
+ * ONE BANNER PER COMPLETION, claimed before delivery (§12.3). The key is the
22093
+ * caller's, minted from the completion's own identity - see
22094
+ * `console/completion.ts` for what it is built from and why the design's
22095
+ * `exit_epoch` is not readable here.
22096
+ */
22097
+ consoleCompletion(notice) {
22098
+ if (!this.canNotify) return;
22099
+ if (this.windowRaise === "never") return;
22100
+ if (notice.displayed && this.windowFocused()) return;
22101
+ if (!this.claim(notice.key)) return;
22102
+ this.show(
22103
+ notice.sessionId,
22104
+ notice.surfaceName,
22105
+ "",
22106
+ notice.exitCode === null ? "Console finished" : `Console exited with code ${notice.exitCode}`,
22107
+ false,
22108
+ false,
22109
+ notice.surface
22110
+ );
22111
+ }
22112
+ /**
22113
+ * Whether the app's own window is on screen AND focused.
22114
+ *
22115
+ * The console's suppression rule reads this directly rather than through the
22116
+ * renderer's reported presence, and the difference is the question being asked:
22117
+ * presence answers "which conversation does this app have on screen", which is
22118
+ * session-level, while this one needs to know whether the WINDOW the user is in
22119
+ * is the one displaying the surface. A window that is visible but behind another
22120
+ * app is not a user looking at it.
22121
+ */
22122
+ windowFocused() {
22123
+ const target2 = this.window();
22124
+ return Boolean(
22125
+ target2 && !target2.isDestroyed() && target2.isVisible() && target2.isFocused()
22126
+ );
22127
+ }
21432
22128
  /**
21433
22129
  * Legacy completion toast: canned copy, reached only against a backend that
21434
22130
  * does not advertise `notification_contract`. Keyed on `session:epoch:seq`
@@ -21492,7 +22188,7 @@ class DesktopNotifier {
21492
22188
  * still re-words nothing (design 8.4). Gates pass `status = ""` and are
21493
22189
  * unaffected either way.
21494
22190
  */
21495
- show(sessionId2, title, status2, body, isSnippet = false, isFailure = false) {
22191
+ show(sessionId2, title, status2, body, isSnippet = false, isFailure = false, surface = null) {
21496
22192
  const lead = (isSnippet || isFailure) && status2 ? `${status2} — ` : "";
21497
22193
  const notification = new electron.Notification({
21498
22194
  title,
@@ -21505,7 +22201,10 @@ class DesktopNotifier {
21505
22201
  this.host.reopen(sessionId2);
21506
22202
  return;
21507
22203
  }
21508
- target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
22204
+ target2.webContents.send("desktop-open-conversation", {
22205
+ sessionId: sessionId2,
22206
+ ...surface ? { surface } : {}
22207
+ });
21509
22208
  raiseWindow(target2, this.windowRaise, {
21510
22209
  trigger: "banner-click",
21511
22210
  report: this.raiseReport
@@ -28861,6 +29560,7 @@ electron.app.whenReady().then(async () => {
28861
29560
  }
28862
29561
  });
28863
29562
  const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
29563
+ const consoleCaptureUrl = consoleCaptureUrlFor(rendererUrl);
28864
29564
  backendService.observeDesktopFeed(
28865
29565
  (frame) => {
28866
29566
  if (frame.type === "notification") {
@@ -29422,6 +30122,16 @@ electron.app.whenReady().then(async () => {
29422
30122
  // the screen, and re-deciding it there would be a second policy beside
29423
30123
  // `window-mode.ts`.
29424
30124
  windowShow: windowLaunch.show,
30125
+ // The console's completion banner is raised through this app's ONE
30126
+ // notifier (design 12.3: a second raiser would duplicate the TTL dedupe,
30127
+ // the window state, the raise policy and the click path). It is still the
30128
+ // notifier that decides whether a banner is delivered: this forwards it.
30129
+ notifier: desktopNotifier,
30130
+ // The console's offscreen capture view (design 13.2/13.3): its own
30131
+ // document, and the preload every renderer in this app gets, which is what
30132
+ // lets main feed the reconstruction to it.
30133
+ consoleCaptureUrl,
30134
+ preloadPath: path$1.join(__dirname, "../preload/index.js"),
29425
30135
  log: (message) => logger.info(message, LogFileType.BACKEND)
29426
30136
  });
29427
30137
  } catch (error) {