local-operator-ui 0.30.5 → 0.30.7

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 +663 -42
  2. package/out/preload/index.js +40 -2
  3. package/out/renderer/assets/{_basePickBy-C4KSwmCT.js → _basePickBy-DBYR8O9B.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-Tv4U4nWm.js → _baseUniq-OhMNZhi7.js} +1 -1
  5. package/out/renderer/assets/agent-details-page-BVicz2tX.js +6 -0
  6. package/out/renderer/assets/{agent-hub-page-wHxSp_GW.js → agent-hub-page-CHs73703.js} +2 -2
  7. package/out/renderer/assets/agents-page-rb2Hwnbu.js +2 -0
  8. package/out/renderer/assets/{arc-DzD8wAPt.js → arc-DJQs7XhV.js} +1 -1
  9. package/out/renderer/assets/architectureDiagram-IEHRJDOE-xr0V8iP9.js +36 -0
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-B6WAB17o.js → blockDiagram-JOT3LUYC--j5Arlup.js} +2 -2
  11. package/out/renderer/assets/browser-page-DoK-77aU.js +1 -0
  12. package/out/renderer/assets/browser-webauthn-prompt-BJLi2ZJa.js +1 -0
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-BikcRrmj.js → c4Diagram-VJAJSXHY-Bc6EP_HP.js} +2 -2
  14. package/out/renderer/assets/channel-YCbc1E0Y.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-BK2w2E6b.js → chunk-4BMEZGHF--zNo-yKh.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-L5GYaZTn.js → chunk-A2AXSNBT-CIYOm0W9.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-Cf51xDme.js → chunk-AEK57VVT-W5eNrSxP.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-BNvdRpy_.js → chunk-D6G4REZN-DOaOf7dY.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-Cgmol7e5.js → chunk-RZ5BOZE2-ClT3mZ9I.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-C0dlWEyM.js → chunk-XZIHB7SX-BI7BMUin.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-DqWS7_8g.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DqWS7_8g.js +1 -0
  23. package/out/renderer/assets/clone-Cyp-7Qpl.js +1 -0
  24. package/out/renderer/assets/compact-pagination-BwRh3C3H.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-BWwWtgLx.js +4 -0
  29. package/out/renderer/assets/diagram-SSKATNLV-CWrYQie4.js +43 -0
  30. package/out/renderer/assets/diagram-VNBRO52H-CN2gAwct.js +24 -0
  31. package/out/renderer/assets/erDiagram-Q7BY3M3F-Bu8iPwGW.js +60 -0
  32. package/out/renderer/assets/{flowDiagram-4HSFHLVR-CayOnjMu.js → flowDiagram-4HSFHLVR-CuRpi4Dp.js} +2 -2
  33. package/out/renderer/assets/{ganttDiagram-APWFNJXF-D1H-WlQG.js → ganttDiagram-APWFNJXF-mnURcNut.js} +2 -2
  34. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-BdYeZl9i.js +65 -0
  35. package/out/renderer/assets/{graph-DING6zyF.js → graph-MLvZAGI_.js} +1 -1
  36. package/out/renderer/assets/icon-BQh0NRL5.js +146 -0
  37. package/out/renderer/assets/index-BMCrBJ-B.js +1 -0
  38. package/out/renderer/assets/{index-B8L0Kiwp.js → index-J5rd4Dm5.js} +418 -403
  39. package/out/renderer/assets/index-Q1Oi0B95.js +55 -0
  40. package/out/renderer/assets/index-n0ogWXxx.css +1 -0
  41. package/out/renderer/assets/index-wl3vxwRq.js +7 -0
  42. package/out/renderer/assets/infoDiagram-PH2N3AL5-BmNe_X42.js +2 -0
  43. package/out/renderer/assets/{installer-xkP9rTvG.js → installer-CbTFoEpL.js} +3 -3
  44. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BZUoUByB.js → journeyDiagram-U35MCT3I-oz0SPXm5.js} +2 -2
  45. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-pdihKIeu.js → kanban-definition-NDS4AKOZ-D7XbJu_a.js} +2 -2
  46. package/out/renderer/assets/{layout-BzsmzZSv.js → layout-Cfw0DBLH.js} +1 -1
  47. package/out/renderer/assets/{legacy-agents-page-BQdlRR2j.js → legacy-agents-page-Cpz2ZQCy.js} +3 -3
  48. package/out/renderer/assets/{mermaid.core-BuXyhIZv.js → mermaid.core-XqW4GKnj.js} +19 -19
  49. package/out/renderer/assets/mindmap-definition-ALO5MXBD-CNweV-Fd.js +95 -0
  50. package/out/renderer/assets/page-header-Cc0XdSll.js +1 -0
  51. package/out/renderer/assets/{parseISO-FjLu4rnm.js → parseISO-ew-BKu2O.js} +1 -1
  52. package/out/renderer/assets/{pieDiagram-IB7DONF6-DSJv21tC.js → pieDiagram-IB7DONF6-BPiL68yC.js} +2 -2
  53. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-xXstR1Vf.js → quadrantDiagram-7GDLP6J5-m9iG_U6Q.js} +2 -2
  54. package/out/renderer/assets/{radar-MK3ICKWK-iY9ggxmQ.js → radar-MK3ICKWK-CoKPIP0t.js} +1 -1
  55. package/out/renderer/assets/radient-auth-buttons-GCNNfevt.js +6 -0
  56. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-C3wkWBba.js → requirementDiagram-KVF5MWMF-BpAhKn90.js} +2 -2
  57. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-Kdb3WPgA.js +10 -0
  58. package/out/renderer/assets/{schedules-page-Du06WRO3.js → schedules-page-BOd_LGf1.js} +9 -9
  59. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BAJqm7xV.js → sequenceDiagram-X6HHIX6F-BxaWgsF6.js} +2 -2
  60. package/out/renderer/assets/{settings-page-DTvd2Uj2.js → settings-page-DmM_rBG6.js} +66 -71
  61. package/out/renderer/assets/stateDiagram-DGXRK772-bighRJ2W.js +1 -0
  62. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Dheha3M6.js +1 -0
  63. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BpywGcxh.js → timeline-definition-BDJGKUSR-DyVGXzVc.js} +2 -2
  64. package/out/renderer/assets/use-agent-like-mutation-BQ6GyYOT.js +1 -0
  65. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BLfYh75R.js → xychartDiagram-VJFVF3MP-CKXHo-5e.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-Cr8W7y-H.js +0 -6
  71. package/out/renderer/assets/agents-page-CvzMQcXA.js +0 -2
  72. package/out/renderer/assets/architectureDiagram-IEHRJDOE-Bdg3auIF.js +0 -36
  73. package/out/renderer/assets/browser-page-Cwj3W0B7.js +0 -1
  74. package/out/renderer/assets/browser-webauthn-prompt-HQaxvM7Z.js +0 -1
  75. package/out/renderer/assets/channel-DkQdMXxf.js +0 -1
  76. package/out/renderer/assets/classDiagram-GIVACNV2-CduP7VCg.js +0 -1
  77. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CduP7VCg.js +0 -1
  78. package/out/renderer/assets/clone-ByK_vrzo.js +0 -1
  79. package/out/renderer/assets/compact-pagination-DJ3iOLPw.js +0 -1
  80. package/out/renderer/assets/dagre-OKDRZEBW-CO96SQ8V.js +0 -4
  81. package/out/renderer/assets/diagram-SSKATNLV-CZy8Uth3.js +0 -43
  82. package/out/renderer/assets/diagram-VNBRO52H-xj2birJB.js +0 -24
  83. package/out/renderer/assets/erDiagram-Q7BY3M3F-Da-RbfXv.js +0 -60
  84. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-U4UfZBoD.js +0 -65
  85. package/out/renderer/assets/icon-BfZhrs0k.js +0 -200
  86. package/out/renderer/assets/icon-Bv_GhQh2.css +0 -1
  87. package/out/renderer/assets/index-B8ll31iK.js +0 -7
  88. package/out/renderer/assets/index-UJlKYaYv.js +0 -1
  89. package/out/renderer/assets/infoDiagram-PH2N3AL5-cfKlqrgd.js +0 -2
  90. package/out/renderer/assets/mindmap-definition-ALO5MXBD-DPrERxrY.js +0 -95
  91. package/out/renderer/assets/page-header-Dwwd9Ozp.js +0 -1
  92. package/out/renderer/assets/radient-auth-buttons-Bt3YWj-N.js +0 -6
  93. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-Bl717LGh.js +0 -10
  94. package/out/renderer/assets/stateDiagram-DGXRK772-CakcCsbw.js +0 -1
  95. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-C9clUakK.js +0 -1
  96. package/out/renderer/assets/use-agent-like-mutation-BwgCtL8B.js +0 -1
package/out/main/index.js CHANGED
@@ -10435,7 +10435,8 @@ const CONSOLE_ERROR_CODES = [
10435
10435
  "secure_input_active",
10436
10436
  "console_unavailable",
10437
10437
  "invalid_grid",
10438
- "capture_unavailable"
10438
+ "capture_unavailable",
10439
+ "console_capture_full"
10439
10440
  ];
10440
10441
  const PROTO_VERSION = 1;
10441
10442
  const METHODS$1 = [
@@ -10642,6 +10643,375 @@ class ConsoleError extends BridgeCommandError {
10642
10643
  super(code, message, data);
10643
10644
  }
10644
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
+ }
10645
11015
  const RETAIN_BYTES = 4 * 1024 * 1024;
10646
11016
  const CEILING_BYTES = 16 * 1024 * 1024;
10647
11017
  class ByteLog {
@@ -11144,6 +11514,7 @@ class ConsoleRegistry {
11144
11514
  live: true,
11145
11515
  retain: input.retain,
11146
11516
  secure: false,
11517
+ lastActor: null,
11147
11518
  sizing: input.sizing
11148
11519
  };
11149
11520
  const entry = { record, runtime: null };
@@ -11256,9 +11627,9 @@ const MAX_ROWS = 200;
11256
11627
  const SCROLLBACK_LINES = 5e3;
11257
11628
  const MAX_INPUT_BYTES = 1 << 18;
11258
11629
  const OUTPUT_COALESCE_MS = 16;
11630
+ const LIVE_PENDING_MAX_BYTES = 1 << 20;
11259
11631
  const PERSIST_FLUSH_MS = 250;
11260
11632
  const PERSIST_FLUSH_BYTES = 256 * 1024;
11261
- const MIN_FRAME_BYTES = 2e3;
11262
11633
  const FRAME_RETRY_DELAY_MS = 120;
11263
11634
  const CLOSE_GRACE_MS = 1500;
11264
11635
  const SURFACE_ENV_MARKER = "LOCAL_OPERATOR_CONSOLE_SURFACE";
@@ -11353,6 +11724,8 @@ class ConsoleHost {
11353
11724
  scanner: new Osc133Scanner(),
11354
11725
  subscribers: /* @__PURE__ */ new Set(),
11355
11726
  pending: [],
11727
+ pendingBytes: 0,
11728
+ droppedLiveBytes: 0,
11356
11729
  pendingTimer: null,
11357
11730
  persistTimer: null,
11358
11731
  seq: 0,
@@ -11461,6 +11834,8 @@ class ConsoleHost {
11461
11834
  scanner: new Osc133Scanner(),
11462
11835
  subscribers: /* @__PURE__ */ new Set(),
11463
11836
  pending: [],
11837
+ pendingBytes: 0,
11838
+ droppedLiveBytes: 0,
11464
11839
  pendingTimer: null,
11465
11840
  persistTimer: null,
11466
11841
  seq: 0,
@@ -11498,9 +11873,28 @@ class ConsoleHost {
11498
11873
  exit_code: record.exitCode,
11499
11874
  last_activity: record.lastActivity,
11500
11875
  live: record.live,
11501
- 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
11502
11880
  };
11503
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
+ }
11504
11898
  /** `console_status`. */
11505
11899
  status(surface) {
11506
11900
  const entry = this.registry.require(surface);
@@ -11517,6 +11911,14 @@ class ConsoleHost {
11517
11911
  rows: entry.record.rows,
11518
11912
  live: entry.record.live,
11519
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,
11520
11922
  modes: grid?.modes ?? null,
11521
11923
  cursor: grid?.cursor ?? null,
11522
11924
  last_activity: entry.record.lastActivity,
@@ -11557,19 +11959,16 @@ class ConsoleHost {
11557
11959
  }
11558
11960
  /**
11559
11961
  * `console_screenshot`: the app's own window, cropped to the pane's reported
11560
- * rect, photographed with `capturePage` (design 13.2's first row).
11561
- *
11562
- * WHAT THIS DOES NOT DO, and it is the design's own first cut item (§17.3): the
11563
- * offscreen capture view — a hidden renderer replaying the record — is PR B's
11564
- * (§17.1), so a surface with no displayed pane is refused with
11565
- * `capture_unavailable` rather than answered with a frame that would not be of
11566
- * that surface. The `rendered` field is kept so the gap is visible instead of
11567
- * 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.
11568
11966
  *
11569
11967
  * The assert-and-retry is the spike's measured trap and not a hopeful
11570
11968
  * `setTimeout`: on a hidden window the FIRST `capturePage` came back blank
11571
11969
  * (9,866 B against 27,869 B for the settled frame), and a headless rig — which
11572
- * 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.
11573
11972
  */
11574
11973
  async screenshot(surface) {
11575
11974
  const entry = this.registry.require(surface);
@@ -11577,11 +11976,34 @@ class ConsoleHost {
11577
11976
  this.refuseWhenSecure(entry);
11578
11977
  const report = runtime.rect;
11579
11978
  if (this.displayed !== surface || !report || !report.visible) {
11580
- throw new ConsoleError(
11581
- "capture_unavailable",
11582
- "no pane is displaying this surface, and this app version cannot reconstruct a frame offscreen",
11583
- { rendered: null }
11584
- );
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
+ };
11585
12007
  }
11586
12008
  const window = this.options.window();
11587
12009
  if (!window || window.isDestroyed()) {
@@ -11606,11 +12028,44 @@ class ConsoleHost {
11606
12028
  live: entry.record.live
11607
12029
  };
11608
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
+ */
11609
12050
  async captureWithRetry(window, rect) {
11610
- const first = framePng(await window.webContents.capturePage(rect));
11611
- if (first.length >= MIN_FRAME_BYTES) return first;
11612
- await delay(FRAME_RETRY_DELAY_MS);
11613
- 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
+ );
11614
12069
  }
11615
12070
  /**
11616
12071
  * `console_input`: bytes into the pty.
@@ -11619,7 +12074,7 @@ class ConsoleHost {
11619
12074
  * record's LIVE mode says so** (§10.5), which is why this is a main-side
11620
12075
  * decision: the mode is read at the moment of the call, from the record.
11621
12076
  */
11622
- async input(surface, payload) {
12077
+ async input(surface, payload, actor = "agent") {
11623
12078
  const entry = this.registry.require(surface);
11624
12079
  const runtime = this.requireRuntime(entry);
11625
12080
  this.requireRunning(entry);
@@ -11631,13 +12086,14 @@ class ConsoleHost {
11631
12086
  { accepted: 0, limit: MAX_INPUT_BYTES }
11632
12087
  );
11633
12088
  }
12089
+ this.markActor(entry, actor);
11634
12090
  const bracketed = payload.paste === true && runtime.emulator.grid.modes.bracketedPaste;
11635
12091
  const bytes = bracketed ? wrapBracketedPaste(raw) : raw;
11636
12092
  this.writeToPty(entry, runtime, bytes);
11637
12093
  return { accepted: true, bytes: bytes.length };
11638
12094
  }
11639
12095
  /** `console_keys`: named keys through the one encoder (§10.5). */
11640
- async keys(surface, names) {
12096
+ async keys(surface, names, actor = "agent") {
11641
12097
  const entry = this.registry.require(surface);
11642
12098
  const runtime = this.requireRuntime(entry);
11643
12099
  this.requireRunning(entry);
@@ -11656,6 +12112,7 @@ class ConsoleHost {
11656
12112
  { accepted: [...NAMED_KEYS], key: unknown }
11657
12113
  );
11658
12114
  }
12115
+ this.markActor(entry, actor);
11659
12116
  const sequences = [];
11660
12117
  for (const bytes of encoded) {
11661
12118
  if (!bytes) continue;
@@ -11861,11 +12318,24 @@ class ConsoleHost {
11861
12318
  broadcast(entry, runtime, bytes) {
11862
12319
  if (runtime.subscribers.size === 0) return;
11863
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
+ }
11864
12333
  if (runtime.pendingTimer) return;
11865
12334
  runtime.pendingTimer = setTimeout(() => {
11866
12335
  runtime.pendingTimer = null;
11867
12336
  const pending = runtime.pending;
11868
12337
  runtime.pending = [];
12338
+ runtime.pendingBytes = 0;
11869
12339
  if (pending.length === 0) return;
11870
12340
  const joined = concat(pending);
11871
12341
  runtime.seq += 1;
@@ -11900,6 +12370,11 @@ class ConsoleHost {
11900
12370
  `[console] surface ${redactSurface(surface)} exited ${exitCode}`
11901
12371
  );
11902
12372
  this.options.onChanged?.();
12373
+ this.options.onExit?.({
12374
+ surface,
12375
+ sessionId: entry.record.sessionId,
12376
+ exitCode
12377
+ });
11903
12378
  }
11904
12379
  /** Apply a grid change: clamp, compare, and only then touch the record and the
11905
12380
  * pty — so one clamp-crossing is one `TIOCSWINSZ` and one `SIGWINCH` (§8.2). */
@@ -12293,11 +12768,15 @@ async function dispatchConsole(host, method, params) {
12293
12768
  { surface }
12294
12769
  );
12295
12770
  }
12296
- return host.input(surface, {
12297
- text,
12298
- bytes,
12299
- paste: optionalBoolean(params.paste, "paste")
12300
- });
12771
+ return host.input(
12772
+ surface,
12773
+ {
12774
+ text,
12775
+ bytes,
12776
+ paste: optionalBoolean(params.paste, "paste")
12777
+ },
12778
+ "agent"
12779
+ );
12301
12780
  }
12302
12781
  case "console_keys": {
12303
12782
  const surface = requiredSurface(params.surface);
@@ -12309,7 +12788,7 @@ async function dispatchConsole(host, method, params) {
12309
12788
  {}
12310
12789
  );
12311
12790
  }
12312
- return host.keys(surface, keys);
12791
+ return host.keys(surface, keys, "agent");
12313
12792
  }
12314
12793
  case "console_resize": {
12315
12794
  const surface = requiredSurface(params.surface);
@@ -12719,19 +13198,31 @@ const CONSOLE_IPC_CHANNELS = [
12719
13198
  "console-unsubscribe"
12720
13199
  ];
12721
13200
  function registerConsoleIpc(options) {
12722
- function authorize(event) {
13201
+ function assertTrustedSender(event) {
12723
13202
  const owner = options.window();
12724
13203
  if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
12725
13204
  throw new Error("This window cannot use the console.");
12726
13205
  }
13206
+ }
13207
+ function authorize(event) {
13208
+ assertTrustedSender(event);
12727
13209
  const host = options.host();
12728
13210
  if (!host) throw new Error("The console is not running.");
12729
13211
  return host;
12730
13212
  }
12731
- electron.ipcMain.handle(
12732
- "console-state",
12733
- (event, sessionId2) => authorize(event).state(optionalString(sessionId2, "sessionId"))
12734
- );
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
+ });
12735
13226
  electron.ipcMain.handle("console-create-surface", (event, request) => {
12736
13227
  const host = authorize(event);
12737
13228
  const input = request && typeof request === "object" ? request : {};
@@ -12773,7 +13264,7 @@ function registerConsoleIpc(options) {
12773
13264
  if (typeof text !== "string") {
12774
13265
  throw new Error("Console input must be a string.");
12775
13266
  }
12776
- return host.input(stringOrThrow(surface, "surface"), { text });
13267
+ return host.input(stringOrThrow(surface, "surface"), { text }, "user");
12777
13268
  }
12778
13269
  );
12779
13270
  electron.ipcMain.handle(
@@ -12783,7 +13274,11 @@ function registerConsoleIpc(options) {
12783
13274
  if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
12784
13275
  throw new Error("Console keys must be a list of names.");
12785
13276
  }
12786
- return host.keys(stringOrThrow(surface, "surface"), keys);
13277
+ return host.keys(
13278
+ stringOrThrow(surface, "surface"),
13279
+ keys,
13280
+ "user"
13281
+ );
12787
13282
  }
12788
13283
  );
12789
13284
  electron.ipcMain.handle(
@@ -13060,27 +13555,36 @@ async function startConsoleHost(options) {
13060
13555
  );
13061
13556
  }
13062
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
+ };
13063
13567
  if (!consoleHostEnabled()) {
13064
13568
  log(
13065
13569
  `[console] disabled by ${CONSOLE_HOST_ENV}; no surfaces will be created and the record carries console: false`
13066
13570
  );
13571
+ registerRefusal("disabled", `${CONSOLE_HOST_ENV} is off`);
13067
13572
  return {
13068
13573
  ok: false,
13069
13574
  reason: "disabled",
13070
13575
  detail: `${CONSOLE_HOST_ENV} is off`,
13071
- stop: async () => {
13072
- }
13576
+ stop: async () => unregisterConsoleIpc()
13073
13577
  };
13074
13578
  }
13075
13579
  const load = loadNodePty();
13076
13580
  if (!load.ok) {
13077
13581
  log(`[console] node-pty is unavailable: ${load.reason}`);
13582
+ registerRefusal("pty_unavailable", load.reason);
13078
13583
  return {
13079
13584
  ok: false,
13080
13585
  reason: "pty_unavailable",
13081
13586
  detail: load.reason,
13082
- stop: async () => {
13083
- }
13587
+ stop: async () => unregisterConsoleIpc()
13084
13588
  };
13085
13589
  }
13086
13590
  if (load.helper.healed) {
@@ -13093,19 +13597,43 @@ async function startConsoleHost(options) {
13093
13597
  path$1.join(configDir, "run", "ui-console", "history")
13094
13598
  );
13095
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;
13096
13613
  const host = new ConsoleHost(registry, {
13097
13614
  window: () => options.window,
13098
13615
  log,
13099
13616
  history,
13100
13617
  now: options.now,
13618
+ captureOffscreen: capture ? (request) => capture.capture(request) : void 0,
13101
13619
  spawn: options.spawn ?? ((spawnOptions) => spawnPty(load.module, spawnOptions, (report) => {
13102
13620
  log(
13103
13621
  `[console] restored the exec bit on ${report.path ?? "spawn-helper"}`
13104
13622
  );
13105
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
+ */
13106
13632
  onChanged: () => {
13107
13633
  broadcastConsoleState(options.window);
13108
13634
  },
13635
+ onMark: completion.onMark,
13636
+ onExit: completion.onExit,
13109
13637
  onReveal: (request) => {
13110
13638
  const window = options.window;
13111
13639
  if (window.isDestroyed()) return;
@@ -13133,6 +13661,7 @@ async function startConsoleHost(options) {
13133
13661
  `[console] history GC removed ${collected.removedSurfaces} surface(s) from ${collected.removedSessions.length} session(s) older than the window`
13134
13662
  );
13135
13663
  }
13664
+ hostRef = host;
13136
13665
  registerConsoleIpc({
13137
13666
  window: () => options.window,
13138
13667
  expectedUrl: options.expectedUrl,
@@ -13155,6 +13684,7 @@ async function startConsoleHost(options) {
13155
13684
  },
13156
13685
  stop: async () => {
13157
13686
  unregisterConsoleIpc();
13687
+ capture?.dispose();
13158
13688
  host.dispose();
13159
13689
  log("[console] host stopped");
13160
13690
  }
@@ -20126,6 +20656,18 @@ async function startBrowserHost(options) {
20126
20656
  window: options.window,
20127
20657
  expectedUrl: options.expectedUrl,
20128
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,
20129
20671
  log
20130
20672
  });
20131
20673
  const sessionKey = mintSessionKey();
@@ -20402,6 +20944,9 @@ function createSessionCookieQuitHold(deps) {
20402
20944
  return true;
20403
20945
  };
20404
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}`;
20405
20950
  const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
20406
20951
  const FOREGROUND_RECEIPT_OPS = /* @__PURE__ */ new Set([
20407
20952
  "sessions.seen",
@@ -21518,6 +22063,68 @@ class DesktopNotifier {
21518
22063
  gateBody(kind, title.trim() || fallback, detail)
21519
22064
  );
21520
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
+ }
21521
22128
  /**
21522
22129
  * Legacy completion toast: canned copy, reached only against a backend that
21523
22130
  * does not advertise `notification_contract`. Keyed on `session:epoch:seq`
@@ -21581,7 +22188,7 @@ class DesktopNotifier {
21581
22188
  * still re-words nothing (design 8.4). Gates pass `status = ""` and are
21582
22189
  * unaffected either way.
21583
22190
  */
21584
- show(sessionId2, title, status2, body, isSnippet = false, isFailure = false) {
22191
+ show(sessionId2, title, status2, body, isSnippet = false, isFailure = false, surface = null) {
21585
22192
  const lead = (isSnippet || isFailure) && status2 ? `${status2} — ` : "";
21586
22193
  const notification = new electron.Notification({
21587
22194
  title,
@@ -21594,7 +22201,10 @@ class DesktopNotifier {
21594
22201
  this.host.reopen(sessionId2);
21595
22202
  return;
21596
22203
  }
21597
- target2.webContents.send("desktop-open-conversation", { sessionId: sessionId2 });
22204
+ target2.webContents.send("desktop-open-conversation", {
22205
+ sessionId: sessionId2,
22206
+ ...surface ? { surface } : {}
22207
+ });
21598
22208
  raiseWindow(target2, this.windowRaise, {
21599
22209
  trigger: "banner-click",
21600
22210
  report: this.raiseReport
@@ -28950,6 +29560,7 @@ electron.app.whenReady().then(async () => {
28950
29560
  }
28951
29561
  });
28952
29562
  const rendererUrl = process.env.ELECTRON_RENDERER_URL || node_url.pathToFileURL(path$1.join(__dirname, "../renderer/index.html")).href;
29563
+ const consoleCaptureUrl = consoleCaptureUrlFor(rendererUrl);
28953
29564
  backendService.observeDesktopFeed(
28954
29565
  (frame) => {
28955
29566
  if (frame.type === "notification") {
@@ -29511,6 +30122,16 @@ electron.app.whenReady().then(async () => {
29511
30122
  // the screen, and re-deciding it there would be a second policy beside
29512
30123
  // `window-mode.ts`.
29513
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"),
29514
30135
  log: (message) => logger.info(message, LogFileType.BACKEND)
29515
30136
  });
29516
30137
  } catch (error) {