local-operator-ui 0.30.5 → 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.
- package/out/main/index.js +663 -42
- package/out/preload/index.js +40 -2
- package/out/renderer/assets/{_basePickBy-C4KSwmCT.js → _basePickBy-D5b3yDx8.js} +1 -1
- package/out/renderer/assets/{_baseUniq-Tv4U4nWm.js → _baseUniq-D1tkhcv6.js} +1 -1
- package/out/renderer/assets/agent-details-page-DZh__Ocy.js +6 -0
- package/out/renderer/assets/{agent-hub-page-wHxSp_GW.js → agent-hub-page-C_kDnp60.js} +2 -2
- package/out/renderer/assets/agents-page-CnB9CXuW.js +2 -0
- package/out/renderer/assets/{arc-DzD8wAPt.js → arc-CUyHzDaA.js} +1 -1
- package/out/renderer/assets/architectureDiagram-IEHRJDOE-Cvo28P8g.js +36 -0
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-B6WAB17o.js → blockDiagram-JOT3LUYC-CCMUOsjA.js} +2 -2
- package/out/renderer/assets/browser-page-DcAY6eY-.js +1 -0
- package/out/renderer/assets/browser-webauthn-prompt-Bz42nxS6.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BikcRrmj.js → c4Diagram-VJAJSXHY-DqacZ02T.js} +2 -2
- package/out/renderer/assets/channel-CNjcqKwB.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-BK2w2E6b.js → chunk-4BMEZGHF-Vs_Om413.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-L5GYaZTn.js → chunk-A2AXSNBT-nlUPemkk.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-Cf51xDme.js → chunk-AEK57VVT-BvAdLttG.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-BNvdRpy_.js → chunk-D6G4REZN-B47lDjp3.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-Cgmol7e5.js → chunk-RZ5BOZE2-BtJp8S0M.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-C0dlWEyM.js → chunk-XZIHB7SX-9C073wve.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CQVISGwS.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CQVISGwS.js +1 -0
- package/out/renderer/assets/clone-C38ets0J.js +1 -0
- package/out/renderer/assets/compact-pagination-DqAqETGQ.js +1 -0
- package/out/renderer/assets/console-mirror-BVW-_zui.js +44 -0
- package/out/renderer/assets/console-mirror-DOrYoP_4.css +32 -0
- package/out/renderer/assets/consoleCapture-BIeBPj-4.js +1 -0
- package/out/renderer/assets/dagre-OKDRZEBW-aOl_nKn1.js +4 -0
- package/out/renderer/assets/diagram-SSKATNLV-BuelnlQq.js +43 -0
- package/out/renderer/assets/diagram-VNBRO52H-CnvD7FYh.js +24 -0
- package/out/renderer/assets/erDiagram-Q7BY3M3F-DiuVMmfr.js +60 -0
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-CayOnjMu.js → flowDiagram-4HSFHLVR-BKzqYFvF.js} +2 -2
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-D1H-WlQG.js → ganttDiagram-APWFNJXF-BR8sfw6V.js} +2 -2
- package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-DBUM9S0w.js +65 -0
- package/out/renderer/assets/{graph-DING6zyF.js → graph-By8JgBue.js} +1 -1
- package/out/renderer/assets/icon-BQh0NRL5.js +146 -0
- package/out/renderer/assets/{index-B8L0Kiwp.js → index-Bv85BxaP.js} +418 -403
- package/out/renderer/assets/index-Q1Oi0B95.js +55 -0
- package/out/renderer/assets/index-kvxi4VOW.js +7 -0
- package/out/renderer/assets/index-n0ogWXxx.css +1 -0
- package/out/renderer/assets/index-up32o88v.js +1 -0
- package/out/renderer/assets/infoDiagram-PH2N3AL5-BhD7ac-z.js +2 -0
- package/out/renderer/assets/{installer-xkP9rTvG.js → installer-CbTFoEpL.js} +3 -3
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BZUoUByB.js → journeyDiagram-U35MCT3I-Dv2mu_Re.js} +2 -2
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-pdihKIeu.js → kanban-definition-NDS4AKOZ-BqBdPTZf.js} +2 -2
- package/out/renderer/assets/{layout-BzsmzZSv.js → layout-DzuHNJry.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-BQdlRR2j.js → legacy-agents-page-B1YsuHQM.js} +3 -3
- package/out/renderer/assets/{mermaid.core-BuXyhIZv.js → mermaid.core-DymZo21v.js} +19 -19
- package/out/renderer/assets/mindmap-definition-ALO5MXBD-BMN7owbo.js +95 -0
- package/out/renderer/assets/page-header-Cc0XdSll.js +1 -0
- package/out/renderer/assets/{parseISO-FjLu4rnm.js → parseISO-0hNehfLH.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DSJv21tC.js → pieDiagram-IB7DONF6-D0dvCyyn.js} +2 -2
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-xXstR1Vf.js → quadrantDiagram-7GDLP6J5-Bkb3lcZe.js} +2 -2
- package/out/renderer/assets/{radar-MK3ICKWK-iY9ggxmQ.js → radar-MK3ICKWK-2JuG8irb.js} +1 -1
- package/out/renderer/assets/radient-auth-buttons-C2cd-JNV.js +6 -0
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-C3wkWBba.js → requirementDiagram-KVF5MWMF-DE_-TcrO.js} +2 -2
- package/out/renderer/assets/sankeyDiagram-QLVOVGJD-BoMvFI-6.js +10 -0
- package/out/renderer/assets/{schedules-page-Du06WRO3.js → schedules-page-CQpjyrDH.js} +9 -9
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BAJqm7xV.js → sequenceDiagram-X6HHIX6F-BMcSZEau.js} +2 -2
- package/out/renderer/assets/{settings-page-DTvd2Uj2.js → settings-page-CEH0ix19.js} +66 -71
- package/out/renderer/assets/stateDiagram-DGXRK772-B6DVk8hu.js +1 -0
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DTKEGiyu.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BpywGcxh.js → timeline-definition-BDJGKUSR-CkMFtRgw.js} +2 -2
- package/out/renderer/assets/use-agent-like-mutation-C7OkrJmI.js +1 -0
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BLfYh75R.js → xychartDiagram-VJFVF3MP-uj7ccV7g.js} +2 -2
- package/out/renderer/console-capture.html +20 -0
- package/out/renderer/index.html +6 -3
- package/out/renderer/installer.html +4 -3
- package/package.json +6 -3
- package/out/renderer/assets/agent-details-page-Cr8W7y-H.js +0 -6
- package/out/renderer/assets/agents-page-CvzMQcXA.js +0 -2
- package/out/renderer/assets/architectureDiagram-IEHRJDOE-Bdg3auIF.js +0 -36
- package/out/renderer/assets/browser-page-Cwj3W0B7.js +0 -1
- package/out/renderer/assets/browser-webauthn-prompt-HQaxvM7Z.js +0 -1
- package/out/renderer/assets/channel-DkQdMXxf.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CduP7VCg.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CduP7VCg.js +0 -1
- package/out/renderer/assets/clone-ByK_vrzo.js +0 -1
- package/out/renderer/assets/compact-pagination-DJ3iOLPw.js +0 -1
- package/out/renderer/assets/dagre-OKDRZEBW-CO96SQ8V.js +0 -4
- package/out/renderer/assets/diagram-SSKATNLV-CZy8Uth3.js +0 -43
- package/out/renderer/assets/diagram-VNBRO52H-xj2birJB.js +0 -24
- package/out/renderer/assets/erDiagram-Q7BY3M3F-Da-RbfXv.js +0 -60
- package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-U4UfZBoD.js +0 -65
- package/out/renderer/assets/icon-BfZhrs0k.js +0 -200
- package/out/renderer/assets/icon-Bv_GhQh2.css +0 -1
- package/out/renderer/assets/index-B8ll31iK.js +0 -7
- package/out/renderer/assets/index-UJlKYaYv.js +0 -1
- package/out/renderer/assets/infoDiagram-PH2N3AL5-cfKlqrgd.js +0 -2
- package/out/renderer/assets/mindmap-definition-ALO5MXBD-DPrERxrY.js +0 -95
- package/out/renderer/assets/page-header-Dwwd9Ozp.js +0 -1
- package/out/renderer/assets/radient-auth-buttons-Bt3YWj-N.js +0 -6
- package/out/renderer/assets/sankeyDiagram-QLVOVGJD-Bl717LGh.js +0 -10
- package/out/renderer/assets/stateDiagram-DGXRK772-CakcCsbw.js +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-C9clUakK.js +0 -1
- 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
|
-
*
|
|
11563
|
-
*
|
|
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
|
-
|
|
11581
|
-
|
|
11582
|
-
|
|
11583
|
-
|
|
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
|
-
|
|
11611
|
-
|
|
11612
|
-
|
|
11613
|
-
|
|
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(
|
|
12297
|
-
|
|
12298
|
-
|
|
12299
|
-
|
|
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
|
|
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
|
-
|
|
12733
|
-
|
|
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(
|
|
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", {
|
|
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) {
|