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