local-operator-ui 0.29.11 → 0.29.12
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 +2769 -186
- package/out/preload/index.js +113 -0
- package/out/renderer/assets/{_basePickBy-gHcXb1KL.js → _basePickBy-1H7gCAbJ.js} +1 -1
- package/out/renderer/assets/{_baseUniq-Cjg_TxLc.js → _baseUniq-9lNuDyr9.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BLVP0oIP.js → agent-details-page-DljI0xn2.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-DAzUXdCl.js → agent-hub-page-BrDV_UN_.js} +1 -1
- package/out/renderer/assets/{agents-page-BqqWqJRL.js → agents-page-DwxRQf7x.js} +2 -2
- package/out/renderer/assets/{arc-7-q8aDG3.js → arc-Bo4fsGBf.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Ba7yCUVS.js → architectureDiagram-IEHRJDOE-CFazObUZ.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-t6SLXRXN.js → blockDiagram-JOT3LUYC-Bg72Epup.js} +1 -1
- package/out/renderer/assets/browser-page-NToP8sAi.js +1 -0
- package/out/renderer/assets/{browser-webauthn-prompt-BiFtkxJX.js → browser-webauthn-prompt-Bp6ZNrfE.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-C6nxJeUu.js → c4Diagram-VJAJSXHY-BGeUxHWC.js} +1 -1
- package/out/renderer/assets/channel-M9BWKnVc.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-iV0vwEQH.js → chunk-4BMEZGHF-DzG5AABJ.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-Bo_VOUDE.js → chunk-A2AXSNBT-BDE1k_Sd.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-CdHexsQ8.js → chunk-AEK57VVT-Bnqy1REO.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-CeLv_5W7.js → chunk-D6G4REZN-OJwou3dn.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-BnQffRFA.js → chunk-RZ5BOZE2-BBpDVXLp.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-5It_CStB.js → chunk-XZIHB7SX-HO33Rcol.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C2Z-Z1K9.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2Z-Z1K9.js +1 -0
- package/out/renderer/assets/clone-StYPNe3g.js +1 -0
- package/out/renderer/assets/{compact-pagination-BJfBJjZq.js → compact-pagination-YLr5UxEq.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-CPHSSmct.js → dagre-OKDRZEBW-DaKzvkro.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-uoifaHV7.js → diagram-SSKATNLV-BJ85p8MH.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-DiPorn-j.js → diagram-VNBRO52H-BDylL4pB.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-D3_RVsgS.js → erDiagram-Q7BY3M3F-Bmuo6n4L.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-9kpTgY8l.js → flowDiagram-4HSFHLVR-CxswEMEz.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-C1hwJAlh.js → ganttDiagram-APWFNJXF-CXUPGd8I.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BvA7T2vP.js → gitGraphDiagram-7IBYFJ6S-By5CUo46.js} +1 -1
- package/out/renderer/assets/{graph-DpmQwtUv.js → graph-CMfReLAi.js} +1 -1
- package/out/renderer/assets/icon-DGoQRttI.css +1 -0
- package/out/renderer/assets/{index-CxwcyLWF.js → index-COoXd8RY.js} +1 -1
- package/out/renderer/assets/{index-Bw89Hsw-.js → index-CbVSom_R.js} +373 -373
- package/out/renderer/assets/{index-Dl2E40WV.js → index-CpOPM-iW.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-W9OqWzoG.js → infoDiagram-PH2N3AL5-BWOjcWnS.js} +1 -1
- package/out/renderer/assets/{installer-Cf6g2y9a.js → installer-BfhAHY4r.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-Deb5RarE.js → journeyDiagram-U35MCT3I-B-x8rfW8.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BsbQgmLq.js → kanban-definition-NDS4AKOZ-v7_cq85Q.js} +1 -1
- package/out/renderer/assets/{layout-CrJ6Fu9B.js → layout-DD4W9deB.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-egSNYRnK.js → legacy-agents-page-Ciisz9T5.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BYxmKNp-.js → mermaid.core-DMA_yzhO.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C-9egivQ.js → mindmap-definition-ALO5MXBD-BO-1wp5Z.js} +1 -1
- package/out/renderer/assets/{page-header-B9ZaEm1G.js → page-header-kampE4Cn.js} +1 -1
- package/out/renderer/assets/{parseISO-B1ekvrn4.js → parseISO-fIBQPPHT.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-B8qVlKET.js → pieDiagram-IB7DONF6-SvGNVrTF.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DFTB7_6X.js → quadrantDiagram-7GDLP6J5-BQ9fNSZT.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-BBqHNsuC.js → radar-MK3ICKWK-BdQH-zQG.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-D7Z-gfFA.js → radient-auth-buttons-BMIRfsdU.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-nfWLgIi1.js → requirementDiagram-KVF5MWMF-B5b6b0Cc.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-kz3aMr3g.js → sankeyDiagram-QLVOVGJD-DBrSxuPt.js} +1 -1
- package/out/renderer/assets/{schedules-page-9k1r0G5Z.js → schedules-page-BiLy5_Sv.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BlPj6-VP.js → sequenceDiagram-X6HHIX6F-CQ7cg-pc.js} +1 -1
- package/out/renderer/assets/{settings-page-7FNkwi6H.js → settings-page-Bt7hCm86.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-U-C3BZ-1.js → stateDiagram-DGXRK772-C9ED7Xhx.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DNMp6of4.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-Bg_ZEH7m.js → timeline-definition-BDJGKUSR-DKvZ80yG.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-B5uInGPk.js → use-agent-like-mutation-C_Y-w2IJ.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CUjkSuIE.js → xychartDiagram-VJFVF3MP-peQEFG2j.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +12 -4
- package/out/renderer/assets/browser-page-B3ww4TIa.js +0 -1
- package/out/renderer/assets/channel-D05avaQg.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-DjtkcyI4.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-DjtkcyI4.js +0 -1
- package/out/renderer/assets/clone-CkDaYVxE.js +0 -1
- package/out/renderer/assets/icon-C7JJ2uBI.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Co-dAGQa.js +0 -1
- /package/out/renderer/assets/{icon-BeTB53O2.js → icon-2Q4AuVUW.js} +0 -0
package/out/main/index.js
CHANGED
|
@@ -15,6 +15,8 @@ const dotenv = require("dotenv");
|
|
|
15
15
|
const electronLog = require("electron-log");
|
|
16
16
|
const promises = require("node:fs/promises");
|
|
17
17
|
const node_net = require("node:net");
|
|
18
|
+
const headless = require("@xterm/headless");
|
|
19
|
+
const node_module = require("node:module");
|
|
18
20
|
const node_http = require("node:http");
|
|
19
21
|
const https = require("node:https");
|
|
20
22
|
const electronUpdater = require("electron-updater");
|
|
@@ -4572,7 +4574,7 @@ class DesktopFeedRelay {
|
|
|
4572
4574
|
}
|
|
4573
4575
|
}
|
|
4574
4576
|
pause() {
|
|
4575
|
-
const
|
|
4577
|
+
const delay2 = Math.min(
|
|
4576
4578
|
RECONNECT_MAX_MS,
|
|
4577
4579
|
RECONNECT_MIN_MS * 2 ** Math.min(this.attempt, 5)
|
|
4578
4580
|
);
|
|
@@ -4583,7 +4585,7 @@ class DesktopFeedRelay {
|
|
|
4583
4585
|
this.reconnectTimer = null;
|
|
4584
4586
|
this.reconnectResolve = null;
|
|
4585
4587
|
resolve();
|
|
4586
|
-
},
|
|
4588
|
+
}, delay2);
|
|
4587
4589
|
this.reconnectTimer.unref?.();
|
|
4588
4590
|
});
|
|
4589
4591
|
}
|
|
@@ -10168,6 +10170,2697 @@ class BackendInstaller {
|
|
|
10168
10170
|
}
|
|
10169
10171
|
}
|
|
10170
10172
|
}
|
|
10173
|
+
const CONSOLE_METHODS = [
|
|
10174
|
+
"console_list",
|
|
10175
|
+
"console_create",
|
|
10176
|
+
"console_status",
|
|
10177
|
+
"console_read",
|
|
10178
|
+
"console_screenshot",
|
|
10179
|
+
"console_input",
|
|
10180
|
+
"console_keys",
|
|
10181
|
+
"console_resize",
|
|
10182
|
+
"console_secure",
|
|
10183
|
+
"console_close"
|
|
10184
|
+
];
|
|
10185
|
+
new Set(
|
|
10186
|
+
CONSOLE_METHODS
|
|
10187
|
+
);
|
|
10188
|
+
const CONSOLE_ERROR_CODES = [
|
|
10189
|
+
"unsupported_method",
|
|
10190
|
+
"surface_unavailable",
|
|
10191
|
+
"surface_not_owned",
|
|
10192
|
+
"process_exited",
|
|
10193
|
+
"input_queue_full",
|
|
10194
|
+
"unknown_key",
|
|
10195
|
+
"secure_input_active",
|
|
10196
|
+
"console_unavailable",
|
|
10197
|
+
"invalid_grid",
|
|
10198
|
+
"capture_unavailable"
|
|
10199
|
+
];
|
|
10200
|
+
const PROTO_VERSION = 1;
|
|
10201
|
+
const METHODS$1 = [
|
|
10202
|
+
"open",
|
|
10203
|
+
"goto",
|
|
10204
|
+
"read",
|
|
10205
|
+
"snapshot",
|
|
10206
|
+
"screenshot",
|
|
10207
|
+
"click",
|
|
10208
|
+
"type",
|
|
10209
|
+
"close",
|
|
10210
|
+
"status",
|
|
10211
|
+
"tabs",
|
|
10212
|
+
"scroll",
|
|
10213
|
+
"logs",
|
|
10214
|
+
"request_access",
|
|
10215
|
+
"await_access",
|
|
10216
|
+
"cancel_access",
|
|
10217
|
+
"retitle",
|
|
10218
|
+
"owner_recover",
|
|
10219
|
+
"owner_finish",
|
|
10220
|
+
"owner_retain",
|
|
10221
|
+
"owner_release",
|
|
10222
|
+
...CONSOLE_METHODS
|
|
10223
|
+
];
|
|
10224
|
+
const METHOD_SET = new Set(METHODS$1);
|
|
10225
|
+
function isMethod(value) {
|
|
10226
|
+
return typeof value === "string" && METHOD_SET.has(value);
|
|
10227
|
+
}
|
|
10228
|
+
const ERROR_CODES = [
|
|
10229
|
+
"extension_disconnected",
|
|
10230
|
+
"not_paired",
|
|
10231
|
+
"tab_closed",
|
|
10232
|
+
"nav_failed",
|
|
10233
|
+
"nav_timeout",
|
|
10234
|
+
"element_not_found",
|
|
10235
|
+
"origin_denied",
|
|
10236
|
+
"origin_prompt_pending",
|
|
10237
|
+
"origin_not_allowed",
|
|
10238
|
+
"debugger_conflict",
|
|
10239
|
+
"access_queue_full",
|
|
10240
|
+
"tab_limit",
|
|
10241
|
+
"tab_ambiguous",
|
|
10242
|
+
"busy",
|
|
10243
|
+
"proto_mismatch",
|
|
10244
|
+
"owner_refused",
|
|
10245
|
+
"extension_unresponsive",
|
|
10246
|
+
"internal",
|
|
10247
|
+
// The console namespace's own additions (design 10.6). Spread rather than copied
|
|
10248
|
+
// for the same reason METHODS is: this list is what `rpc.ts` narrows a raised
|
|
10249
|
+
// code against, and an unknown code is silently dropped by the session's model —
|
|
10250
|
+
// a command that hangs rather than one that fails.
|
|
10251
|
+
...CONSOLE_ERROR_CODES
|
|
10252
|
+
];
|
|
10253
|
+
const RUN_DIRNAME = path$1.join("run", "ui-browser");
|
|
10254
|
+
const STATE_FILENAME = "host.json";
|
|
10255
|
+
const STATE_DIR_MODE = 448;
|
|
10256
|
+
const STATE_FILE_MODE = 384;
|
|
10257
|
+
const HEARTBEAT_INTERVAL_MS = 15e3;
|
|
10258
|
+
function localOperatorConfigDir() {
|
|
10259
|
+
const configured = process.env.LOCAL_OPERATOR_CONFIG_DIR?.trim();
|
|
10260
|
+
if (configured) return configured;
|
|
10261
|
+
return path$1.join(os.homedir(), ".local-operator");
|
|
10262
|
+
}
|
|
10263
|
+
function stateFilePath(root = localOperatorConfigDir()) {
|
|
10264
|
+
return path$1.join(root, RUN_DIRNAME, STATE_FILENAME);
|
|
10265
|
+
}
|
|
10266
|
+
function mintSessionKey() {
|
|
10267
|
+
return node_crypto.randomBytes(32).toString("base64url");
|
|
10268
|
+
}
|
|
10269
|
+
function keysMatch(presented, expected) {
|
|
10270
|
+
if (typeof presented !== "string") return false;
|
|
10271
|
+
const a = Buffer.from(presented, "utf8");
|
|
10272
|
+
const b = Buffer.from(expected, "utf8");
|
|
10273
|
+
if (a.length !== b.length) return false;
|
|
10274
|
+
return node_crypto.timingSafeEqual(a, b);
|
|
10275
|
+
}
|
|
10276
|
+
class BrowserStateWriter {
|
|
10277
|
+
constructor(path2, facts, options) {
|
|
10278
|
+
this.path = path2;
|
|
10279
|
+
this.facts = facts;
|
|
10280
|
+
this.now = options.now ?? Date.now;
|
|
10281
|
+
this.onError = options.onError ?? (() => {
|
|
10282
|
+
});
|
|
10283
|
+
this.appVersion = options.appVersion;
|
|
10284
|
+
}
|
|
10285
|
+
now;
|
|
10286
|
+
onError;
|
|
10287
|
+
timer = null;
|
|
10288
|
+
written = false;
|
|
10289
|
+
appVersion;
|
|
10290
|
+
/** Publish the record and start the heartbeat. The heartbeat is the
|
|
10291
|
+
* difference between "the app is running and can open a tab on demand" and a
|
|
10292
|
+
* record that names a process that died without cleaning up. */
|
|
10293
|
+
start(port, sessionKey) {
|
|
10294
|
+
const startedAt = this.now() / 1e3;
|
|
10295
|
+
this.startedAt = startedAt;
|
|
10296
|
+
this.port = port;
|
|
10297
|
+
this.sessionKey = sessionKey;
|
|
10298
|
+
this.write();
|
|
10299
|
+
this.timer = setInterval(() => this.write(), HEARTBEAT_INTERVAL_MS);
|
|
10300
|
+
this.timer.unref?.();
|
|
10301
|
+
}
|
|
10302
|
+
startedAt = 0;
|
|
10303
|
+
port = 0;
|
|
10304
|
+
sessionKey = "";
|
|
10305
|
+
/** Re-publish immediately, without waiting for the next heartbeat. Called
|
|
10306
|
+
* when something the record reports has changed (a tab opened or closed). */
|
|
10307
|
+
publishNow() {
|
|
10308
|
+
if (this.port === 0) return;
|
|
10309
|
+
this.write();
|
|
10310
|
+
}
|
|
10311
|
+
write() {
|
|
10312
|
+
try {
|
|
10313
|
+
const facts = this.facts();
|
|
10314
|
+
const state = {
|
|
10315
|
+
pid: process.pid,
|
|
10316
|
+
port: this.port,
|
|
10317
|
+
session_key: this.sessionKey,
|
|
10318
|
+
proto: PROTO_VERSION,
|
|
10319
|
+
host: "ui",
|
|
10320
|
+
app_version: this.appVersion,
|
|
10321
|
+
profile_dir: facts.profileDir,
|
|
10322
|
+
tabs: facts.tabs,
|
|
10323
|
+
agent_tabs: facts.agentTabs,
|
|
10324
|
+
console: facts.console ?? false,
|
|
10325
|
+
console_surfaces: facts.consoleSurfaces ?? 0,
|
|
10326
|
+
console_agent_surfaces: facts.consoleAgentSurfaces ?? 0,
|
|
10327
|
+
heartbeat_at: this.now() / 1e3,
|
|
10328
|
+
started_at: this.startedAt || this.now() / 1e3
|
|
10329
|
+
};
|
|
10330
|
+
fs.mkdirSync(path$1.dirname(this.path), {
|
|
10331
|
+
recursive: true,
|
|
10332
|
+
mode: STATE_DIR_MODE
|
|
10333
|
+
});
|
|
10334
|
+
const staged = `${this.path}.${process.pid}.tmp`;
|
|
10335
|
+
fs.writeFileSync(staged, `${JSON.stringify(state, null, 2)}
|
|
10336
|
+
`, {
|
|
10337
|
+
mode: STATE_FILE_MODE
|
|
10338
|
+
});
|
|
10339
|
+
fs.renameSync(staged, this.path);
|
|
10340
|
+
enforceStateFileModes(this.path);
|
|
10341
|
+
this.written = true;
|
|
10342
|
+
} catch (error) {
|
|
10343
|
+
this.onError(error);
|
|
10344
|
+
}
|
|
10345
|
+
}
|
|
10346
|
+
/** Remove the record, so discovery is honest when the host is gone. Called on
|
|
10347
|
+
* host stop, on quit, and by the Settings toggle that turns the host off. */
|
|
10348
|
+
clear() {
|
|
10349
|
+
if (this.timer) {
|
|
10350
|
+
clearInterval(this.timer);
|
|
10351
|
+
this.timer = null;
|
|
10352
|
+
}
|
|
10353
|
+
if (!this.written) return;
|
|
10354
|
+
try {
|
|
10355
|
+
fs.unlinkSync(this.path);
|
|
10356
|
+
} catch (error) {
|
|
10357
|
+
this.onError(error);
|
|
10358
|
+
}
|
|
10359
|
+
this.written = false;
|
|
10360
|
+
}
|
|
10361
|
+
}
|
|
10362
|
+
function enforceStateFileModes(path2) {
|
|
10363
|
+
try {
|
|
10364
|
+
fs.chmodSync(path$1.dirname(path2), STATE_DIR_MODE);
|
|
10365
|
+
fs.chmodSync(path2, STATE_FILE_MODE);
|
|
10366
|
+
} catch {
|
|
10367
|
+
}
|
|
10368
|
+
}
|
|
10369
|
+
class BridgeCommandError extends Error {
|
|
10370
|
+
constructor(code, message, data = {}) {
|
|
10371
|
+
super(message);
|
|
10372
|
+
this.code = code;
|
|
10373
|
+
this.data = data;
|
|
10374
|
+
}
|
|
10375
|
+
}
|
|
10376
|
+
class ConsoleError extends BridgeCommandError {
|
|
10377
|
+
constructor(code, message, data = {}) {
|
|
10378
|
+
super(code, message, data);
|
|
10379
|
+
}
|
|
10380
|
+
}
|
|
10381
|
+
const RETAIN_BYTES = 4 * 1024 * 1024;
|
|
10382
|
+
const CEILING_BYTES = 16 * 1024 * 1024;
|
|
10383
|
+
class ByteLog {
|
|
10384
|
+
retainBytes;
|
|
10385
|
+
ceilingBytes;
|
|
10386
|
+
/** Oldest first. Chunks are the pty's own reads, so a trim drops many bytes at
|
|
10387
|
+
* once without copying the ones it keeps. */
|
|
10388
|
+
chunks = [];
|
|
10389
|
+
retained = 0;
|
|
10390
|
+
/** Absolute offset just past the last byte ever appended. */
|
|
10391
|
+
end = 0;
|
|
10392
|
+
/** Absolute offset of the oldest retained byte. */
|
|
10393
|
+
start = 0;
|
|
10394
|
+
dropped = 0;
|
|
10395
|
+
constructor(options = {}) {
|
|
10396
|
+
this.retainBytes = options.retainBytes ?? RETAIN_BYTES;
|
|
10397
|
+
this.ceilingBytes = options.ceilingBytes ?? CEILING_BYTES;
|
|
10398
|
+
if (this.ceilingBytes < this.retainBytes) {
|
|
10399
|
+
throw new Error(
|
|
10400
|
+
"a byte log's ceiling must not be below its retained window"
|
|
10401
|
+
);
|
|
10402
|
+
}
|
|
10403
|
+
}
|
|
10404
|
+
/** Absolute offset of the oldest retained byte. */
|
|
10405
|
+
get startOffset() {
|
|
10406
|
+
return this.start;
|
|
10407
|
+
}
|
|
10408
|
+
/** Absolute offset just past the newest byte. */
|
|
10409
|
+
get endOffset() {
|
|
10410
|
+
return this.end;
|
|
10411
|
+
}
|
|
10412
|
+
/** How many bytes are held right now. */
|
|
10413
|
+
get retainedBytes() {
|
|
10414
|
+
return this.retained;
|
|
10415
|
+
}
|
|
10416
|
+
/** Whether any byte has ever been dropped, i.e. the log is not complete. */
|
|
10417
|
+
get truncated() {
|
|
10418
|
+
return this.dropped > 0;
|
|
10419
|
+
}
|
|
10420
|
+
/** How many bytes have been dropped since the surface was created. */
|
|
10421
|
+
get droppedBytes() {
|
|
10422
|
+
return this.dropped;
|
|
10423
|
+
}
|
|
10424
|
+
append(bytes) {
|
|
10425
|
+
if (bytes.length === 0) return;
|
|
10426
|
+
const copy = Uint8Array.from(bytes);
|
|
10427
|
+
this.chunks.push({ offset: this.end, bytes: copy });
|
|
10428
|
+
this.retained += copy.length;
|
|
10429
|
+
this.end += copy.length;
|
|
10430
|
+
this.trim(copy.length);
|
|
10431
|
+
}
|
|
10432
|
+
/**
|
|
10433
|
+
* Read the retained bytes from `from` onward.
|
|
10434
|
+
*
|
|
10435
|
+
* A `from` below `startOffset` is answered with the retained window and
|
|
10436
|
+
* `truncated: true` rather than an error: the caller asked "everything since
|
|
10437
|
+
* N", and the honest answer is "everything since N is gone; here is the rest".
|
|
10438
|
+
*/
|
|
10439
|
+
read(from = 0) {
|
|
10440
|
+
const wanted = Math.max(Math.trunc(from) || 0, 0);
|
|
10441
|
+
const first = Math.max(wanted, this.start);
|
|
10442
|
+
const startIndex = Math.min(first, this.end);
|
|
10443
|
+
const length = Math.max(this.end - startIndex, 0);
|
|
10444
|
+
const out = new Uint8Array(length);
|
|
10445
|
+
let written = 0;
|
|
10446
|
+
for (const chunk of this.chunks) {
|
|
10447
|
+
const chunkEnd = chunk.offset + chunk.bytes.length;
|
|
10448
|
+
if (chunkEnd <= startIndex) continue;
|
|
10449
|
+
const skip = Math.max(startIndex - chunk.offset, 0);
|
|
10450
|
+
const slice = chunk.bytes.subarray(skip);
|
|
10451
|
+
out.set(slice, written);
|
|
10452
|
+
written += slice.length;
|
|
10453
|
+
}
|
|
10454
|
+
return {
|
|
10455
|
+
bytes: out,
|
|
10456
|
+
from: startIndex,
|
|
10457
|
+
to: this.end,
|
|
10458
|
+
truncated: wanted < this.start
|
|
10459
|
+
};
|
|
10460
|
+
}
|
|
10461
|
+
/**
|
|
10462
|
+
* Drop the oldest bytes so the next append cannot cross the ceiling.
|
|
10463
|
+
*
|
|
10464
|
+
* Called with the size of the append that just landed, so a single append
|
|
10465
|
+
* larger than the whole ceiling is handled by the same rule: whatever the size
|
|
10466
|
+
* of the newest bytes, at least the newest `retainBytes` survive, so a reader
|
|
10467
|
+
* always has a window rather than an empty log.
|
|
10468
|
+
*/
|
|
10469
|
+
trim(newestChunkBytes) {
|
|
10470
|
+
if (this.retained <= this.ceilingBytes) return;
|
|
10471
|
+
const keep = Math.max(this.retainBytes, newestChunkBytes);
|
|
10472
|
+
while (this.chunks.length > 1 && this.retained > keep) {
|
|
10473
|
+
const oldest = this.chunks[0];
|
|
10474
|
+
if (this.retained - oldest.bytes.length < keep) break;
|
|
10475
|
+
this.chunks.shift();
|
|
10476
|
+
this.retained -= oldest.bytes.length;
|
|
10477
|
+
this.start = oldest.offset + oldest.bytes.length;
|
|
10478
|
+
this.dropped += oldest.bytes.length;
|
|
10479
|
+
}
|
|
10480
|
+
if (this.chunks.length === 1 && this.retained > this.ceilingBytes) {
|
|
10481
|
+
const only = this.chunks[0];
|
|
10482
|
+
const kept = only.bytes.subarray(only.bytes.length - keep);
|
|
10483
|
+
this.dropped += only.bytes.length - kept.length;
|
|
10484
|
+
this.start = only.offset + (only.bytes.length - kept.length);
|
|
10485
|
+
this.chunks = [{ offset: this.start, bytes: Uint8Array.from(kept) }];
|
|
10486
|
+
this.retained = kept.length;
|
|
10487
|
+
}
|
|
10488
|
+
}
|
|
10489
|
+
}
|
|
10490
|
+
function createXtermEmulator(options) {
|
|
10491
|
+
const terminal = new headless.Terminal({
|
|
10492
|
+
cols: options.cols,
|
|
10493
|
+
rows: options.rows,
|
|
10494
|
+
scrollback: options.scrollback,
|
|
10495
|
+
allowProposedApi: true
|
|
10496
|
+
});
|
|
10497
|
+
let pending = 0;
|
|
10498
|
+
let idle = null;
|
|
10499
|
+
const settleIfIdle = () => {
|
|
10500
|
+
if (pending > 0) return;
|
|
10501
|
+
const resolve = idle;
|
|
10502
|
+
idle = null;
|
|
10503
|
+
resolve?.();
|
|
10504
|
+
};
|
|
10505
|
+
const lineText = (index) => {
|
|
10506
|
+
const line = terminal.buffer.active.getLine(index);
|
|
10507
|
+
return line ? line.translateToString(true) : "";
|
|
10508
|
+
};
|
|
10509
|
+
const bufferLineCount = () => {
|
|
10510
|
+
return terminal.buffer.active.length;
|
|
10511
|
+
};
|
|
10512
|
+
return {
|
|
10513
|
+
write(bytes) {
|
|
10514
|
+
pending += 1;
|
|
10515
|
+
terminal.write(bytes, () => {
|
|
10516
|
+
pending -= 1;
|
|
10517
|
+
settleIfIdle();
|
|
10518
|
+
});
|
|
10519
|
+
},
|
|
10520
|
+
resize(cols, rows) {
|
|
10521
|
+
terminal.resize(cols, rows);
|
|
10522
|
+
},
|
|
10523
|
+
text(mode, window) {
|
|
10524
|
+
const buffer = terminal.buffer.active;
|
|
10525
|
+
const last = bufferLineCount();
|
|
10526
|
+
const first = mode === "viewport" ? Math.min(buffer.viewportY, last) : truncateToInt(window?.start ?? 0, 0, last);
|
|
10527
|
+
const wanted = mode === "viewport" ? terminal.rows : window?.count === void 0 ? Math.max(buffer.viewportY - first, 0) : truncateToInt(window.count, 0, last - first);
|
|
10528
|
+
const end = Math.min(first + wanted, last);
|
|
10529
|
+
const lines = [];
|
|
10530
|
+
for (let index = first; index < end; index += 1) {
|
|
10531
|
+
lines.push(lineText(index));
|
|
10532
|
+
}
|
|
10533
|
+
return lines.join("\n");
|
|
10534
|
+
},
|
|
10535
|
+
get grid() {
|
|
10536
|
+
const buffer = terminal.buffer.active;
|
|
10537
|
+
return {
|
|
10538
|
+
cols: terminal.cols,
|
|
10539
|
+
rows: terminal.rows,
|
|
10540
|
+
viewportTop: buffer.viewportY,
|
|
10541
|
+
lineCount: bufferLineCount(),
|
|
10542
|
+
cursor: { x: buffer.cursorX, y: buffer.cursorY },
|
|
10543
|
+
modes: {
|
|
10544
|
+
bracketedPaste: terminal.modes.bracketedPasteMode,
|
|
10545
|
+
applicationCursorKeys: terminal.modes.applicationCursorKeysMode,
|
|
10546
|
+
mouseTracking: terminal.modes.mouseTrackingMode
|
|
10547
|
+
}
|
|
10548
|
+
};
|
|
10549
|
+
},
|
|
10550
|
+
whenIdle() {
|
|
10551
|
+
if (pending === 0) return Promise.resolve();
|
|
10552
|
+
return new Promise((resolve) => {
|
|
10553
|
+
idle = resolve;
|
|
10554
|
+
});
|
|
10555
|
+
},
|
|
10556
|
+
dispose() {
|
|
10557
|
+
terminal.dispose();
|
|
10558
|
+
}
|
|
10559
|
+
};
|
|
10560
|
+
}
|
|
10561
|
+
function truncateToInt(value, min, max) {
|
|
10562
|
+
if (!Number.isFinite(value)) return min;
|
|
10563
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
10564
|
+
}
|
|
10565
|
+
const NAMED_KEYS = [
|
|
10566
|
+
"enter",
|
|
10567
|
+
"return",
|
|
10568
|
+
"tab",
|
|
10569
|
+
"shift+tab",
|
|
10570
|
+
"backspace",
|
|
10571
|
+
"escape",
|
|
10572
|
+
"space",
|
|
10573
|
+
"up",
|
|
10574
|
+
"down",
|
|
10575
|
+
"left",
|
|
10576
|
+
"right",
|
|
10577
|
+
"home",
|
|
10578
|
+
"end",
|
|
10579
|
+
"pageup",
|
|
10580
|
+
"pagedown",
|
|
10581
|
+
"insert",
|
|
10582
|
+
"delete",
|
|
10583
|
+
"f1",
|
|
10584
|
+
"f2",
|
|
10585
|
+
"f3",
|
|
10586
|
+
"f4",
|
|
10587
|
+
"f5",
|
|
10588
|
+
"f6",
|
|
10589
|
+
"f7",
|
|
10590
|
+
"f8",
|
|
10591
|
+
"f9",
|
|
10592
|
+
"f10",
|
|
10593
|
+
"f11",
|
|
10594
|
+
"f12",
|
|
10595
|
+
"ctrl+a",
|
|
10596
|
+
"ctrl+b",
|
|
10597
|
+
"ctrl+c",
|
|
10598
|
+
"ctrl+d",
|
|
10599
|
+
"ctrl+e",
|
|
10600
|
+
"ctrl+f",
|
|
10601
|
+
"ctrl+g",
|
|
10602
|
+
"ctrl+h",
|
|
10603
|
+
"ctrl+i",
|
|
10604
|
+
"ctrl+j",
|
|
10605
|
+
"ctrl+k",
|
|
10606
|
+
"ctrl+l",
|
|
10607
|
+
"ctrl+m",
|
|
10608
|
+
"ctrl+n",
|
|
10609
|
+
"ctrl+o",
|
|
10610
|
+
"ctrl+p",
|
|
10611
|
+
"ctrl+q",
|
|
10612
|
+
"ctrl+r",
|
|
10613
|
+
"ctrl+s",
|
|
10614
|
+
"ctrl+t",
|
|
10615
|
+
"ctrl+u",
|
|
10616
|
+
"ctrl+v",
|
|
10617
|
+
"ctrl+w",
|
|
10618
|
+
"ctrl+x",
|
|
10619
|
+
"ctrl+y",
|
|
10620
|
+
"ctrl+z",
|
|
10621
|
+
"ctrl+space",
|
|
10622
|
+
"ctrl+[",
|
|
10623
|
+
"ctrl+\\",
|
|
10624
|
+
"ctrl+]",
|
|
10625
|
+
"ctrl+^",
|
|
10626
|
+
"ctrl+_"
|
|
10627
|
+
];
|
|
10628
|
+
const FIXED = /* @__PURE__ */ new Map([
|
|
10629
|
+
["enter", "\r"],
|
|
10630
|
+
["return", "\r"],
|
|
10631
|
+
["tab", " "],
|
|
10632
|
+
["shift+tab", "\x1B[Z"],
|
|
10633
|
+
["backspace", ""],
|
|
10634
|
+
["escape", "\x1B"],
|
|
10635
|
+
["space", " "],
|
|
10636
|
+
["pageup", "\x1B[5~"],
|
|
10637
|
+
["pagedown", "\x1B[6~"],
|
|
10638
|
+
["insert", "\x1B[2~"],
|
|
10639
|
+
["delete", "\x1B[3~"],
|
|
10640
|
+
// F1-F4 are SS3 (their own code, no bracket); F5 onward are CSI with a number.
|
|
10641
|
+
["f1", "\x1BOP"],
|
|
10642
|
+
["f2", "\x1BOQ"],
|
|
10643
|
+
["f3", "\x1BOR"],
|
|
10644
|
+
["f4", "\x1BOS"],
|
|
10645
|
+
["f5", "\x1B[15~"],
|
|
10646
|
+
["f6", "\x1B[17~"],
|
|
10647
|
+
["f7", "\x1B[18~"],
|
|
10648
|
+
["f8", "\x1B[19~"],
|
|
10649
|
+
["f9", "\x1B[20~"],
|
|
10650
|
+
["f10", "\x1B[21~"],
|
|
10651
|
+
["f11", "\x1B[23~"],
|
|
10652
|
+
["f12", "\x1B[24~"]
|
|
10653
|
+
]);
|
|
10654
|
+
const CONTROL = /* @__PURE__ */ new Map([
|
|
10655
|
+
["ctrl+space", 0],
|
|
10656
|
+
["ctrl+[", 27],
|
|
10657
|
+
["ctrl+\\", 28],
|
|
10658
|
+
["ctrl+]", 29],
|
|
10659
|
+
["ctrl+^", 30],
|
|
10660
|
+
["ctrl+_", 31]
|
|
10661
|
+
]);
|
|
10662
|
+
for (const letter of "abcdefghijklmnopqrstuvwxyz") {
|
|
10663
|
+
CONTROL.set(`ctrl+${letter}`, letter.charCodeAt(0) - 96);
|
|
10664
|
+
}
|
|
10665
|
+
const CURSOR = /* @__PURE__ */ new Map([
|
|
10666
|
+
["up", { normal: "\x1B[A", application: "\x1BOA" }],
|
|
10667
|
+
["down", { normal: "\x1B[B", application: "\x1BOB" }],
|
|
10668
|
+
["right", { normal: "\x1B[C", application: "\x1BOC" }],
|
|
10669
|
+
["left", { normal: "\x1B[D", application: "\x1BOD" }],
|
|
10670
|
+
["home", { normal: "\x1B[H", application: "\x1BOH" }],
|
|
10671
|
+
["end", { normal: "\x1B[F", application: "\x1BOF" }]
|
|
10672
|
+
]);
|
|
10673
|
+
const ENCODER$1 = new TextEncoder();
|
|
10674
|
+
const encodeText = (text) => ENCODER$1.encode(text);
|
|
10675
|
+
function encodeNamedKey(name, modes) {
|
|
10676
|
+
const fixed = FIXED.get(name);
|
|
10677
|
+
if (fixed !== void 0) return encodeText(fixed);
|
|
10678
|
+
const control = CONTROL.get(name);
|
|
10679
|
+
if (control !== void 0) return Uint8Array.from([control]);
|
|
10680
|
+
const cursor = CURSOR.get(name);
|
|
10681
|
+
if (cursor !== void 0) {
|
|
10682
|
+
return encodeText(
|
|
10683
|
+
modes.applicationCursorKeys ? cursor.application : cursor.normal
|
|
10684
|
+
);
|
|
10685
|
+
}
|
|
10686
|
+
return null;
|
|
10687
|
+
}
|
|
10688
|
+
const OSC_133_PREFIX = [27, 93, 49, 51, 51, 59];
|
|
10689
|
+
const ESC = 27;
|
|
10690
|
+
const BEL = 7;
|
|
10691
|
+
const BACKSLASH = 92;
|
|
10692
|
+
const MAX_BODY_BYTES$1 = 64;
|
|
10693
|
+
const KINDS = {
|
|
10694
|
+
A: "prompt-start",
|
|
10695
|
+
B: "command-start",
|
|
10696
|
+
C: "output-start",
|
|
10697
|
+
D: "command-finished"
|
|
10698
|
+
};
|
|
10699
|
+
class Osc133Scanner {
|
|
10700
|
+
maxBodyBytes;
|
|
10701
|
+
/** How much of `OSC_133_PREFIX` has matched, 0 when not inside one. */
|
|
10702
|
+
matched = 0;
|
|
10703
|
+
/** The body accumulated since the prefix matched, or null when not inside a
|
|
10704
|
+
* sequence. Kept as a number array because a body is a handful of ASCII bytes. */
|
|
10705
|
+
body = null;
|
|
10706
|
+
/** Whether the previous byte was an ESC that may terminate the body. */
|
|
10707
|
+
escaped = false;
|
|
10708
|
+
/** Whether the retained body already exceeded `maxBodyBytes`. Such a sequence
|
|
10709
|
+
* is abandoned rather than truncated, so a long non-133 OSC cannot produce a
|
|
10710
|
+
* mark from its tail. */
|
|
10711
|
+
overflowed = false;
|
|
10712
|
+
/** Bytes fed so far, i.e. the offset of the next byte. */
|
|
10713
|
+
consumed = 0;
|
|
10714
|
+
/** Absolute offset of the sequence currently being read, or null. */
|
|
10715
|
+
sequenceStart = null;
|
|
10716
|
+
constructor(options = {}) {
|
|
10717
|
+
this.maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES$1;
|
|
10718
|
+
}
|
|
10719
|
+
/** Absolute offset of the next byte this scanner will be given. */
|
|
10720
|
+
get offset() {
|
|
10721
|
+
return this.consumed;
|
|
10722
|
+
}
|
|
10723
|
+
feed(bytes) {
|
|
10724
|
+
const marks = [];
|
|
10725
|
+
for (const byte of bytes) {
|
|
10726
|
+
const mark = this.step(byte);
|
|
10727
|
+
if (mark) marks.push(mark);
|
|
10728
|
+
this.consumed += 1;
|
|
10729
|
+
}
|
|
10730
|
+
return marks;
|
|
10731
|
+
}
|
|
10732
|
+
/** Forget a partial sequence (called when a surface is reset). The offset is
|
|
10733
|
+
* not rewound: offsets are absolute for the surface's life. */
|
|
10734
|
+
reset() {
|
|
10735
|
+
this.matched = 0;
|
|
10736
|
+
this.body = null;
|
|
10737
|
+
this.escaped = false;
|
|
10738
|
+
this.overflowed = false;
|
|
10739
|
+
this.sequenceStart = null;
|
|
10740
|
+
}
|
|
10741
|
+
step(byte) {
|
|
10742
|
+
if (this.body !== null) return this.stepBody(byte);
|
|
10743
|
+
if (byte === OSC_133_PREFIX[this.matched]) {
|
|
10744
|
+
this.matched += 1;
|
|
10745
|
+
if (this.matched === OSC_133_PREFIX.length) {
|
|
10746
|
+
this.body = [];
|
|
10747
|
+
this.sequenceStart = this.consumed - (OSC_133_PREFIX.length - 1);
|
|
10748
|
+
this.matched = 0;
|
|
10749
|
+
this.escaped = false;
|
|
10750
|
+
this.overflowed = false;
|
|
10751
|
+
}
|
|
10752
|
+
return null;
|
|
10753
|
+
}
|
|
10754
|
+
this.matched = byte === OSC_133_PREFIX[0] ? 1 : 0;
|
|
10755
|
+
return null;
|
|
10756
|
+
}
|
|
10757
|
+
stepBody(byte) {
|
|
10758
|
+
if (this.escaped) {
|
|
10759
|
+
this.escaped = false;
|
|
10760
|
+
if (byte === BACKSLASH) return this.finish();
|
|
10761
|
+
this.abandon();
|
|
10762
|
+
this.step(ESC);
|
|
10763
|
+
return this.step(byte);
|
|
10764
|
+
}
|
|
10765
|
+
if (byte === BEL) return this.finish();
|
|
10766
|
+
if (byte === ESC) {
|
|
10767
|
+
this.escaped = true;
|
|
10768
|
+
return null;
|
|
10769
|
+
}
|
|
10770
|
+
if (this.body === null) return null;
|
|
10771
|
+
if (this.body.length >= this.maxBodyBytes) {
|
|
10772
|
+
this.overflowed = true;
|
|
10773
|
+
this.body = null;
|
|
10774
|
+
return null;
|
|
10775
|
+
}
|
|
10776
|
+
this.body.push(byte);
|
|
10777
|
+
return null;
|
|
10778
|
+
}
|
|
10779
|
+
finish() {
|
|
10780
|
+
const body = this.body;
|
|
10781
|
+
const offset = this.sequenceStart ?? 0;
|
|
10782
|
+
this.body = null;
|
|
10783
|
+
this.escaped = false;
|
|
10784
|
+
this.sequenceStart = null;
|
|
10785
|
+
if (body === null || this.overflowed) {
|
|
10786
|
+
this.overflowed = false;
|
|
10787
|
+
return null;
|
|
10788
|
+
}
|
|
10789
|
+
return parseBody(body, offset);
|
|
10790
|
+
}
|
|
10791
|
+
abandon() {
|
|
10792
|
+
this.body = null;
|
|
10793
|
+
this.overflowed = false;
|
|
10794
|
+
this.sequenceStart = null;
|
|
10795
|
+
}
|
|
10796
|
+
}
|
|
10797
|
+
function parseBody(body, offset) {
|
|
10798
|
+
const text = String.fromCharCode(...body);
|
|
10799
|
+
const [kind, status2] = text.split(";");
|
|
10800
|
+
const mapped = KINDS[kind];
|
|
10801
|
+
if (!mapped) return null;
|
|
10802
|
+
if (status2 === void 0 || status2 === "") {
|
|
10803
|
+
return { kind: mapped, exitCode: null, offset };
|
|
10804
|
+
}
|
|
10805
|
+
const parsed = Number.parseInt(status2, 10);
|
|
10806
|
+
const exitCode = Number.isInteger(parsed) && parsed >= 0 && parsed <= 255 ? parsed : null;
|
|
10807
|
+
return { kind: mapped, exitCode, offset };
|
|
10808
|
+
}
|
|
10809
|
+
const SURFACE_PREFIX$1 = "con";
|
|
10810
|
+
const MAX_AGENT_SURFACES_PER_SESSION = 8;
|
|
10811
|
+
const MAX_SURFACES_PER_APP = 32;
|
|
10812
|
+
const REDACTED_NONCE_CHARS$1 = 6;
|
|
10813
|
+
const SURFACE_TOKEN$1 = /^con:(\d+):([A-Za-z0-9_-]+)$/;
|
|
10814
|
+
const NONCE_BYTES = 16;
|
|
10815
|
+
class ConsoleRegistry {
|
|
10816
|
+
entries = /* @__PURE__ */ new Map();
|
|
10817
|
+
now;
|
|
10818
|
+
nonce;
|
|
10819
|
+
/** Monotonic slot number: the `<n>` in `con:<n>:<nonce>`. A counter rather
|
|
10820
|
+
* than a reuse of the current size, so a handle never names a surface that was
|
|
10821
|
+
* closed and a later one that took its place. */
|
|
10822
|
+
nextSlot = 1;
|
|
10823
|
+
constructor(options = {}) {
|
|
10824
|
+
this.now = options.now ?? (() => Date.now() / 1e3);
|
|
10825
|
+
this.nonce = options.nonce ?? (() => node_crypto.randomBytes(NONCE_BYTES).toString("base64url"));
|
|
10826
|
+
}
|
|
10827
|
+
/** Every entry, oldest first, optionally filtered to one session. */
|
|
10828
|
+
list(sessionId2) {
|
|
10829
|
+
const all = [...this.entries.values()];
|
|
10830
|
+
return sessionId2 === void 0 ? all : all.filter((entry) => entry.record.sessionId === sessionId2);
|
|
10831
|
+
}
|
|
10832
|
+
/** How many surfaces exist, app-wide. */
|
|
10833
|
+
count() {
|
|
10834
|
+
return this.entries.size;
|
|
10835
|
+
}
|
|
10836
|
+
/** How many were created by that session's agent. */
|
|
10837
|
+
agentCount(sessionId2) {
|
|
10838
|
+
return this.list(sessionId2).filter(
|
|
10839
|
+
(entry) => entry.record.origin === "agent"
|
|
10840
|
+
).length;
|
|
10841
|
+
}
|
|
10842
|
+
/**
|
|
10843
|
+
* Create an entry, enforcing the caps in the order §6.3 states them.
|
|
10844
|
+
*
|
|
10845
|
+
* The refusals are the browser's own code for the same fact (`tab_limit`) with
|
|
10846
|
+
* `data.limit` and the scope that bound, rather than a console-specific
|
|
10847
|
+
* vocabulary for a generic condition — the same choice the design makes
|
|
10848
|
+
* everywhere the existing taxonomy fits (§10.6).
|
|
10849
|
+
*/
|
|
10850
|
+
create(input) {
|
|
10851
|
+
if (this.count() >= MAX_SURFACES_PER_APP) {
|
|
10852
|
+
throw new ConsoleError(
|
|
10853
|
+
"tab_limit",
|
|
10854
|
+
`this app is already holding ${MAX_SURFACES_PER_APP} console surfaces; close one first`,
|
|
10855
|
+
{ limit: MAX_SURFACES_PER_APP, scope: "app" }
|
|
10856
|
+
);
|
|
10857
|
+
}
|
|
10858
|
+
if (input.origin === "agent" && this.agentCount(input.sessionId) >= MAX_AGENT_SURFACES_PER_SESSION) {
|
|
10859
|
+
throw new ConsoleError(
|
|
10860
|
+
"tab_limit",
|
|
10861
|
+
`this session's agent is already running ${MAX_AGENT_SURFACES_PER_SESSION} console surfaces; close one first`,
|
|
10862
|
+
{ limit: MAX_AGENT_SURFACES_PER_SESSION, scope: "session" }
|
|
10863
|
+
);
|
|
10864
|
+
}
|
|
10865
|
+
const adopted = input.surface ? parseSurface$1(input.surface) : null;
|
|
10866
|
+
const slot = adopted ? adopted.slot : this.nextSlot;
|
|
10867
|
+
this.nextSlot = Math.max(this.nextSlot, slot + 1);
|
|
10868
|
+
const record = {
|
|
10869
|
+
surface: input.surface ?? `${SURFACE_PREFIX$1}:${slot}:${this.nonce()}`,
|
|
10870
|
+
sessionId: input.sessionId,
|
|
10871
|
+
origin: input.origin,
|
|
10872
|
+
command: input.command,
|
|
10873
|
+
argvTail: input.argvTail,
|
|
10874
|
+
cwd: input.cwd,
|
|
10875
|
+
cols: input.cols,
|
|
10876
|
+
rows: input.rows,
|
|
10877
|
+
running: true,
|
|
10878
|
+
exitCode: null,
|
|
10879
|
+
lastActivity: this.now(),
|
|
10880
|
+
live: true,
|
|
10881
|
+
retain: input.retain,
|
|
10882
|
+
secure: false,
|
|
10883
|
+
sizing: input.sizing
|
|
10884
|
+
};
|
|
10885
|
+
const entry = { record, runtime: null };
|
|
10886
|
+
this.entries.set(record.surface, entry);
|
|
10887
|
+
return entry;
|
|
10888
|
+
}
|
|
10889
|
+
/** Attach the host's runtime to an entry it just created. */
|
|
10890
|
+
attach(surface, runtime) {
|
|
10891
|
+
this.require(surface).runtime = runtime;
|
|
10892
|
+
}
|
|
10893
|
+
/**
|
|
10894
|
+
* Look up an entry, or refuse with the typed `surface_unavailable`.
|
|
10895
|
+
*
|
|
10896
|
+
* The message names the handle and how many exist, per §15: a caller that
|
|
10897
|
+
* mis-typed a handle can tell "nothing like this" from "not this one".
|
|
10898
|
+
*/
|
|
10899
|
+
require(surface) {
|
|
10900
|
+
const entry = this.entries.get(surface);
|
|
10901
|
+
if (!entry) {
|
|
10902
|
+
throw new ConsoleError(
|
|
10903
|
+
"surface_unavailable",
|
|
10904
|
+
`no console surface named ${redactSurface(surface)}; ${this.entries.size} exist`,
|
|
10905
|
+
{ surface: redactSurface(surface), count: this.entries.size }
|
|
10906
|
+
);
|
|
10907
|
+
}
|
|
10908
|
+
return entry;
|
|
10909
|
+
}
|
|
10910
|
+
/**
|
|
10911
|
+
* Look up an entry without raising, for the asynchronous paths.
|
|
10912
|
+
*
|
|
10913
|
+
* A pty's data and exit callbacks fire after a surface may already have been
|
|
10914
|
+
* closed, and a throw inside an event handler is an unhandled exception rather
|
|
10915
|
+
* than a typed refusal a caller can read. Those paths use this and treat "gone"
|
|
10916
|
+
* as "nothing left to do".
|
|
10917
|
+
*/
|
|
10918
|
+
find(surface) {
|
|
10919
|
+
return this.entries.get(surface) ?? null;
|
|
10920
|
+
}
|
|
10921
|
+
/**
|
|
10922
|
+
* Require an entry AND that a session owns it.
|
|
10923
|
+
*
|
|
10924
|
+
* Ownership is a fence, not a filter: §15 has the pane show only a session's
|
|
10925
|
+
* own surfaces, and an agent that names another session's handle gets a typed
|
|
10926
|
+
* refusal rather than a read of somebody else's terminal — the browser's
|
|
10927
|
+
* `owner_refused` precedent, spelled the console's way so a caller need not
|
|
10928
|
+
* substring-match a message.
|
|
10929
|
+
*
|
|
10930
|
+
* WHO CALLS IT, stated because a fence nobody stands at is theatre: the
|
|
10931
|
+
* session-scoped callers are the pane's own ops and the harness tool, and neither
|
|
10932
|
+
* is in this PR — the RPC call carries no session identity (the key is per-host,
|
|
10933
|
+
* §10.1), so there is nothing for THIS process to compare a handle against. What
|
|
10934
|
+
* PR A does enforce is the filter half of §15: `console-state`/`console_list`
|
|
10935
|
+
* answer one session's surfaces, which is what the pane renders and what the tool
|
|
10936
|
+
* reads. The refusal is defined and tested here so the seam exists when a caller
|
|
10937
|
+
* has an identity to check.
|
|
10938
|
+
*/
|
|
10939
|
+
requireOwned(surface, sessionId2) {
|
|
10940
|
+
const entry = this.require(surface);
|
|
10941
|
+
if (sessionId2 !== void 0 && entry.record.sessionId !== sessionId2) {
|
|
10942
|
+
throw new ConsoleError(
|
|
10943
|
+
"surface_not_owned",
|
|
10944
|
+
`${redactSurface(surface)} belongs to another session`,
|
|
10945
|
+
{ surface: redactSurface(surface) }
|
|
10946
|
+
);
|
|
10947
|
+
}
|
|
10948
|
+
return entry;
|
|
10949
|
+
}
|
|
10950
|
+
/** Patch the mutable facts. The handle, the session and the origin never move. */
|
|
10951
|
+
update(surface, patch) {
|
|
10952
|
+
const entry = this.require(surface);
|
|
10953
|
+
entry.record = { ...entry.record, ...patch, surface: entry.record.surface };
|
|
10954
|
+
}
|
|
10955
|
+
/**
|
|
10956
|
+
* Drop an entry.
|
|
10957
|
+
*
|
|
10958
|
+
* Returns the entry it removed, or null when it was already gone — the same
|
|
10959
|
+
* "already gone is the finished state" contract the browser's ownership ledger
|
|
10960
|
+
* uses, so a close is idempotent without a second lookup.
|
|
10961
|
+
*/
|
|
10962
|
+
remove(surface) {
|
|
10963
|
+
const entry = this.entries.get(surface);
|
|
10964
|
+
if (!entry) return null;
|
|
10965
|
+
this.entries.delete(surface);
|
|
10966
|
+
return entry;
|
|
10967
|
+
}
|
|
10968
|
+
/** Drop every entry and every runtime. The host's stop path, and the one place
|
|
10969
|
+
* a caller may assume the map is empty afterwards. */
|
|
10970
|
+
clear() {
|
|
10971
|
+
const all = [...this.entries.values()];
|
|
10972
|
+
this.entries.clear();
|
|
10973
|
+
return all;
|
|
10974
|
+
}
|
|
10975
|
+
}
|
|
10976
|
+
function parseSurface$1(token) {
|
|
10977
|
+
const match = SURFACE_TOKEN$1.exec(token);
|
|
10978
|
+
if (!match) return null;
|
|
10979
|
+
return { slot: Number.parseInt(match[1], 10), nonce: match[2] };
|
|
10980
|
+
}
|
|
10981
|
+
function redactSurface(surface) {
|
|
10982
|
+
const parsed = parseSurface$1(surface);
|
|
10983
|
+
if (!parsed) return "(malformed handle)";
|
|
10984
|
+
return `${SURFACE_PREFIX$1}:${parsed.slot}:${parsed.nonce.slice(0, REDACTED_NONCE_CHARS$1)}…`;
|
|
10985
|
+
}
|
|
10986
|
+
const DEFAULT_COLS = 100;
|
|
10987
|
+
const DEFAULT_ROWS = 30;
|
|
10988
|
+
const MIN_COLS = 40;
|
|
10989
|
+
const MIN_ROWS = 10;
|
|
10990
|
+
const MAX_COLS = 500;
|
|
10991
|
+
const MAX_ROWS = 200;
|
|
10992
|
+
const SCROLLBACK_LINES = 5e3;
|
|
10993
|
+
const MAX_INPUT_BYTES = 1 << 18;
|
|
10994
|
+
const OUTPUT_COALESCE_MS = 16;
|
|
10995
|
+
const PERSIST_FLUSH_MS = 250;
|
|
10996
|
+
const PERSIST_FLUSH_BYTES = 256 * 1024;
|
|
10997
|
+
const MIN_FRAME_BYTES = 2e3;
|
|
10998
|
+
const FRAME_RETRY_DELAY_MS = 120;
|
|
10999
|
+
const CLOSE_GRACE_MS = 1500;
|
|
11000
|
+
const SURFACE_ENV_MARKER = "LOCAL_OPERATOR_CONSOLE_SURFACE";
|
|
11001
|
+
const SESSION_ENV_MARKER = "LOCAL_OPERATOR_CONSOLE_SESSION";
|
|
11002
|
+
const SURFACE_TERM = "xterm-256color";
|
|
11003
|
+
const SURFACE_COLORTERM = "truecolor";
|
|
11004
|
+
const STRIPPED_ENV_PREFIXES = ["CMUX_", "LOP_"];
|
|
11005
|
+
class ConsoleHost {
|
|
11006
|
+
registry;
|
|
11007
|
+
options;
|
|
11008
|
+
now;
|
|
11009
|
+
env;
|
|
11010
|
+
constructor(registry, options) {
|
|
11011
|
+
this.registry = registry;
|
|
11012
|
+
this.options = options;
|
|
11013
|
+
this.now = options.now ?? (() => Date.now() / 1e3);
|
|
11014
|
+
this.env = options.env ?? process.env;
|
|
11015
|
+
}
|
|
11016
|
+
/** How many surfaces exist, and how many of them an agent made. The record's
|
|
11017
|
+
* own two numbers (§10.1). */
|
|
11018
|
+
counts() {
|
|
11019
|
+
const all = this.registry.list();
|
|
11020
|
+
return {
|
|
11021
|
+
total: all.length,
|
|
11022
|
+
agent: all.filter((entry) => entry.record.origin === "agent").length
|
|
11023
|
+
};
|
|
11024
|
+
}
|
|
11025
|
+
/**
|
|
11026
|
+
* Create a surface.
|
|
11027
|
+
*
|
|
11028
|
+
* THE ORDER OF THE FIVE STEPS MATTERS and is why this is one method: the record
|
|
11029
|
+
* exists before the spawn (so a failed spawn still has a handle to report), the
|
|
11030
|
+
* runtime is attached only after the spawn succeeded, and a spawn failure
|
|
11031
|
+
* removes the record rather than leaving a surface that lists as running.
|
|
11032
|
+
*/
|
|
11033
|
+
create(options) {
|
|
11034
|
+
const cols = options.cols ?? DEFAULT_COLS;
|
|
11035
|
+
const rows = options.rows ?? DEFAULT_ROWS;
|
|
11036
|
+
const grid = clampGrid(cols, rows);
|
|
11037
|
+
const command = options.command?.trim() || defaultShell(this.env);
|
|
11038
|
+
const args = options.args ?? [];
|
|
11039
|
+
const cwd = resolveCwd(options.cwd);
|
|
11040
|
+
const entry = this.registry.create({
|
|
11041
|
+
sessionId: options.sessionId,
|
|
11042
|
+
origin: options.origin,
|
|
11043
|
+
command: path$1.basename(command),
|
|
11044
|
+
argvTail: args.join(" "),
|
|
11045
|
+
cwd,
|
|
11046
|
+
cols: grid.cols,
|
|
11047
|
+
rows: grid.rows,
|
|
11048
|
+
// The design's default (§7.2): a user's surface is retained, an agent's is
|
|
11049
|
+
// not — an agent that floods a surface should not fill the disk by default,
|
|
11050
|
+
// and a user who opened one wants it next launch.
|
|
11051
|
+
retain: options.retain ?? options.origin === "user",
|
|
11052
|
+
sizing: options.sizing ?? "auto"
|
|
11053
|
+
});
|
|
11054
|
+
const surface = entry.record.surface;
|
|
11055
|
+
const env = surfaceEnvironment({
|
|
11056
|
+
base: this.env,
|
|
11057
|
+
sessionId: options.sessionId,
|
|
11058
|
+
surface,
|
|
11059
|
+
overrides: options.env
|
|
11060
|
+
});
|
|
11061
|
+
const startProcess = () => {
|
|
11062
|
+
try {
|
|
11063
|
+
return this.options.spawn({
|
|
11064
|
+
file: command,
|
|
11065
|
+
args,
|
|
11066
|
+
cwd,
|
|
11067
|
+
env,
|
|
11068
|
+
cols: grid.cols,
|
|
11069
|
+
rows: grid.rows
|
|
11070
|
+
});
|
|
11071
|
+
} catch (error) {
|
|
11072
|
+
this.registry.remove(surface);
|
|
11073
|
+
throw new ConsoleError(
|
|
11074
|
+
"console_unavailable",
|
|
11075
|
+
`the surface's process could not start: ${error instanceof Error ? error.message : String(error)}`,
|
|
11076
|
+
{ reason: "spawn_failed" }
|
|
11077
|
+
);
|
|
11078
|
+
}
|
|
11079
|
+
};
|
|
11080
|
+
const pty = startProcess();
|
|
11081
|
+
const runtime = {
|
|
11082
|
+
pty,
|
|
11083
|
+
log: new ByteLog(),
|
|
11084
|
+
emulator: createXtermEmulator({
|
|
11085
|
+
cols: grid.cols,
|
|
11086
|
+
rows: grid.rows,
|
|
11087
|
+
scrollback: SCROLLBACK_LINES
|
|
11088
|
+
}),
|
|
11089
|
+
scanner: new Osc133Scanner(),
|
|
11090
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
11091
|
+
pending: [],
|
|
11092
|
+
pendingTimer: null,
|
|
11093
|
+
persistTimer: null,
|
|
11094
|
+
seq: 0,
|
|
11095
|
+
rect: null,
|
|
11096
|
+
theme: null,
|
|
11097
|
+
lastMark: null,
|
|
11098
|
+
exitDelivered: false,
|
|
11099
|
+
exitWaiters: []
|
|
11100
|
+
};
|
|
11101
|
+
this.registry.attach(surface, runtime);
|
|
11102
|
+
pty.onData((data) => this.consume(surface, data));
|
|
11103
|
+
pty.onExit((event) => this.exited(surface, event.exitCode));
|
|
11104
|
+
if (options.input) pty.write(options.input);
|
|
11105
|
+
if (entry.record.retain) this.flush(surface);
|
|
11106
|
+
this.options.log(
|
|
11107
|
+
`[console] surface ${redactSurface(surface)} started: ${path$1.basename(command)} in ${cwd} at ${grid.cols}x${grid.rows} (${options.origin})`
|
|
11108
|
+
);
|
|
11109
|
+
this.options.onChanged?.();
|
|
11110
|
+
const reveal = this.resolveReveal(options.reveal ?? "none");
|
|
11111
|
+
if (reveal !== "none") {
|
|
11112
|
+
this.options.onReveal?.({
|
|
11113
|
+
surface,
|
|
11114
|
+
sessionId: options.sessionId,
|
|
11115
|
+
mode: reveal
|
|
11116
|
+
});
|
|
11117
|
+
}
|
|
11118
|
+
return {
|
|
11119
|
+
surface,
|
|
11120
|
+
cols: grid.cols,
|
|
11121
|
+
rows: grid.rows,
|
|
11122
|
+
pid: pty.pid,
|
|
11123
|
+
live: true,
|
|
11124
|
+
// `reveal` is the intent after the focus rule; `revealed` says whether a
|
|
11125
|
+
// pane was asked for at all. They differ exactly when `reveal: "open"` was
|
|
11126
|
+
// downgraded because raising the window is not something this app does
|
|
11127
|
+
// (design 10.4), and a caller that cannot tell the two apart would believe
|
|
11128
|
+
// the user was shown something they were not.
|
|
11129
|
+
reveal,
|
|
11130
|
+
revealed: reveal !== "none"
|
|
11131
|
+
};
|
|
11132
|
+
}
|
|
11133
|
+
/**
|
|
11134
|
+
* The half of `reveal` this process owns: whether the app's window is already
|
|
11135
|
+
* focused (design 10.4).
|
|
11136
|
+
*
|
|
11137
|
+
* `show`/`showInactive`/`focus` live in `window-raise.ts` alone, and
|
|
11138
|
+
* `scripts/window-mode.test.mjs` asserts it — so a `reveal: "open"` that would
|
|
11139
|
+
* have to raise the window is downgraded here rather than at the renderer. The
|
|
11140
|
+
* renderer's half (is the wanted session the one on screen?) is not a question
|
|
11141
|
+
* main can answer, and it is exactly why `reveal: "session"` is forwarded rather
|
|
11142
|
+
* than resolved.
|
|
11143
|
+
*/
|
|
11144
|
+
resolveReveal(mode) {
|
|
11145
|
+
if (mode !== "open") return mode;
|
|
11146
|
+
const window = this.options.window();
|
|
11147
|
+
if (window && !window.isDestroyed() && window.isFocused()) return "open";
|
|
11148
|
+
return "none";
|
|
11149
|
+
}
|
|
11150
|
+
/**
|
|
11151
|
+
* Adopt a surface reconstructed from persisted history (§7.3).
|
|
11152
|
+
*
|
|
11153
|
+
* It is `live: false` from birth, has no pty and never gets one, and its bytes
|
|
11154
|
+
* are replayed into a fresh record so `console_read` and the pane both show what
|
|
11155
|
+
* the surface printed. The listing says so, which is the whole honesty
|
|
11156
|
+
* requirement: nothing here pretends a process is running.
|
|
11157
|
+
*/
|
|
11158
|
+
restore(meta, bytes) {
|
|
11159
|
+
const existing = this.registry.list(meta.session_id);
|
|
11160
|
+
if (existing.some((entry2) => entry2.record.surface === meta.surface)) return;
|
|
11161
|
+
const grid = clampGrid(meta.cols, meta.rows);
|
|
11162
|
+
const entry = this.registry.create({
|
|
11163
|
+
// The handle the history recorded, so a caller holding one across a relaunch
|
|
11164
|
+
// finds the same surface rather than a lookalike with a new nonce.
|
|
11165
|
+
surface: meta.surface,
|
|
11166
|
+
sessionId: meta.session_id,
|
|
11167
|
+
origin: meta.origin,
|
|
11168
|
+
command: meta.command,
|
|
11169
|
+
argvTail: meta.argv.join(" "),
|
|
11170
|
+
cwd: meta.cwd,
|
|
11171
|
+
cols: grid.cols,
|
|
11172
|
+
rows: grid.rows,
|
|
11173
|
+
retain: true,
|
|
11174
|
+
sizing: "auto"
|
|
11175
|
+
});
|
|
11176
|
+
this.createdAt.set(
|
|
11177
|
+
meta.surface,
|
|
11178
|
+
Number.isFinite(meta.created_at) ? meta.created_at : this.now()
|
|
11179
|
+
);
|
|
11180
|
+
this.exitEpoch.set(
|
|
11181
|
+
meta.surface,
|
|
11182
|
+
Number.isFinite(meta.exit_epoch) ? meta.exit_epoch : 0
|
|
11183
|
+
);
|
|
11184
|
+
const emulator = createXtermEmulator({
|
|
11185
|
+
cols: grid.cols,
|
|
11186
|
+
rows: grid.rows,
|
|
11187
|
+
scrollback: SCROLLBACK_LINES
|
|
11188
|
+
});
|
|
11189
|
+
emulator.write(bytes);
|
|
11190
|
+
const runtime = {
|
|
11191
|
+
// A reconstructed surface has no pty. `null` rather than a fake process:
|
|
11192
|
+
// every path that would signal one checks `running` first, and the record
|
|
11193
|
+
// says `running: false` for this surface forever.
|
|
11194
|
+
pty: null,
|
|
11195
|
+
log: new ByteLog(),
|
|
11196
|
+
emulator,
|
|
11197
|
+
scanner: new Osc133Scanner(),
|
|
11198
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
11199
|
+
pending: [],
|
|
11200
|
+
pendingTimer: null,
|
|
11201
|
+
persistTimer: null,
|
|
11202
|
+
seq: 0,
|
|
11203
|
+
rect: null,
|
|
11204
|
+
theme: null,
|
|
11205
|
+
lastMark: null,
|
|
11206
|
+
exitDelivered: true,
|
|
11207
|
+
exitWaiters: []
|
|
11208
|
+
};
|
|
11209
|
+
runtime.log.append(bytes);
|
|
11210
|
+
this.registry.attach(entry.record.surface, runtime);
|
|
11211
|
+
this.registry.update(entry.record.surface, {
|
|
11212
|
+
running: false,
|
|
11213
|
+
exitCode: meta.exit_code,
|
|
11214
|
+
live: false,
|
|
11215
|
+
lastActivity: meta.last_seen_at
|
|
11216
|
+
});
|
|
11217
|
+
}
|
|
11218
|
+
/** Every surface, optionally one session's, as the wire reports it. */
|
|
11219
|
+
list(sessionId2) {
|
|
11220
|
+
return this.registry.list(sessionId2).map((entry) => this.listing(entry));
|
|
11221
|
+
}
|
|
11222
|
+
listing(entry) {
|
|
11223
|
+
const record = entry.record;
|
|
11224
|
+
return {
|
|
11225
|
+
surface: record.surface,
|
|
11226
|
+
session_id: record.sessionId,
|
|
11227
|
+
origin: record.origin,
|
|
11228
|
+
command: record.command,
|
|
11229
|
+
argv_tail: record.argvTail,
|
|
11230
|
+
cwd: record.cwd,
|
|
11231
|
+
cols: record.cols,
|
|
11232
|
+
rows: record.rows,
|
|
11233
|
+
running: record.running,
|
|
11234
|
+
exit_code: record.exitCode,
|
|
11235
|
+
last_activity: record.lastActivity,
|
|
11236
|
+
live: record.live,
|
|
11237
|
+
agent_owned: record.origin === "agent"
|
|
11238
|
+
};
|
|
11239
|
+
}
|
|
11240
|
+
/** `console_status`. */
|
|
11241
|
+
status(surface) {
|
|
11242
|
+
const entry = this.registry.require(surface);
|
|
11243
|
+
const runtime = entry.runtime;
|
|
11244
|
+
const grid = runtime?.emulator.grid;
|
|
11245
|
+
return {
|
|
11246
|
+
running: entry.record.running,
|
|
11247
|
+
exit_code: entry.record.exitCode,
|
|
11248
|
+
// The retention layer's counter, bumped once per `process_exited` (§7.3,
|
|
11249
|
+
// §10.2). Read from the host's live view of it: the sidecar is what makes
|
|
11250
|
+
// it survive a relaunch, and this is what the running process reports.
|
|
11251
|
+
exit_epoch: this.exitEpoch.get(surface) ?? 0,
|
|
11252
|
+
cols: entry.record.cols,
|
|
11253
|
+
rows: entry.record.rows,
|
|
11254
|
+
live: entry.record.live,
|
|
11255
|
+
truncated: runtime?.log.truncated ?? false,
|
|
11256
|
+
modes: grid?.modes ?? null,
|
|
11257
|
+
cursor: grid?.cursor ?? null,
|
|
11258
|
+
last_activity: entry.record.lastActivity,
|
|
11259
|
+
retain: entry.record.retain,
|
|
11260
|
+
secure: entry.record.secure,
|
|
11261
|
+
// §6.6 asks `console_status` to report "the marker it set and the glyph
|
|
11262
|
+
// decision, so the behaviour is observable rather than inferred". The
|
|
11263
|
+
// marker is this process's own (§6.6's env table, set in
|
|
11264
|
+
// `surfaceEnvironment`); the glyph DECISION is the Python side's predicate
|
|
11265
|
+
// (`is_local_operator_console` → `_nerd_capable_terminal`, §6.6), which
|
|
11266
|
+
// runs in the session that reads this record rather than here, so it is
|
|
11267
|
+
// reported by PR C's side and named there.
|
|
11268
|
+
env_marker: { name: SURFACE_ENV_MARKER, value: surface }
|
|
11269
|
+
};
|
|
11270
|
+
}
|
|
11271
|
+
/**
|
|
11272
|
+
* `console_read`: text from the record, with no view present (R7).
|
|
11273
|
+
*
|
|
11274
|
+
* `whenIdle` first, because the pinned emulator parses asynchronously: a read
|
|
11275
|
+
* answered from a grid one chunk behind is exactly the staleness an agent-driven
|
|
11276
|
+
* read cannot detect (see `emulator.ts`).
|
|
11277
|
+
*/
|
|
11278
|
+
async read(surface, mode, window) {
|
|
11279
|
+
const entry = this.registry.require(surface);
|
|
11280
|
+
const runtime = this.requireRuntime(entry);
|
|
11281
|
+
this.refuseWhenSecure(entry);
|
|
11282
|
+
await runtime.emulator.whenIdle();
|
|
11283
|
+
const grid = runtime.emulator.grid;
|
|
11284
|
+
return {
|
|
11285
|
+
text: runtime.emulator.text(mode, window),
|
|
11286
|
+
cols: grid.cols,
|
|
11287
|
+
rows: grid.rows,
|
|
11288
|
+
cursor: grid.cursor,
|
|
11289
|
+
truncated: runtime.log.truncated,
|
|
11290
|
+
live: entry.record.live,
|
|
11291
|
+
mode
|
|
11292
|
+
};
|
|
11293
|
+
}
|
|
11294
|
+
/**
|
|
11295
|
+
* `console_screenshot`: the app's own window, cropped to the pane's reported
|
|
11296
|
+
* rect, photographed with `capturePage` (design 13.2's first row).
|
|
11297
|
+
*
|
|
11298
|
+
* WHAT THIS DOES NOT DO, and it is the design's own first cut item (§17.3): the
|
|
11299
|
+
* offscreen capture view — a hidden renderer replaying the record — is PR B's
|
|
11300
|
+
* (§17.1), so a surface with no displayed pane is refused with
|
|
11301
|
+
* `capture_unavailable` rather than answered with a frame that would not be of
|
|
11302
|
+
* that surface. The `rendered` field is kept so the gap is visible instead of
|
|
11303
|
+
* implied.
|
|
11304
|
+
*
|
|
11305
|
+
* The assert-and-retry is the spike's measured trap and not a hopeful
|
|
11306
|
+
* `setTimeout`: on a hidden window the FIRST `capturePage` came back blank
|
|
11307
|
+
* (9,866 B against 27,869 B for the settled frame), and a headless rig — which
|
|
11308
|
+
* is what every capture in this repo runs in — is exactly that case.
|
|
11309
|
+
*/
|
|
11310
|
+
async screenshot(surface) {
|
|
11311
|
+
const entry = this.registry.require(surface);
|
|
11312
|
+
const runtime = this.requireRuntime(entry);
|
|
11313
|
+
this.refuseWhenSecure(entry);
|
|
11314
|
+
const report = runtime.rect;
|
|
11315
|
+
if (this.displayed !== surface || !report || !report.visible) {
|
|
11316
|
+
throw new ConsoleError(
|
|
11317
|
+
"capture_unavailable",
|
|
11318
|
+
"no pane is displaying this surface, and this app version cannot reconstruct a frame offscreen",
|
|
11319
|
+
{ rendered: null }
|
|
11320
|
+
);
|
|
11321
|
+
}
|
|
11322
|
+
const window = this.options.window();
|
|
11323
|
+
if (!window || window.isDestroyed()) {
|
|
11324
|
+
throw new ConsoleError(
|
|
11325
|
+
"capture_unavailable",
|
|
11326
|
+
"this app's window is not available to photograph",
|
|
11327
|
+
{ rendered: null }
|
|
11328
|
+
);
|
|
11329
|
+
}
|
|
11330
|
+
const rect = cropToWindow(report.contentRect, window.getContentBounds());
|
|
11331
|
+
const png = await this.captureWithRetry(window, rect);
|
|
11332
|
+
const grid = runtime.emulator.grid;
|
|
11333
|
+
return {
|
|
11334
|
+
image_base64: png.toString("base64"),
|
|
11335
|
+
cols: grid.cols,
|
|
11336
|
+
rows: grid.rows,
|
|
11337
|
+
// Named, not implied (13.2): this frame is a photograph of the app's own
|
|
11338
|
+
// window, so it is what a person is looking at rather than a
|
|
11339
|
+
// reconstruction of the record.
|
|
11340
|
+
rendered: "displayed",
|
|
11341
|
+
theme: runtime.theme,
|
|
11342
|
+
live: entry.record.live
|
|
11343
|
+
};
|
|
11344
|
+
}
|
|
11345
|
+
async captureWithRetry(window, rect) {
|
|
11346
|
+
const first = framePng(await window.webContents.capturePage(rect));
|
|
11347
|
+
if (first.length >= MIN_FRAME_BYTES) return first;
|
|
11348
|
+
await delay(FRAME_RETRY_DELAY_MS);
|
|
11349
|
+
return framePng(await window.webContents.capturePage(rect));
|
|
11350
|
+
}
|
|
11351
|
+
/**
|
|
11352
|
+
* `console_input`: bytes into the pty.
|
|
11353
|
+
*
|
|
11354
|
+
* `paste` wraps the payload in the bracketed-paste guards **only when the
|
|
11355
|
+
* record's LIVE mode says so** (§10.5), which is why this is a main-side
|
|
11356
|
+
* decision: the mode is read at the moment of the call, from the record.
|
|
11357
|
+
*/
|
|
11358
|
+
async input(surface, payload) {
|
|
11359
|
+
const entry = this.registry.require(surface);
|
|
11360
|
+
const runtime = this.requireRuntime(entry);
|
|
11361
|
+
this.requireRunning(entry);
|
|
11362
|
+
const raw = payload.bytes ?? ENCODER.encode(payload.text ?? "");
|
|
11363
|
+
if (raw.length > MAX_INPUT_BYTES) {
|
|
11364
|
+
throw new ConsoleError(
|
|
11365
|
+
"input_queue_full",
|
|
11366
|
+
`that payload is ${raw.length} bytes and this surface accepts ${MAX_INPUT_BYTES} at a time`,
|
|
11367
|
+
{ accepted: 0, limit: MAX_INPUT_BYTES }
|
|
11368
|
+
);
|
|
11369
|
+
}
|
|
11370
|
+
const bracketed = payload.paste === true && runtime.emulator.grid.modes.bracketedPaste;
|
|
11371
|
+
const bytes = bracketed ? wrapBracketedPaste(raw) : raw;
|
|
11372
|
+
this.writeToPty(entry, runtime, bytes);
|
|
11373
|
+
return { accepted: true, bytes: bytes.length };
|
|
11374
|
+
}
|
|
11375
|
+
/** `console_keys`: named keys through the one encoder (§10.5). */
|
|
11376
|
+
async keys(surface, names) {
|
|
11377
|
+
const entry = this.registry.require(surface);
|
|
11378
|
+
const runtime = this.requireRuntime(entry);
|
|
11379
|
+
this.requireRunning(entry);
|
|
11380
|
+
await runtime.emulator.whenIdle();
|
|
11381
|
+
const modes = runtime.emulator.grid.modes;
|
|
11382
|
+
const encoded = names.map(
|
|
11383
|
+
(name) => encodeNamedKey(name, {
|
|
11384
|
+
applicationCursorKeys: modes.applicationCursorKeys
|
|
11385
|
+
})
|
|
11386
|
+
);
|
|
11387
|
+
const unknown = names.find((_name, index) => encoded[index] === null);
|
|
11388
|
+
if (unknown !== void 0) {
|
|
11389
|
+
throw new ConsoleError(
|
|
11390
|
+
"unknown_key",
|
|
11391
|
+
`unknown key ${JSON.stringify(unknown)}; accepted: ${NAMED_KEYS.join(", ")}`,
|
|
11392
|
+
{ accepted: [...NAMED_KEYS], key: unknown }
|
|
11393
|
+
);
|
|
11394
|
+
}
|
|
11395
|
+
const sequences = [];
|
|
11396
|
+
for (const bytes of encoded) {
|
|
11397
|
+
if (!bytes) continue;
|
|
11398
|
+
this.writeToPty(entry, runtime, bytes);
|
|
11399
|
+
sequences.push(Buffer.from(bytes).toString("latin1"));
|
|
11400
|
+
}
|
|
11401
|
+
return { accepted: true, encoded: sequences };
|
|
11402
|
+
}
|
|
11403
|
+
/**
|
|
11404
|
+
* `console_resize`: the explicit writer, and the only one besides a displayed
|
|
11405
|
+
* pane's rect (§8.4).
|
|
11406
|
+
*/
|
|
11407
|
+
resize(surface, cols, rows) {
|
|
11408
|
+
const entry = this.registry.require(surface);
|
|
11409
|
+
if (entry.record.sizing === "fixed") {
|
|
11410
|
+
throw new ConsoleError(
|
|
11411
|
+
"invalid_grid",
|
|
11412
|
+
"this surface was created with a fixed grid; create another one to resize",
|
|
11413
|
+
// §10.6 reads `reason: "fixed"` here and no clamp: there is no clamp
|
|
11414
|
+
// to carry for a surface that cannot be resized at all.
|
|
11415
|
+
{ reason: "fixed" }
|
|
11416
|
+
);
|
|
11417
|
+
}
|
|
11418
|
+
this.applyGrid(entry, cols, rows);
|
|
11419
|
+
return { cols: entry.record.cols, rows: entry.record.rows };
|
|
11420
|
+
}
|
|
11421
|
+
/** The pane's report: measurement in, grid decision out (§8.2). */
|
|
11422
|
+
setContentRect(surface, report) {
|
|
11423
|
+
const entry = this.registry.require(surface);
|
|
11424
|
+
const runtime = this.requireRuntime(entry);
|
|
11425
|
+
runtime.rect = report;
|
|
11426
|
+
runtime.theme = report.theme ?? null;
|
|
11427
|
+
if (!report.visible) return;
|
|
11428
|
+
if (this.displayed !== surface) return;
|
|
11429
|
+
if (entry.record.sizing === "fixed") return;
|
|
11430
|
+
if (!(report.cellWidth > 0) || !(report.cellHeight > 0)) return;
|
|
11431
|
+
const cols = Math.floor(report.contentRect.width / report.cellWidth);
|
|
11432
|
+
const rows = Math.floor(report.contentRect.height / report.cellHeight);
|
|
11433
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
11434
|
+
this.applyGrid(entry, cols, rows);
|
|
11435
|
+
}
|
|
11436
|
+
/** The pane unmounted or switched surface: forget the rect, and send no resize
|
|
11437
|
+
* and no signal (§6.4, §8.3). */
|
|
11438
|
+
forgetContentRect(surface) {
|
|
11439
|
+
const entry = this.registry.require(surface);
|
|
11440
|
+
if (entry.runtime) entry.runtime.rect = null;
|
|
11441
|
+
}
|
|
11442
|
+
/** `console_secure`: the explicit "do not capture this" switch (§11.4.4). */
|
|
11443
|
+
setSecure(surface, on) {
|
|
11444
|
+
const entry = this.registry.require(surface);
|
|
11445
|
+
this.registry.update(surface, { secure: on });
|
|
11446
|
+
if (on && entry.runtime) {
|
|
11447
|
+
this.options.log(
|
|
11448
|
+
`[console] secure input on for ${redactSurface(surface)}: new bytes are not retained until it is off`
|
|
11449
|
+
);
|
|
11450
|
+
}
|
|
11451
|
+
this.flush(surface);
|
|
11452
|
+
this.options.onChanged?.();
|
|
11453
|
+
return { secure: on };
|
|
11454
|
+
}
|
|
11455
|
+
/**
|
|
11456
|
+
* `console_close`. `kill` signals; without it the surface is only forgotten,
|
|
11457
|
+
* which is what closing a surface whose process already exited means.
|
|
11458
|
+
*
|
|
11459
|
+
* `retain` overrides the record's own default in both directions: a user may
|
|
11460
|
+
* close a surface and keep its history, an agent may close one and drop it.
|
|
11461
|
+
*/
|
|
11462
|
+
async close(surface, options = {}) {
|
|
11463
|
+
const entry = this.registry.require(surface);
|
|
11464
|
+
const runtime = entry.runtime;
|
|
11465
|
+
if (options.retain !== void 0) {
|
|
11466
|
+
this.registry.update(surface, { retain: options.retain });
|
|
11467
|
+
}
|
|
11468
|
+
let exitCode = entry.record.exitCode;
|
|
11469
|
+
if (runtime && entry.record.running) {
|
|
11470
|
+
if (options.kill === false) {
|
|
11471
|
+
throw new ConsoleError(
|
|
11472
|
+
"busy",
|
|
11473
|
+
`${redactSurface(surface)} is still running; close it with kill: true, or wait for its process to exit`,
|
|
11474
|
+
{ running: true }
|
|
11475
|
+
);
|
|
11476
|
+
}
|
|
11477
|
+
exitCode = await this.terminate(entry) ?? exitCode;
|
|
11478
|
+
}
|
|
11479
|
+
if (!entry.record.retain) {
|
|
11480
|
+
this.options.history?.remove(entry.record.sessionId, surface);
|
|
11481
|
+
} else {
|
|
11482
|
+
this.flush(surface);
|
|
11483
|
+
}
|
|
11484
|
+
this.drop(surface);
|
|
11485
|
+
this.options.log(
|
|
11486
|
+
`[console] surface ${redactSurface(surface)} closed${exitCode === null ? "" : ` with exit ${exitCode}`}${entry.record.retain ? " (history retained)" : ""}`
|
|
11487
|
+
);
|
|
11488
|
+
return exitCode === null ? { closed: true } : { closed: true, exit_code: exitCode };
|
|
11489
|
+
}
|
|
11490
|
+
/**
|
|
11491
|
+
* Signal a surface and wait for its exit, with the design's bounded grace and a
|
|
11492
|
+
* SIGKILL after it (§6.7, the session-deleted row).
|
|
11493
|
+
*
|
|
11494
|
+
* WHY IT WAITS AT ALL: an exit code is the whole evidence that a close did
|
|
11495
|
+
* something, and a close that returned before the process was gone would report
|
|
11496
|
+
* "closed" while the program was still writing into a pty whose surface had
|
|
11497
|
+
* already been dropped. The bound matters for the same reason — a process that
|
|
11498
|
+
* ignores SIGTERM must not hold the RPC call open, so it is escalated rather
|
|
11499
|
+
* than waited on.
|
|
11500
|
+
*/
|
|
11501
|
+
async terminate(entry) {
|
|
11502
|
+
const pty = entry.runtime?.pty ?? null;
|
|
11503
|
+
if (!pty) return null;
|
|
11504
|
+
const exit = new Promise((resolve) => {
|
|
11505
|
+
entry.runtime?.exitWaiters.push(resolve);
|
|
11506
|
+
});
|
|
11507
|
+
pty.kill();
|
|
11508
|
+
const code = await Promise.race([
|
|
11509
|
+
exit,
|
|
11510
|
+
delay(CLOSE_GRACE_MS).then(() => null)
|
|
11511
|
+
]);
|
|
11512
|
+
if (code === null) pty.kill("SIGKILL");
|
|
11513
|
+
return code;
|
|
11514
|
+
}
|
|
11515
|
+
/** Subscribe a pane to one surface's stream, replaying from `fromByte` (§10.3).
|
|
11516
|
+
*
|
|
11517
|
+
* Returns the bytes the subscriber must replay first, and the offset to resume
|
|
11518
|
+
* streaming from, so the two-step is one call rather than a race between a read
|
|
11519
|
+
* and a subscribe. */
|
|
11520
|
+
subscribe(surface, fromByte, subscriber) {
|
|
11521
|
+
const entry = this.registry.require(surface);
|
|
11522
|
+
const runtime = this.requireRuntime(entry);
|
|
11523
|
+
const replay = runtime.log.read(fromByte);
|
|
11524
|
+
runtime.subscribers.add(subscriber);
|
|
11525
|
+
return replay;
|
|
11526
|
+
}
|
|
11527
|
+
/** Stop delivering to one subscriber. Idempotent. */
|
|
11528
|
+
unsubscribe(surface, subscriber) {
|
|
11529
|
+
this.registry.find(surface)?.runtime?.subscribers.delete(subscriber);
|
|
11530
|
+
}
|
|
11531
|
+
/** Drop every subscriber of one surface: the pane unmounted or switched away.
|
|
11532
|
+
* `find`, because a pane can unsubscribe while the surface is being closed. */
|
|
11533
|
+
unsubscribeAll(surface) {
|
|
11534
|
+
this.registry.find(surface)?.runtime?.subscribers.clear();
|
|
11535
|
+
}
|
|
11536
|
+
/**
|
|
11537
|
+
* Which surface a pane is displaying, or null for none (§10.2's pane ops).
|
|
11538
|
+
*
|
|
11539
|
+
* This is a FACT the app reports, not a lever: it never resizes a surface, and
|
|
11540
|
+
* the pane's own measurements arrive separately. What it buys is the honest
|
|
11541
|
+
* `rendered: "displayed"` value on a capture and the answer to "is anybody
|
|
11542
|
+
* looking at this surface", which is the blip's clearing rule (§12.2).
|
|
11543
|
+
*/
|
|
11544
|
+
setDisplayed(surface) {
|
|
11545
|
+
if (surface !== null) this.registry.require(surface);
|
|
11546
|
+
this.displayed = surface;
|
|
11547
|
+
if (surface === null) {
|
|
11548
|
+
for (const entry of this.registry.list()) {
|
|
11549
|
+
if (entry.runtime) entry.runtime.rect = null;
|
|
11550
|
+
}
|
|
11551
|
+
}
|
|
11552
|
+
this.options.onChanged?.();
|
|
11553
|
+
}
|
|
11554
|
+
/** The surface a pane is displaying, or null. */
|
|
11555
|
+
get displayedSurface() {
|
|
11556
|
+
return this.displayed;
|
|
11557
|
+
}
|
|
11558
|
+
displayed = null;
|
|
11559
|
+
/** The renderer-facing state: what a pane, a blip or a Settings surface reads. */
|
|
11560
|
+
state(sessionId2) {
|
|
11561
|
+
const counts = this.counts();
|
|
11562
|
+
return {
|
|
11563
|
+
available: true,
|
|
11564
|
+
total: counts.total,
|
|
11565
|
+
agent: counts.agent,
|
|
11566
|
+
displayed_surface: this.displayed,
|
|
11567
|
+
surfaces: this.registry.list(sessionId2).map((entry) => ({
|
|
11568
|
+
...this.listing(entry),
|
|
11569
|
+
secure: entry.record.secure,
|
|
11570
|
+
retain: entry.record.retain,
|
|
11571
|
+
sizing: entry.record.sizing,
|
|
11572
|
+
displayed: entry.record.surface === this.displayed,
|
|
11573
|
+
last_mark: entry.runtime?.lastMark ?? null
|
|
11574
|
+
}))
|
|
11575
|
+
};
|
|
11576
|
+
}
|
|
11577
|
+
/** Producer of the surface's own output: the pty's data, in order. This is the
|
|
11578
|
+
* ONE place bytes enter the surface, which is why the log, the record, the
|
|
11579
|
+
* scanner and the subscribers cannot disagree about the stream. */
|
|
11580
|
+
consume(surface, data) {
|
|
11581
|
+
const entry = this.registry.find(surface);
|
|
11582
|
+
const runtime = entry?.runtime;
|
|
11583
|
+
if (!entry || !runtime) return;
|
|
11584
|
+
const bytes = Buffer.from(data, "utf8");
|
|
11585
|
+
runtime.emulator.write(new Uint8Array(bytes));
|
|
11586
|
+
if (!entry.record.secure) runtime.log.append(new Uint8Array(bytes));
|
|
11587
|
+
for (const mark of runtime.scanner.feed(new Uint8Array(bytes))) {
|
|
11588
|
+
runtime.lastMark = mark;
|
|
11589
|
+
this.options.onMark?.(surface, mark);
|
|
11590
|
+
}
|
|
11591
|
+
this.registry.update(surface, { lastActivity: this.now() });
|
|
11592
|
+
if (entry.record.retain) this.schedulePersist(entry, runtime);
|
|
11593
|
+
this.broadcast(entry, runtime, new Uint8Array(bytes));
|
|
11594
|
+
}
|
|
11595
|
+
/** Coalesce output for one event-loop tick, then deliver one frame per
|
|
11596
|
+
* subscriber (§10.3: batching is a delivery concern, never the log's). */
|
|
11597
|
+
broadcast(entry, runtime, bytes) {
|
|
11598
|
+
if (runtime.subscribers.size === 0) return;
|
|
11599
|
+
runtime.pending.push(bytes);
|
|
11600
|
+
if (runtime.pendingTimer) return;
|
|
11601
|
+
runtime.pendingTimer = setTimeout(() => {
|
|
11602
|
+
runtime.pendingTimer = null;
|
|
11603
|
+
const pending = runtime.pending;
|
|
11604
|
+
runtime.pending = [];
|
|
11605
|
+
if (pending.length === 0) return;
|
|
11606
|
+
const joined = concat(pending);
|
|
11607
|
+
runtime.seq += 1;
|
|
11608
|
+
const frame = {
|
|
11609
|
+
surface: entry.record.surface,
|
|
11610
|
+
seq: runtime.seq,
|
|
11611
|
+
bytes: joined
|
|
11612
|
+
};
|
|
11613
|
+
for (const subscriber of runtime.subscribers) subscriber.output(frame);
|
|
11614
|
+
}, OUTPUT_COALESCE_MS);
|
|
11615
|
+
runtime.pendingTimer.unref?.();
|
|
11616
|
+
}
|
|
11617
|
+
exited(surface, exitCode) {
|
|
11618
|
+
const entry = this.registry.find(surface);
|
|
11619
|
+
if (!entry || !entry.runtime) return;
|
|
11620
|
+
this.registry.update(surface, {
|
|
11621
|
+
running: false,
|
|
11622
|
+
exitCode,
|
|
11623
|
+
lastActivity: this.now()
|
|
11624
|
+
});
|
|
11625
|
+
this.bumpExitEpoch(entry);
|
|
11626
|
+
if (entry.record.retain) this.flush(surface);
|
|
11627
|
+
const runtime = entry.runtime;
|
|
11628
|
+
for (const waiter of runtime.exitWaiters.splice(0)) waiter(exitCode);
|
|
11629
|
+
if (!runtime.exitDelivered) {
|
|
11630
|
+
runtime.exitDelivered = true;
|
|
11631
|
+
for (const subscriber of runtime.subscribers) {
|
|
11632
|
+
subscriber.exit({ surface, exitCode });
|
|
11633
|
+
}
|
|
11634
|
+
}
|
|
11635
|
+
this.options.log(
|
|
11636
|
+
`[console] surface ${redactSurface(surface)} exited ${exitCode}`
|
|
11637
|
+
);
|
|
11638
|
+
this.options.onChanged?.();
|
|
11639
|
+
}
|
|
11640
|
+
/** Apply a grid change: clamp, compare, and only then touch the record and the
|
|
11641
|
+
* pty — so one clamp-crossing is one `TIOCSWINSZ` and one `SIGWINCH` (§8.2). */
|
|
11642
|
+
applyGrid(entry, cols, rows) {
|
|
11643
|
+
const grid = clampGrid(cols, rows);
|
|
11644
|
+
if (grid.cols === entry.record.cols && grid.rows === entry.record.rows)
|
|
11645
|
+
return;
|
|
11646
|
+
this.registry.update(entry.record.surface, {
|
|
11647
|
+
cols: grid.cols,
|
|
11648
|
+
rows: grid.rows
|
|
11649
|
+
});
|
|
11650
|
+
entry.runtime?.emulator.resize(grid.cols, grid.rows);
|
|
11651
|
+
if (entry.record.running) entry.runtime?.pty?.resize(grid.cols, grid.rows);
|
|
11652
|
+
this.options.log(
|
|
11653
|
+
`[console] surface ${redactSurface(entry.record.surface)} resized ${grid.cols}x${grid.rows}`
|
|
11654
|
+
);
|
|
11655
|
+
this.options.onChanged?.();
|
|
11656
|
+
}
|
|
11657
|
+
writeToPty(entry, runtime, bytes) {
|
|
11658
|
+
runtime.pty?.write(bytes);
|
|
11659
|
+
this.registry.update(entry.record.surface, { lastActivity: this.now() });
|
|
11660
|
+
}
|
|
11661
|
+
requireRuntime(entry) {
|
|
11662
|
+
if (!entry.runtime) {
|
|
11663
|
+
throw new ConsoleError(
|
|
11664
|
+
"console_unavailable",
|
|
11665
|
+
`${redactSurface(entry.record.surface)} has no runtime in this app run`,
|
|
11666
|
+
{ reason: "no_runtime" }
|
|
11667
|
+
);
|
|
11668
|
+
}
|
|
11669
|
+
return entry.runtime;
|
|
11670
|
+
}
|
|
11671
|
+
requireRunning(entry) {
|
|
11672
|
+
if (!entry.record.running) {
|
|
11673
|
+
throw new ConsoleError(
|
|
11674
|
+
"process_exited",
|
|
11675
|
+
`${redactSurface(entry.record.surface)} has exited${entry.record.exitCode === null ? "" : ` with code ${entry.record.exitCode}`}; its output is still readable`,
|
|
11676
|
+
// `retain`, not `live`, is §10.6's key for this code: the consumer's
|
|
11677
|
+
// sentence says whether the log is retained, and a caller looking for
|
|
11678
|
+
// `retain` would find nothing if this carried `live` instead.
|
|
11679
|
+
{ exit_code: entry.record.exitCode, retain: entry.record.retain }
|
|
11680
|
+
);
|
|
11681
|
+
}
|
|
11682
|
+
}
|
|
11683
|
+
refuseWhenSecure(entry) {
|
|
11684
|
+
if (entry.record.secure) {
|
|
11685
|
+
throw new ConsoleError(
|
|
11686
|
+
"secure_input_active",
|
|
11687
|
+
`${redactSurface(entry.record.surface)} is in secure input; reads and captures are refused until it is turned off`,
|
|
11688
|
+
{ secure: true }
|
|
11689
|
+
);
|
|
11690
|
+
}
|
|
11691
|
+
}
|
|
11692
|
+
/**
|
|
11693
|
+
* Write one retained surface's pending bytes and, when it moved, its sidecar.
|
|
11694
|
+
*
|
|
11695
|
+
* THE BYTES ARE THE SPINE and the sidecar is the exception. Every call appends
|
|
11696
|
+
* whatever the log has that disk does not — one `appendFileSync` for the whole
|
|
11697
|
+
* pending window rather than one per chunk — and the sidecar is rewritten only
|
|
11698
|
+
* when one of its own fields actually changed. It used to be rewritten on every
|
|
11699
|
+
* chunk, which is a staged write, a rename and two `chmod`s per pty read for a
|
|
11700
|
+
* document whose fields move about once a second; the signature below is what
|
|
11701
|
+
* keeps a durable `last_seen_at` without paying that.
|
|
11702
|
+
*/
|
|
11703
|
+
persist(entry, runtime) {
|
|
11704
|
+
const history = this.options.history;
|
|
11705
|
+
if (!history) return;
|
|
11706
|
+
const surface = entry.record.surface;
|
|
11707
|
+
const record = entry.record;
|
|
11708
|
+
const meta = {
|
|
11709
|
+
surface,
|
|
11710
|
+
session_id: record.sessionId,
|
|
11711
|
+
origin: record.origin,
|
|
11712
|
+
command: record.command,
|
|
11713
|
+
argv: record.argvTail ? record.argvTail.split(" ") : [],
|
|
11714
|
+
cwd: record.cwd,
|
|
11715
|
+
cols: record.cols,
|
|
11716
|
+
rows: record.rows,
|
|
11717
|
+
// The creation time is the record's own first `last_activity`; a sidecar is
|
|
11718
|
+
// rewritten rather than appended, so it carries what is true now. A surface
|
|
11719
|
+
// restored from history seeded this from the sidecar it came from, so a
|
|
11720
|
+
// relaunch cannot rewrite its own creation time (§7.3).
|
|
11721
|
+
created_at: this.createdAt.get(surface) ?? this.now(),
|
|
11722
|
+
last_seen_at: record.lastActivity,
|
|
11723
|
+
exit_code: record.exitCode,
|
|
11724
|
+
truncated: runtime.log.truncated,
|
|
11725
|
+
exit_epoch: this.exitEpoch.get(surface) ?? 0
|
|
11726
|
+
};
|
|
11727
|
+
if (!this.createdAt.has(surface))
|
|
11728
|
+
this.createdAt.set(surface, meta.created_at);
|
|
11729
|
+
if (!this.persistedBytes.has(surface)) this.persistedBytes.set(surface, 0);
|
|
11730
|
+
const already = this.persistedBytes.get(surface) ?? 0;
|
|
11731
|
+
const end = runtime.log.endOffset;
|
|
11732
|
+
if (end > already) {
|
|
11733
|
+
const slice = runtime.log.read(already);
|
|
11734
|
+
history.append(record.sessionId, surface, slice.bytes, meta);
|
|
11735
|
+
this.persistedBytes.set(surface, slice.to);
|
|
11736
|
+
}
|
|
11737
|
+
const signature = metaSignature(meta);
|
|
11738
|
+
if (this.metaSignatures.get(surface) === signature) return;
|
|
11739
|
+
history.writeMeta(meta);
|
|
11740
|
+
this.metaSignatures.set(surface, signature);
|
|
11741
|
+
}
|
|
11742
|
+
/**
|
|
11743
|
+
* Flush a surface's pending bytes and sidecar NOW.
|
|
11744
|
+
*
|
|
11745
|
+
* Called at every point the coalescing must not be observable: `close`,
|
|
11746
|
+
* `dispose`, a process exit, a secure toggle, and the create that writes the
|
|
11747
|
+
* first sidecar. Also the seam a test drives the durable state through without
|
|
11748
|
+
* waiting a quarter of a second.
|
|
11749
|
+
*/
|
|
11750
|
+
flush(surface) {
|
|
11751
|
+
const entry = this.registry.find(surface);
|
|
11752
|
+
const runtime = entry?.runtime;
|
|
11753
|
+
if (!entry || !runtime) return;
|
|
11754
|
+
if (runtime.persistTimer) {
|
|
11755
|
+
clearTimeout(runtime.persistTimer);
|
|
11756
|
+
runtime.persistTimer = null;
|
|
11757
|
+
}
|
|
11758
|
+
if (!entry.record.retain) return;
|
|
11759
|
+
this.persist(entry, runtime);
|
|
11760
|
+
}
|
|
11761
|
+
/**
|
|
11762
|
+
* Hand the newest bytes to the bounded flush (§7.2's cap, §18.3's starvation).
|
|
11763
|
+
*
|
|
11764
|
+
* A threshold crossing flushes immediately and a trickle waits for the timer,
|
|
11765
|
+
* which is the two cases this has to get right: a flood must not hold megabytes
|
|
11766
|
+
* in memory, and a single prompt line must be on disk without waiting for the
|
|
11767
|
+
* next byte to arrive. Neither path writes per chunk, which is the defect this
|
|
11768
|
+
* exists to close.
|
|
11769
|
+
*/
|
|
11770
|
+
schedulePersist(entry, runtime) {
|
|
11771
|
+
const surface = entry.record.surface;
|
|
11772
|
+
const pending = runtime.log.endOffset - (this.persistedBytes.get(surface) ?? 0);
|
|
11773
|
+
if (pending >= PERSIST_FLUSH_BYTES) {
|
|
11774
|
+
this.flush(surface);
|
|
11775
|
+
return;
|
|
11776
|
+
}
|
|
11777
|
+
if (runtime.persistTimer) return;
|
|
11778
|
+
runtime.persistTimer = setTimeout(() => {
|
|
11779
|
+
runtime.persistTimer = null;
|
|
11780
|
+
this.flush(surface);
|
|
11781
|
+
}, PERSIST_FLUSH_MS);
|
|
11782
|
+
}
|
|
11783
|
+
/**
|
|
11784
|
+
* The surface's next exit generation (§7.3).
|
|
11785
|
+
*
|
|
11786
|
+
* The arithmetic lives in the retention layer (`ConsoleHistory.nextExitEpoch`)
|
|
11787
|
+
* because the sidecar is the only place a generation SURVIVES a relaunch: a
|
|
11788
|
+
* surface replayed under its own handle and run again must report generation 2
|
|
11789
|
+
* on its second exit, and a counter kept here alone would start at 1 again. This
|
|
11790
|
+
* method is the in-process half — the value the running host reports through
|
|
11791
|
+
* `console_status` — and the max() in the history is what keeps the two from
|
|
11792
|
+
* disagreeing when a write was lost.
|
|
11793
|
+
*/
|
|
11794
|
+
bumpExitEpoch(entry) {
|
|
11795
|
+
const surface = entry.record.surface;
|
|
11796
|
+
const live = this.exitEpoch.get(surface) ?? 0;
|
|
11797
|
+
const next = this.options.history?.nextExitEpoch(
|
|
11798
|
+
entry.record.sessionId,
|
|
11799
|
+
surface,
|
|
11800
|
+
live
|
|
11801
|
+
) ?? live + 1;
|
|
11802
|
+
this.exitEpoch.set(surface, next);
|
|
11803
|
+
return next;
|
|
11804
|
+
}
|
|
11805
|
+
/** Forget a surface: its runtime, its record and its subscribers. The ONE
|
|
11806
|
+
* removal path, so the registry and the runtime map cannot drift. */
|
|
11807
|
+
drop(surface) {
|
|
11808
|
+
const entry = this.registry.require(surface);
|
|
11809
|
+
const runtime = entry.runtime;
|
|
11810
|
+
if (runtime) {
|
|
11811
|
+
if (runtime.pendingTimer) clearTimeout(runtime.pendingTimer);
|
|
11812
|
+
if (runtime.persistTimer) clearTimeout(runtime.persistTimer);
|
|
11813
|
+
runtime.subscribers.clear();
|
|
11814
|
+
runtime.emulator.dispose();
|
|
11815
|
+
}
|
|
11816
|
+
this.registry.remove(surface);
|
|
11817
|
+
this.createdAt.delete(surface);
|
|
11818
|
+
this.persistedBytes.delete(surface);
|
|
11819
|
+
this.metaSignatures.delete(surface);
|
|
11820
|
+
this.exitEpoch.delete(surface);
|
|
11821
|
+
this.options.onChanged?.();
|
|
11822
|
+
}
|
|
11823
|
+
/** Stop everything: every pty, every record, every subscriber. */
|
|
11824
|
+
dispose() {
|
|
11825
|
+
for (const entry of this.registry.clear()) {
|
|
11826
|
+
const runtime = entry.runtime;
|
|
11827
|
+
if (!runtime) continue;
|
|
11828
|
+
if (runtime.pendingTimer) clearTimeout(runtime.pendingTimer);
|
|
11829
|
+
if (runtime.persistTimer) clearTimeout(runtime.persistTimer);
|
|
11830
|
+
runtime.subscribers.clear();
|
|
11831
|
+
if (entry.record.retain) this.persist(entry, runtime);
|
|
11832
|
+
if (entry.record.running) runtime.pty?.kill();
|
|
11833
|
+
runtime.emulator.dispose();
|
|
11834
|
+
}
|
|
11835
|
+
this.createdAt.clear();
|
|
11836
|
+
this.persistedBytes.clear();
|
|
11837
|
+
this.metaSignatures.clear();
|
|
11838
|
+
this.exitEpoch.clear();
|
|
11839
|
+
}
|
|
11840
|
+
/** Creation times, kept here rather than on the wire record: nothing outside
|
|
11841
|
+
* this class reads them, and the record is what a listing serialises. */
|
|
11842
|
+
createdAt = /* @__PURE__ */ new Map();
|
|
11843
|
+
/** How many bytes of each surface's log have been written to disk, so an append
|
|
11844
|
+
* never re-writes what is already there. */
|
|
11845
|
+
persistedBytes = /* @__PURE__ */ new Map();
|
|
11846
|
+
/** The last sidecar content written per surface, so an unchanged document is
|
|
11847
|
+
* not rewritten on every flush. */
|
|
11848
|
+
metaSignatures = /* @__PURE__ */ new Map();
|
|
11849
|
+
/** Each surface's exit generation, seeded from the sidecar on restore (§7.3). */
|
|
11850
|
+
exitEpoch = /* @__PURE__ */ new Map();
|
|
11851
|
+
}
|
|
11852
|
+
function metaSignature(meta) {
|
|
11853
|
+
return [
|
|
11854
|
+
meta.exit_code,
|
|
11855
|
+
meta.truncated,
|
|
11856
|
+
meta.cols,
|
|
11857
|
+
meta.rows,
|
|
11858
|
+
meta.exit_epoch,
|
|
11859
|
+
Math.floor(meta.last_seen_at)
|
|
11860
|
+
].join("|");
|
|
11861
|
+
}
|
|
11862
|
+
function clampGrid(cols, rows) {
|
|
11863
|
+
return {
|
|
11864
|
+
cols: clamp(
|
|
11865
|
+
Number.isFinite(cols) ? Math.floor(cols) : DEFAULT_COLS,
|
|
11866
|
+
MIN_COLS,
|
|
11867
|
+
MAX_COLS
|
|
11868
|
+
),
|
|
11869
|
+
rows: clamp(
|
|
11870
|
+
Number.isFinite(rows) ? Math.floor(rows) : DEFAULT_ROWS,
|
|
11871
|
+
MIN_ROWS,
|
|
11872
|
+
MAX_ROWS
|
|
11873
|
+
)
|
|
11874
|
+
};
|
|
11875
|
+
}
|
|
11876
|
+
function clamp(value, min, max) {
|
|
11877
|
+
return Math.min(Math.max(value, min), max);
|
|
11878
|
+
}
|
|
11879
|
+
function surfaceEnvironment(input) {
|
|
11880
|
+
const env = {};
|
|
11881
|
+
for (const [key, value] of Object.entries(input.base)) {
|
|
11882
|
+
if (value === void 0) continue;
|
|
11883
|
+
if (STRIPPED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)))
|
|
11884
|
+
continue;
|
|
11885
|
+
env[key] = value;
|
|
11886
|
+
}
|
|
11887
|
+
env.TERM = SURFACE_TERM;
|
|
11888
|
+
env.COLORTERM = SURFACE_COLORTERM;
|
|
11889
|
+
env[SURFACE_ENV_MARKER] = input.surface;
|
|
11890
|
+
env[SESSION_ENV_MARKER] = input.sessionId;
|
|
11891
|
+
for (const [key, value] of Object.entries(input.overrides ?? {})) {
|
|
11892
|
+
env[key] = value;
|
|
11893
|
+
}
|
|
11894
|
+
return env;
|
|
11895
|
+
}
|
|
11896
|
+
function defaultShell(env) {
|
|
11897
|
+
const shell = env.SHELL?.trim();
|
|
11898
|
+
if (shell && path$1.isAbsolute(shell)) return shell;
|
|
11899
|
+
return "/bin/zsh";
|
|
11900
|
+
}
|
|
11901
|
+
function resolveCwd(cwd) {
|
|
11902
|
+
const wanted = cwd?.trim();
|
|
11903
|
+
if (wanted && path$1.isAbsolute(wanted)) return wanted;
|
|
11904
|
+
return os.homedir();
|
|
11905
|
+
}
|
|
11906
|
+
function wrapBracketedPaste(bytes) {
|
|
11907
|
+
const start = new TextEncoder().encode("\x1B[200~");
|
|
11908
|
+
const end = new TextEncoder().encode("\x1B[201~");
|
|
11909
|
+
const out = new Uint8Array(start.length + bytes.length + end.length);
|
|
11910
|
+
out.set(start, 0);
|
|
11911
|
+
out.set(bytes, start.length);
|
|
11912
|
+
out.set(end, start.length + bytes.length);
|
|
11913
|
+
return out;
|
|
11914
|
+
}
|
|
11915
|
+
function cropToWindow(rect, bounds) {
|
|
11916
|
+
const x = Math.max(Math.floor(rect.x), 0);
|
|
11917
|
+
const y = Math.max(Math.floor(rect.y), 0);
|
|
11918
|
+
return {
|
|
11919
|
+
x,
|
|
11920
|
+
y,
|
|
11921
|
+
width: Math.max(Math.min(Math.floor(rect.width), bounds.width - x), 1),
|
|
11922
|
+
height: Math.max(Math.min(Math.floor(rect.height), bounds.height - y), 1)
|
|
11923
|
+
};
|
|
11924
|
+
}
|
|
11925
|
+
function framePng(image2) {
|
|
11926
|
+
return image2.isEmpty() ? Buffer.alloc(0) : image2.toPNG();
|
|
11927
|
+
}
|
|
11928
|
+
const ENCODER = new TextEncoder();
|
|
11929
|
+
function delay(ms) {
|
|
11930
|
+
return new Promise((resolve) => {
|
|
11931
|
+
setTimeout(resolve, ms);
|
|
11932
|
+
});
|
|
11933
|
+
}
|
|
11934
|
+
function concat(chunks) {
|
|
11935
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
11936
|
+
const out = new Uint8Array(total);
|
|
11937
|
+
let offset = 0;
|
|
11938
|
+
for (const chunk of chunks) {
|
|
11939
|
+
out.set(chunk, offset);
|
|
11940
|
+
offset += chunk.length;
|
|
11941
|
+
}
|
|
11942
|
+
return out;
|
|
11943
|
+
}
|
|
11944
|
+
const METHODS = [
|
|
11945
|
+
"console_list",
|
|
11946
|
+
"console_create",
|
|
11947
|
+
"console_status",
|
|
11948
|
+
"console_read",
|
|
11949
|
+
"console_screenshot",
|
|
11950
|
+
"console_input",
|
|
11951
|
+
"console_keys",
|
|
11952
|
+
"console_resize",
|
|
11953
|
+
"console_secure",
|
|
11954
|
+
"console_close"
|
|
11955
|
+
];
|
|
11956
|
+
function isConsoleDispatchMethod(method) {
|
|
11957
|
+
return METHODS.includes(method);
|
|
11958
|
+
}
|
|
11959
|
+
async function dispatchConsole(host, method, params) {
|
|
11960
|
+
switch (method) {
|
|
11961
|
+
case "console_list": {
|
|
11962
|
+
const sessionId2 = optionalString$1(params.session_id, "session_id");
|
|
11963
|
+
const surfaces = host.list(sessionId2);
|
|
11964
|
+
return { surfaces, count: surfaces.length };
|
|
11965
|
+
}
|
|
11966
|
+
case "console_create": {
|
|
11967
|
+
const cols = optionalGrid(params.cols, "cols", DEFAULT_COLS);
|
|
11968
|
+
const rows = optionalGrid(params.rows, "rows", DEFAULT_ROWS);
|
|
11969
|
+
const clamped = clampGrid(cols, rows);
|
|
11970
|
+
if (clamped.cols !== cols || clamped.rows !== rows) {
|
|
11971
|
+
throw new ConsoleError(
|
|
11972
|
+
"invalid_grid",
|
|
11973
|
+
`${cols}x${rows} is outside the supported grid; the clamp would be ${clamped.cols}x${clamped.rows}`,
|
|
11974
|
+
// §10.6's key for this code is the nested `clamp`, which is what a
|
|
11975
|
+
// consumer's sentence names — the requested grid is already in the
|
|
11976
|
+
// message above it.
|
|
11977
|
+
{ clamp: clamped }
|
|
11978
|
+
);
|
|
11979
|
+
}
|
|
11980
|
+
return {
|
|
11981
|
+
...host.create({
|
|
11982
|
+
sessionId: requiredString(params.session_id, "session_id"),
|
|
11983
|
+
origin: "agent",
|
|
11984
|
+
cwd: optionalString$1(params.cwd, "cwd"),
|
|
11985
|
+
command: optionalString$1(params.command, "command"),
|
|
11986
|
+
args: optionalStringList(params.args, "args"),
|
|
11987
|
+
input: optionalString$1(params.input, "input"),
|
|
11988
|
+
env: optionalEnv(params.env),
|
|
11989
|
+
cols,
|
|
11990
|
+
rows,
|
|
11991
|
+
reveal: optionalReveal(params.reveal),
|
|
11992
|
+
retain: optionalBoolean(params.retain, "retain"),
|
|
11993
|
+
sizing: optionalSizing(params.sizing)
|
|
11994
|
+
})
|
|
11995
|
+
};
|
|
11996
|
+
}
|
|
11997
|
+
case "console_status": {
|
|
11998
|
+
return host.status(requiredSurface(params.surface));
|
|
11999
|
+
}
|
|
12000
|
+
case "console_read": {
|
|
12001
|
+
return host.read(
|
|
12002
|
+
requiredSurface(params.surface),
|
|
12003
|
+
requiredMode(params.mode),
|
|
12004
|
+
{
|
|
12005
|
+
start: optionalCount(params.start, "start"),
|
|
12006
|
+
count: optionalCount(params.count, "count")
|
|
12007
|
+
}
|
|
12008
|
+
);
|
|
12009
|
+
}
|
|
12010
|
+
case "console_screenshot": {
|
|
12011
|
+
const format = optionalString$1(params.format, "format");
|
|
12012
|
+
if (format !== void 0 && format !== "png") {
|
|
12013
|
+
throw new ConsoleError(
|
|
12014
|
+
"unsupported_method",
|
|
12015
|
+
`this app version captures png only, not ${JSON.stringify(format)}`,
|
|
12016
|
+
{ format }
|
|
12017
|
+
);
|
|
12018
|
+
}
|
|
12019
|
+
return host.screenshot(requiredSurface(params.surface));
|
|
12020
|
+
}
|
|
12021
|
+
case "console_input": {
|
|
12022
|
+
const surface = requiredSurface(params.surface);
|
|
12023
|
+
const text = optionalString$1(params.text, "text");
|
|
12024
|
+
const bytes = optionalBytes(params.bytes);
|
|
12025
|
+
if (text === void 0 && bytes === void 0) {
|
|
12026
|
+
throw new ConsoleError(
|
|
12027
|
+
"internal",
|
|
12028
|
+
"console_input needs text or bytes; a secret_ref is resolved by the tool before the call",
|
|
12029
|
+
{ surface }
|
|
12030
|
+
);
|
|
12031
|
+
}
|
|
12032
|
+
return host.input(surface, {
|
|
12033
|
+
text,
|
|
12034
|
+
bytes,
|
|
12035
|
+
paste: optionalBoolean(params.paste, "paste")
|
|
12036
|
+
});
|
|
12037
|
+
}
|
|
12038
|
+
case "console_keys": {
|
|
12039
|
+
const surface = requiredSurface(params.surface);
|
|
12040
|
+
const keys = params.keys;
|
|
12041
|
+
if (!Array.isArray(keys) || keys.length === 0 || keys.some((key) => typeof key !== "string")) {
|
|
12042
|
+
throw new ConsoleError(
|
|
12043
|
+
"unknown_key",
|
|
12044
|
+
"console_keys needs a non-empty list of key names",
|
|
12045
|
+
{}
|
|
12046
|
+
);
|
|
12047
|
+
}
|
|
12048
|
+
return host.keys(surface, keys);
|
|
12049
|
+
}
|
|
12050
|
+
case "console_resize": {
|
|
12051
|
+
const surface = requiredSurface(params.surface);
|
|
12052
|
+
const cols = requiredInteger(params.cols, "cols");
|
|
12053
|
+
const rows = requiredInteger(params.rows, "rows");
|
|
12054
|
+
const clamped = clampGrid(cols, rows);
|
|
12055
|
+
if (clamped.cols !== cols || clamped.rows !== rows) {
|
|
12056
|
+
throw new ConsoleError(
|
|
12057
|
+
"invalid_grid",
|
|
12058
|
+
`${cols}x${rows} is outside the supported grid; the clamp would be ${clamped.cols}x${clamped.rows}`,
|
|
12059
|
+
{ clamp: clamped }
|
|
12060
|
+
);
|
|
12061
|
+
}
|
|
12062
|
+
return host.resize(surface, cols, rows);
|
|
12063
|
+
}
|
|
12064
|
+
case "console_secure": {
|
|
12065
|
+
const surface = requiredSurface(params.surface);
|
|
12066
|
+
const on = params.on;
|
|
12067
|
+
if (typeof on !== "boolean") {
|
|
12068
|
+
throw new ConsoleError(
|
|
12069
|
+
"internal",
|
|
12070
|
+
"console_secure needs `on` to be true or false",
|
|
12071
|
+
{ surface }
|
|
12072
|
+
);
|
|
12073
|
+
}
|
|
12074
|
+
return host.setSecure(surface, on);
|
|
12075
|
+
}
|
|
12076
|
+
case "console_close": {
|
|
12077
|
+
const surface = requiredSurface(params.surface);
|
|
12078
|
+
return host.close(surface, {
|
|
12079
|
+
kill: optionalBoolean(params.kill, "kill"),
|
|
12080
|
+
retain: optionalBoolean(params.retain, "retain")
|
|
12081
|
+
});
|
|
12082
|
+
}
|
|
12083
|
+
}
|
|
12084
|
+
}
|
|
12085
|
+
function requiredSurface(value) {
|
|
12086
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
12087
|
+
throw new ConsoleError(
|
|
12088
|
+
"surface_unavailable",
|
|
12089
|
+
"a console command needs a surface handle",
|
|
12090
|
+
{ param: "surface" }
|
|
12091
|
+
);
|
|
12092
|
+
}
|
|
12093
|
+
return value;
|
|
12094
|
+
}
|
|
12095
|
+
function requiredString(value, name) {
|
|
12096
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
12097
|
+
throw new ConsoleError("internal", `${name} must be a non-empty string`, {
|
|
12098
|
+
param: name
|
|
12099
|
+
});
|
|
12100
|
+
}
|
|
12101
|
+
return value;
|
|
12102
|
+
}
|
|
12103
|
+
function optionalString$1(value, name) {
|
|
12104
|
+
if (value === void 0 || value === null) return void 0;
|
|
12105
|
+
return requiredString(value, name);
|
|
12106
|
+
}
|
|
12107
|
+
function optionalStringList(value, name) {
|
|
12108
|
+
if (value === void 0 || value === null) return void 0;
|
|
12109
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
12110
|
+
throw new ConsoleError("internal", `${name} must be a list of strings`, {
|
|
12111
|
+
param: name
|
|
12112
|
+
});
|
|
12113
|
+
}
|
|
12114
|
+
return value;
|
|
12115
|
+
}
|
|
12116
|
+
function optionalBoolean(value, name) {
|
|
12117
|
+
if (value === void 0 || value === null) return void 0;
|
|
12118
|
+
if (typeof value !== "boolean") {
|
|
12119
|
+
throw new ConsoleError("internal", `${name} must be true or false`, {
|
|
12120
|
+
param: name
|
|
12121
|
+
});
|
|
12122
|
+
}
|
|
12123
|
+
return value;
|
|
12124
|
+
}
|
|
12125
|
+
function optionalGrid(value, name, fallback) {
|
|
12126
|
+
if (value === void 0 || value === null) {
|
|
12127
|
+
return fallback;
|
|
12128
|
+
}
|
|
12129
|
+
return requiredInteger(value, name);
|
|
12130
|
+
}
|
|
12131
|
+
function requiredInteger(value, name) {
|
|
12132
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
12133
|
+
throw new ConsoleError("invalid_grid", `${name} must be a whole number`, {
|
|
12134
|
+
param: name
|
|
12135
|
+
});
|
|
12136
|
+
}
|
|
12137
|
+
return value;
|
|
12138
|
+
}
|
|
12139
|
+
function optionalCount(value, name) {
|
|
12140
|
+
if (value === void 0 || value === null) return void 0;
|
|
12141
|
+
const parsed = requiredInteger(value, name);
|
|
12142
|
+
if (parsed < 0) {
|
|
12143
|
+
throw new ConsoleError("internal", `${name} must not be negative`, {
|
|
12144
|
+
param: name
|
|
12145
|
+
});
|
|
12146
|
+
}
|
|
12147
|
+
return parsed;
|
|
12148
|
+
}
|
|
12149
|
+
function requiredMode(value) {
|
|
12150
|
+
if (value === "viewport" || value === "scrollback") return value;
|
|
12151
|
+
throw new ConsoleError(
|
|
12152
|
+
"internal",
|
|
12153
|
+
'mode must be "viewport" or "scrollback"',
|
|
12154
|
+
{
|
|
12155
|
+
param: "mode"
|
|
12156
|
+
}
|
|
12157
|
+
);
|
|
12158
|
+
}
|
|
12159
|
+
function optionalReveal(value) {
|
|
12160
|
+
if (value === void 0 || value === null) return void 0;
|
|
12161
|
+
if (value === "none" || value === "session" || value === "open") return value;
|
|
12162
|
+
throw new ConsoleError(
|
|
12163
|
+
"internal",
|
|
12164
|
+
'reveal must be "none", "session" or "open"',
|
|
12165
|
+
{
|
|
12166
|
+
param: "reveal"
|
|
12167
|
+
}
|
|
12168
|
+
);
|
|
12169
|
+
}
|
|
12170
|
+
function optionalSizing(value) {
|
|
12171
|
+
if (value === void 0 || value === null) return void 0;
|
|
12172
|
+
if (value === "auto" || value === "fixed") return value;
|
|
12173
|
+
throw new ConsoleError("internal", 'sizing must be "auto" or "fixed"', {
|
|
12174
|
+
param: "sizing"
|
|
12175
|
+
});
|
|
12176
|
+
}
|
|
12177
|
+
function optionalEnv(value) {
|
|
12178
|
+
if (value === void 0 || value === null) return void 0;
|
|
12179
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
12180
|
+
throw new ConsoleError("internal", "env must be an object of strings", {
|
|
12181
|
+
param: "env"
|
|
12182
|
+
});
|
|
12183
|
+
}
|
|
12184
|
+
const out = {};
|
|
12185
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
12186
|
+
if (typeof raw !== "string") {
|
|
12187
|
+
throw new ConsoleError("internal", `env.${key} must be a string`, {
|
|
12188
|
+
param: "env",
|
|
12189
|
+
key
|
|
12190
|
+
});
|
|
12191
|
+
}
|
|
12192
|
+
out[key] = raw;
|
|
12193
|
+
}
|
|
12194
|
+
return out;
|
|
12195
|
+
}
|
|
12196
|
+
function optionalBytes(value) {
|
|
12197
|
+
if (value === void 0 || value === null) return void 0;
|
|
12198
|
+
if (!Array.isArray(value) || value.some(
|
|
12199
|
+
(byte) => typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255
|
|
12200
|
+
)) {
|
|
12201
|
+
throw new ConsoleError(
|
|
12202
|
+
"internal",
|
|
12203
|
+
"bytes must be a list of integers between 0 and 255",
|
|
12204
|
+
{ param: "bytes" }
|
|
12205
|
+
);
|
|
12206
|
+
}
|
|
12207
|
+
return Uint8Array.from(value);
|
|
12208
|
+
}
|
|
12209
|
+
path$1.join("run", "ui-console", "history");
|
|
12210
|
+
const HISTORY_DIR_MODE = 448;
|
|
12211
|
+
const HISTORY_FILE_MODE = 384;
|
|
12212
|
+
const DEFAULT_HISTORY_DAYS = 30;
|
|
12213
|
+
const HISTORY_ROTATE_BYTES = RETAIN_BYTES * 2;
|
|
12214
|
+
function fileStem(surface) {
|
|
12215
|
+
return surface.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
12216
|
+
}
|
|
12217
|
+
function sessionDir(root, sessionId2) {
|
|
12218
|
+
return path$1.join(root, fileStem(sessionId2));
|
|
12219
|
+
}
|
|
12220
|
+
function logPath(root, sessionId2, surface) {
|
|
12221
|
+
return path$1.join(sessionDir(root, sessionId2), `${fileStem(surface)}.log`);
|
|
12222
|
+
}
|
|
12223
|
+
function sidecarPath(root, sessionId2, surface) {
|
|
12224
|
+
return path$1.join(sessionDir(root, sessionId2), `${fileStem(surface)}.json`);
|
|
12225
|
+
}
|
|
12226
|
+
class ConsoleHistory {
|
|
12227
|
+
constructor(root, options = {}) {
|
|
12228
|
+
this.root = root;
|
|
12229
|
+
this.now = options.now ?? (() => Date.now() / 1e3);
|
|
12230
|
+
}
|
|
12231
|
+
now;
|
|
12232
|
+
/** Write the sidecar. Staged and renamed, so a reader sees a whole document
|
|
12233
|
+
* or the previous one — never half a JSON file that would read as absent. */
|
|
12234
|
+
writeMeta(meta) {
|
|
12235
|
+
const path2 = sidecarPath(this.root, meta.session_id, meta.surface);
|
|
12236
|
+
fs.mkdirSync(path$1.dirname(path2), { recursive: true, mode: HISTORY_DIR_MODE });
|
|
12237
|
+
const staged = `${path2}.${process.pid}.tmp`;
|
|
12238
|
+
fs.writeFileSync(staged, `${JSON.stringify(meta, null, 2)}
|
|
12239
|
+
`, {
|
|
12240
|
+
mode: HISTORY_FILE_MODE
|
|
12241
|
+
});
|
|
12242
|
+
fs.renameSync(staged, path2);
|
|
12243
|
+
enforceModes(path2);
|
|
12244
|
+
}
|
|
12245
|
+
/**
|
|
12246
|
+
* Append bytes, rotating when the file passes the rotate bound.
|
|
12247
|
+
*
|
|
12248
|
+
* Rotation is what keeps a long-lived surface's history bounded: appending
|
|
12249
|
+
* forever is the one shape that turns "retained history" into an unbounded
|
|
12250
|
+
* directory. `truncated` is the caller's to carry — this method reports whether
|
|
12251
|
+
* it rotated, and the host sets the flag on the sidecar, because the sidecar is
|
|
12252
|
+
* the only place the flag is durable.
|
|
12253
|
+
*/
|
|
12254
|
+
append(sessionId2, surface, bytes, meta) {
|
|
12255
|
+
const path2 = logPath(this.root, sessionId2, surface);
|
|
12256
|
+
try {
|
|
12257
|
+
fs.mkdirSync(path$1.dirname(path2), { recursive: true, mode: HISTORY_DIR_MODE });
|
|
12258
|
+
fs.appendFileSync(path2, bytes, { mode: HISTORY_FILE_MODE });
|
|
12259
|
+
enforceModes(path2);
|
|
12260
|
+
let size = 0;
|
|
12261
|
+
try {
|
|
12262
|
+
size = fs.statSync(path2).size;
|
|
12263
|
+
} catch {
|
|
12264
|
+
size = 0;
|
|
12265
|
+
}
|
|
12266
|
+
if (size <= HISTORY_ROTATE_BYTES) return { rotated: false };
|
|
12267
|
+
const all = fs.readFileSync(path2);
|
|
12268
|
+
const kept = all.subarray(Math.max(all.length - RETAIN_BYTES, 0));
|
|
12269
|
+
const staged = `${path2}.${process.pid}.tmp`;
|
|
12270
|
+
fs.writeFileSync(staged, kept, { mode: HISTORY_FILE_MODE });
|
|
12271
|
+
fs.renameSync(staged, path2);
|
|
12272
|
+
enforceModes(path2);
|
|
12273
|
+
meta.truncated = true;
|
|
12274
|
+
meta.last_seen_at = this.now();
|
|
12275
|
+
this.writeMeta(meta);
|
|
12276
|
+
return { rotated: true };
|
|
12277
|
+
} catch {
|
|
12278
|
+
return { rotated: false };
|
|
12279
|
+
}
|
|
12280
|
+
}
|
|
12281
|
+
/** Load one surface's history, or null. Never creates anything. */
|
|
12282
|
+
load(sessionId2, surface) {
|
|
12283
|
+
const metaPath = sidecarPath(this.root, sessionId2, surface);
|
|
12284
|
+
if (!fs.existsSync(metaPath)) return null;
|
|
12285
|
+
try {
|
|
12286
|
+
const meta = parseMeta(fs.readFileSync(metaPath, "utf8"));
|
|
12287
|
+
const logFile = logPath(this.root, sessionId2, surface);
|
|
12288
|
+
const bytes = fs.existsSync(logFile) ? fs.readFileSync(logFile) : Buffer.alloc(0);
|
|
12289
|
+
return { meta, bytes: new Uint8Array(bytes) };
|
|
12290
|
+
} catch {
|
|
12291
|
+
return null;
|
|
12292
|
+
}
|
|
12293
|
+
}
|
|
12294
|
+
/** One sidecar, or null. Never creates anything, and never throws. */
|
|
12295
|
+
loadSidecar(sessionId2, surface) {
|
|
12296
|
+
const path2 = sidecarPath(this.root, sessionId2, surface);
|
|
12297
|
+
if (!fs.existsSync(path2)) return null;
|
|
12298
|
+
try {
|
|
12299
|
+
return parseMeta(fs.readFileSync(path2, "utf8"));
|
|
12300
|
+
} catch {
|
|
12301
|
+
return null;
|
|
12302
|
+
}
|
|
12303
|
+
}
|
|
12304
|
+
/**
|
|
12305
|
+
* Every retained surface, optionally for one session.
|
|
12306
|
+
*
|
|
12307
|
+
* A directory listing rather than an index file: an index is a second source
|
|
12308
|
+
* of truth that a crash between the two writes can desynchronise, and the
|
|
12309
|
+
* number of sessions here is small enough that a readdir is not a cost.
|
|
12310
|
+
*/
|
|
12311
|
+
list(sessionId2) {
|
|
12312
|
+
const sessions = sessionId2 === void 0 ? this.sessionDirs() : [fileStem(sessionId2)];
|
|
12313
|
+
const loaded = [];
|
|
12314
|
+
for (const stem of sessions) {
|
|
12315
|
+
const dir = path$1.join(this.root, stem);
|
|
12316
|
+
let files;
|
|
12317
|
+
try {
|
|
12318
|
+
files = fs.readdirSync(dir);
|
|
12319
|
+
} catch {
|
|
12320
|
+
continue;
|
|
12321
|
+
}
|
|
12322
|
+
for (const file of files) {
|
|
12323
|
+
if (!file.endsWith(".json")) continue;
|
|
12324
|
+
try {
|
|
12325
|
+
const meta = parseMeta(fs.readFileSync(path$1.join(dir, file), "utf8"));
|
|
12326
|
+
const logFile = path$1.join(dir, `${file.slice(0, -".json".length)}.log`);
|
|
12327
|
+
const bytes = fs.existsSync(logFile) ? fs.readFileSync(logFile) : Buffer.alloc(0);
|
|
12328
|
+
loaded.push({ meta, bytes: new Uint8Array(bytes) });
|
|
12329
|
+
} catch {
|
|
12330
|
+
}
|
|
12331
|
+
}
|
|
12332
|
+
}
|
|
12333
|
+
return loaded;
|
|
12334
|
+
}
|
|
12335
|
+
/**
|
|
12336
|
+
* The exit generation this sidecar currently records, or 0 when nothing has
|
|
12337
|
+
* exited yet (§7.3).
|
|
12338
|
+
*
|
|
12339
|
+
* Read from the FILE rather than from a field the caller carries: the durable
|
|
12340
|
+
* record is the only place a generation can outlive the process, and a caller
|
|
12341
|
+
* that has just restored a surface is holding exactly the meta this reads.
|
|
12342
|
+
*/
|
|
12343
|
+
exitEpoch(sessionId2, surface) {
|
|
12344
|
+
const loaded = this.loadSidecar(sessionId2, surface);
|
|
12345
|
+
return loaded?.exit_epoch ?? 0;
|
|
12346
|
+
}
|
|
12347
|
+
/**
|
|
12348
|
+
* The generation the surface's NEXT exit will carry (§7.3).
|
|
12349
|
+
*
|
|
12350
|
+
* `live` is what the running host has seen in this process, and it is combined
|
|
12351
|
+
* with the sidecar rather than replacing it: a write that failed, or a surface
|
|
12352
|
+
* replayed from history and run again, must never move the generation
|
|
12353
|
+
* backwards — two exits sharing one generation is exactly the collision the
|
|
12354
|
+
* counter exists to make impossible (§12.3).
|
|
12355
|
+
*/
|
|
12356
|
+
nextExitEpoch(sessionId2, surface, live) {
|
|
12357
|
+
return Math.max(this.exitEpoch(sessionId2, surface), live) + 1;
|
|
12358
|
+
}
|
|
12359
|
+
/** Remove one surface's history, log and sidecar. */
|
|
12360
|
+
remove(sessionId2, surface) {
|
|
12361
|
+
for (const path2 of [
|
|
12362
|
+
logPath(this.root, sessionId2, surface),
|
|
12363
|
+
sidecarPath(this.root, sessionId2, surface)
|
|
12364
|
+
]) {
|
|
12365
|
+
try {
|
|
12366
|
+
fs.rmSync(path2, { force: true });
|
|
12367
|
+
} catch {
|
|
12368
|
+
}
|
|
12369
|
+
}
|
|
12370
|
+
}
|
|
12371
|
+
/**
|
|
12372
|
+
* Remove a session's whole history, for the session-deleted path (§6.7).
|
|
12373
|
+
*
|
|
12374
|
+
* CALLED BY NOTHING YET, and that is the deferred row rather than dead code:
|
|
12375
|
+
* §6.7's session-deleted row is disclosed as unimplemented (there is no
|
|
12376
|
+
* session-lifecycle signal for main to subscribe to in this PR), so this is the
|
|
12377
|
+
* seam the wiring will call from. It lives here rather than in the caller
|
|
12378
|
+
* because the GC and this method are the only two things that remove a
|
|
12379
|
+
* DIRECTORY, and one implementation of that is the point.
|
|
12380
|
+
*/
|
|
12381
|
+
removeSession(sessionId2) {
|
|
12382
|
+
try {
|
|
12383
|
+
fs.rmSync(sessionDir(this.root, sessionId2), {
|
|
12384
|
+
recursive: true,
|
|
12385
|
+
force: true
|
|
12386
|
+
});
|
|
12387
|
+
} catch {
|
|
12388
|
+
}
|
|
12389
|
+
}
|
|
12390
|
+
/**
|
|
12391
|
+
* The GC pass (§7.4): drop session directories whose newest file is older than
|
|
12392
|
+
* the window, and report what it removed rather than doing it silently.
|
|
12393
|
+
*/
|
|
12394
|
+
collect(days = DEFAULT_HISTORY_DAYS) {
|
|
12395
|
+
const report = { removedSessions: [], removedSurfaces: 0 };
|
|
12396
|
+
const cutoff = this.now() - days * 24 * 60 * 60;
|
|
12397
|
+
for (const stem of this.sessionDirs()) {
|
|
12398
|
+
const dir = path$1.join(this.root, stem);
|
|
12399
|
+
let newest = 0;
|
|
12400
|
+
let files;
|
|
12401
|
+
try {
|
|
12402
|
+
files = fs.readdirSync(dir);
|
|
12403
|
+
} catch {
|
|
12404
|
+
continue;
|
|
12405
|
+
}
|
|
12406
|
+
for (const file of files) {
|
|
12407
|
+
try {
|
|
12408
|
+
newest = Math.max(newest, fs.statSync(path$1.join(dir, file)).mtimeMs / 1e3);
|
|
12409
|
+
} catch {
|
|
12410
|
+
}
|
|
12411
|
+
}
|
|
12412
|
+
if (newest === 0 || newest >= cutoff) continue;
|
|
12413
|
+
report.removedSurfaces += files.filter(
|
|
12414
|
+
(file) => file.endsWith(".json")
|
|
12415
|
+
).length;
|
|
12416
|
+
try {
|
|
12417
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
12418
|
+
report.removedSessions.push(stem);
|
|
12419
|
+
} catch {
|
|
12420
|
+
}
|
|
12421
|
+
}
|
|
12422
|
+
return report;
|
|
12423
|
+
}
|
|
12424
|
+
sessionDirs() {
|
|
12425
|
+
try {
|
|
12426
|
+
return fs.readdirSync(this.root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
12427
|
+
} catch {
|
|
12428
|
+
return [];
|
|
12429
|
+
}
|
|
12430
|
+
}
|
|
12431
|
+
}
|
|
12432
|
+
function parseMeta(raw) {
|
|
12433
|
+
const meta = JSON.parse(raw);
|
|
12434
|
+
if (typeof meta.exit_epoch !== "number") meta.exit_epoch = 0;
|
|
12435
|
+
return meta;
|
|
12436
|
+
}
|
|
12437
|
+
function enforceModes(path2) {
|
|
12438
|
+
try {
|
|
12439
|
+
fs.chmodSync(path$1.dirname(path2), HISTORY_DIR_MODE);
|
|
12440
|
+
fs.chmodSync(path2, HISTORY_FILE_MODE);
|
|
12441
|
+
} catch {
|
|
12442
|
+
}
|
|
12443
|
+
}
|
|
12444
|
+
const CONSOLE_IPC_CHANNELS = [
|
|
12445
|
+
"console-state",
|
|
12446
|
+
"console-create-surface",
|
|
12447
|
+
"console-open-pane",
|
|
12448
|
+
"console-close-pane",
|
|
12449
|
+
"console-select-surface",
|
|
12450
|
+
"console-input",
|
|
12451
|
+
"console-keys",
|
|
12452
|
+
"console-content-rect",
|
|
12453
|
+
"console-secure-toggle",
|
|
12454
|
+
"console-subscribe",
|
|
12455
|
+
"console-unsubscribe"
|
|
12456
|
+
];
|
|
12457
|
+
function registerConsoleIpc(options) {
|
|
12458
|
+
function authorize(event) {
|
|
12459
|
+
const owner = options.window();
|
|
12460
|
+
if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
|
|
12461
|
+
throw new Error("This window cannot use the console.");
|
|
12462
|
+
}
|
|
12463
|
+
const host = options.host();
|
|
12464
|
+
if (!host) throw new Error("The console is not running.");
|
|
12465
|
+
return host;
|
|
12466
|
+
}
|
|
12467
|
+
electron.ipcMain.handle(
|
|
12468
|
+
"console-state",
|
|
12469
|
+
(event, sessionId2) => authorize(event).state(optionalString(sessionId2, "sessionId"))
|
|
12470
|
+
);
|
|
12471
|
+
electron.ipcMain.handle("console-create-surface", (event, request) => {
|
|
12472
|
+
const host = authorize(event);
|
|
12473
|
+
const input = request && typeof request === "object" ? request : {};
|
|
12474
|
+
const fields = input;
|
|
12475
|
+
const surface = host.create({
|
|
12476
|
+
sessionId: stringOrThrow(fields.sessionId, "sessionId"),
|
|
12477
|
+
origin: "user",
|
|
12478
|
+
cwd: typeof fields.cwd === "string" ? fields.cwd : void 0,
|
|
12479
|
+
command: typeof fields.command === "string" && fields.command.trim() ? fields.command.trim() : void 0,
|
|
12480
|
+
cols: typeof fields.cols === "number" ? fields.cols : void 0,
|
|
12481
|
+
rows: typeof fields.rows === "number" ? fields.rows : void 0,
|
|
12482
|
+
// A user's own surface is born displayed: they asked for it from the pane
|
|
12483
|
+
// they are looking at, and the renderer is the one that opens the pane. No
|
|
12484
|
+
// `reveal` is honoured here because a renderer request cannot need to ask
|
|
12485
|
+
// for a pane it is already in.
|
|
12486
|
+
reveal: "none"
|
|
12487
|
+
});
|
|
12488
|
+
return { ...surface };
|
|
12489
|
+
});
|
|
12490
|
+
electron.ipcMain.handle("console-open-pane", (event, surface) => {
|
|
12491
|
+
const host = authorize(event);
|
|
12492
|
+
host.setDisplayed(stringOrThrow(surface, "surface"));
|
|
12493
|
+
return host.state();
|
|
12494
|
+
});
|
|
12495
|
+
electron.ipcMain.handle("console-close-pane", (event) => {
|
|
12496
|
+
const host = authorize(event);
|
|
12497
|
+
host.setDisplayed(null);
|
|
12498
|
+
return host.state();
|
|
12499
|
+
});
|
|
12500
|
+
electron.ipcMain.handle("console-select-surface", (event, surface) => {
|
|
12501
|
+
const host = authorize(event);
|
|
12502
|
+
host.setDisplayed(stringOrThrow(surface, "surface"));
|
|
12503
|
+
return host.state();
|
|
12504
|
+
});
|
|
12505
|
+
electron.ipcMain.handle(
|
|
12506
|
+
"console-input",
|
|
12507
|
+
async (event, surface, text) => {
|
|
12508
|
+
const host = authorize(event);
|
|
12509
|
+
if (typeof text !== "string") {
|
|
12510
|
+
throw new Error("Console input must be a string.");
|
|
12511
|
+
}
|
|
12512
|
+
return host.input(stringOrThrow(surface, "surface"), { text });
|
|
12513
|
+
}
|
|
12514
|
+
);
|
|
12515
|
+
electron.ipcMain.handle(
|
|
12516
|
+
"console-keys",
|
|
12517
|
+
async (event, surface, keys) => {
|
|
12518
|
+
const host = authorize(event);
|
|
12519
|
+
if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
|
|
12520
|
+
throw new Error("Console keys must be a list of names.");
|
|
12521
|
+
}
|
|
12522
|
+
return host.keys(stringOrThrow(surface, "surface"), keys);
|
|
12523
|
+
}
|
|
12524
|
+
);
|
|
12525
|
+
electron.ipcMain.handle(
|
|
12526
|
+
"console-content-rect",
|
|
12527
|
+
(event, surface, report) => {
|
|
12528
|
+
const host = authorize(event);
|
|
12529
|
+
host.setContentRect(
|
|
12530
|
+
stringOrThrow(surface, "surface"),
|
|
12531
|
+
contentReportOrThrow(report)
|
|
12532
|
+
);
|
|
12533
|
+
return host.state();
|
|
12534
|
+
}
|
|
12535
|
+
);
|
|
12536
|
+
electron.ipcMain.handle(
|
|
12537
|
+
"console-secure-toggle",
|
|
12538
|
+
(event, surface, on) => {
|
|
12539
|
+
const host = authorize(event);
|
|
12540
|
+
if (typeof on !== "boolean") {
|
|
12541
|
+
throw new Error("The secure toggle takes a boolean.");
|
|
12542
|
+
}
|
|
12543
|
+
return host.setSecure(stringOrThrow(surface, "surface"), on);
|
|
12544
|
+
}
|
|
12545
|
+
);
|
|
12546
|
+
electron.ipcMain.handle(
|
|
12547
|
+
"console-subscribe",
|
|
12548
|
+
(event, surface, fromByte) => {
|
|
12549
|
+
const host = authorize(event);
|
|
12550
|
+
const token = stringOrThrow(surface, "surface");
|
|
12551
|
+
const from = typeof fromByte === "number" && Number.isFinite(fromByte) ? Math.max(Math.trunc(fromByte), 0) : 0;
|
|
12552
|
+
const owner = options.window();
|
|
12553
|
+
const subscriber = {
|
|
12554
|
+
output: (frame) => {
|
|
12555
|
+
const target2 = owner && !owner.isDestroyed() ? owner.webContents : null;
|
|
12556
|
+
target2?.send("console-output", {
|
|
12557
|
+
surface: frame.surface,
|
|
12558
|
+
seq: frame.seq,
|
|
12559
|
+
// Base64 rather than an array of numbers: a frame can carry a
|
|
12560
|
+
// megabyte, and the renderer decodes it once into a Uint8Array.
|
|
12561
|
+
bytes_base64: Buffer.from(frame.bytes).toString("base64")
|
|
12562
|
+
});
|
|
12563
|
+
},
|
|
12564
|
+
exit: (frame) => {
|
|
12565
|
+
const target2 = owner && !owner.isDestroyed() ? owner.webContents : null;
|
|
12566
|
+
target2?.send("console-exit", {
|
|
12567
|
+
surface: frame.surface,
|
|
12568
|
+
exit_code: frame.exitCode
|
|
12569
|
+
});
|
|
12570
|
+
}
|
|
12571
|
+
};
|
|
12572
|
+
const replay = host.subscribe(token, from, subscriber);
|
|
12573
|
+
return {
|
|
12574
|
+
surface: token,
|
|
12575
|
+
replay_base64: Buffer.from(replay.bytes).toString("base64"),
|
|
12576
|
+
from_byte: replay.from,
|
|
12577
|
+
to_byte: replay.to,
|
|
12578
|
+
truncated: replay.truncated
|
|
12579
|
+
};
|
|
12580
|
+
}
|
|
12581
|
+
);
|
|
12582
|
+
electron.ipcMain.handle("console-unsubscribe", (event, surface) => {
|
|
12583
|
+
const host = authorize(event);
|
|
12584
|
+
host.unsubscribeAll(stringOrThrow(surface, "surface"));
|
|
12585
|
+
return { unsubscribed: true };
|
|
12586
|
+
});
|
|
12587
|
+
}
|
|
12588
|
+
function unregisterConsoleIpc() {
|
|
12589
|
+
for (const channel of CONSOLE_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
|
|
12590
|
+
}
|
|
12591
|
+
function stringOrThrow(value, name) {
|
|
12592
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
12593
|
+
throw new Error(`${name} must be a non-empty string.`);
|
|
12594
|
+
}
|
|
12595
|
+
return value;
|
|
12596
|
+
}
|
|
12597
|
+
function optionalString(value, name) {
|
|
12598
|
+
if (value === void 0 || value === null) return void 0;
|
|
12599
|
+
return stringOrThrow(value, name);
|
|
12600
|
+
}
|
|
12601
|
+
function contentReportOrThrow(value) {
|
|
12602
|
+
if (!value || typeof value !== "object") {
|
|
12603
|
+
throw new Error("A console content report is required.");
|
|
12604
|
+
}
|
|
12605
|
+
const report = value;
|
|
12606
|
+
const rect = report.contentRect;
|
|
12607
|
+
if (!rect || typeof rect !== "object") {
|
|
12608
|
+
throw new Error("A console content report needs a contentRect.");
|
|
12609
|
+
}
|
|
12610
|
+
const numbers = ["x", "y", "width", "height"].map((key) => {
|
|
12611
|
+
const raw = rect[key];
|
|
12612
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
12613
|
+
throw new Error(`contentRect.${key} must be a number.`);
|
|
12614
|
+
}
|
|
12615
|
+
return raw;
|
|
12616
|
+
});
|
|
12617
|
+
if (numbers[2] <= 0 || numbers[3] <= 0) {
|
|
12618
|
+
throw new Error("A console content rect needs a positive size.");
|
|
12619
|
+
}
|
|
12620
|
+
const cellWidth = positiveNumber(report.cellWidth, "cellWidth");
|
|
12621
|
+
const cellHeight = positiveNumber(report.cellHeight, "cellHeight");
|
|
12622
|
+
if (typeof report.visible !== "boolean") {
|
|
12623
|
+
throw new Error("A console content report says whether it is visible.");
|
|
12624
|
+
}
|
|
12625
|
+
const theme = typeof report.theme === "string" || report.theme === null ? report.theme : null;
|
|
12626
|
+
return {
|
|
12627
|
+
contentRect: {
|
|
12628
|
+
x: numbers[0],
|
|
12629
|
+
y: numbers[1],
|
|
12630
|
+
width: numbers[2],
|
|
12631
|
+
height: numbers[3]
|
|
12632
|
+
},
|
|
12633
|
+
cellWidth,
|
|
12634
|
+
cellHeight,
|
|
12635
|
+
visible: report.visible,
|
|
12636
|
+
theme
|
|
12637
|
+
};
|
|
12638
|
+
}
|
|
12639
|
+
function positiveNumber(value, name) {
|
|
12640
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
12641
|
+
throw new Error(`${name} must be a positive number.`);
|
|
12642
|
+
}
|
|
12643
|
+
return value;
|
|
12644
|
+
}
|
|
12645
|
+
const PTY_PACKAGE = "node-pty";
|
|
12646
|
+
const SPAWN_HELPER_NAME = "spawn-helper";
|
|
12647
|
+
const SPAWN_HELPER_MODE = 493;
|
|
12648
|
+
const OWNER_EXECUTE = 64;
|
|
12649
|
+
const ASAR_APP = /([/\\])app\.asar([/\\])/;
|
|
12650
|
+
const ASAR_NODE_MODULES = /([/\\])node_modules\.asar([/\\])/;
|
|
12651
|
+
function unpackedPath(path2) {
|
|
12652
|
+
return path2.replace(ASAR_APP, "$1app.asar.unpacked$2").replace(ASAR_NODE_MODULES, "$1node_modules.asar.unpacked$2");
|
|
12653
|
+
}
|
|
12654
|
+
function prebuildDir(root, target2) {
|
|
12655
|
+
return path$1.join(root, "prebuilds", `${target2.platform}-${target2.arch}`);
|
|
12656
|
+
}
|
|
12657
|
+
function spawnHelperPath(root, target2) {
|
|
12658
|
+
return path$1.join(prebuildDir(root, target2), SPAWN_HELPER_NAME);
|
|
12659
|
+
}
|
|
12660
|
+
function resolvePtyRoot() {
|
|
12661
|
+
const requireFrom = node_module.createRequire(
|
|
12662
|
+
typeof __filename === "string" ? __filename : require("url").pathToFileURL(__filename).href
|
|
12663
|
+
);
|
|
12664
|
+
return unpackedPath(
|
|
12665
|
+
path$1.dirname(requireFrom.resolve(`${PTY_PACKAGE}/package.json`))
|
|
12666
|
+
);
|
|
12667
|
+
}
|
|
12668
|
+
function ensureSpawnHelperExecutable(root, target2) {
|
|
12669
|
+
const path2 = spawnHelperPath(root, target2);
|
|
12670
|
+
try {
|
|
12671
|
+
if (!fs.existsSync(path2)) {
|
|
12672
|
+
return { path: null, mode: null, healed: false, reason: "no helper" };
|
|
12673
|
+
}
|
|
12674
|
+
const mode = fs.statSync(path2).mode & 511;
|
|
12675
|
+
if ((mode & OWNER_EXECUTE) !== 0) {
|
|
12676
|
+
return { path: path2, mode, healed: false };
|
|
12677
|
+
}
|
|
12678
|
+
fs.chmodSync(path2, SPAWN_HELPER_MODE);
|
|
12679
|
+
return { path: path2, mode, healed: true };
|
|
12680
|
+
} catch (error) {
|
|
12681
|
+
return {
|
|
12682
|
+
path: path2,
|
|
12683
|
+
mode: null,
|
|
12684
|
+
healed: false,
|
|
12685
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
12686
|
+
};
|
|
12687
|
+
}
|
|
12688
|
+
}
|
|
12689
|
+
let cached = null;
|
|
12690
|
+
function loadNodePty() {
|
|
12691
|
+
if (cached) return cached;
|
|
12692
|
+
try {
|
|
12693
|
+
const root = resolvePtyRoot();
|
|
12694
|
+
const requireFrom = node_module.createRequire(
|
|
12695
|
+
typeof __filename === "string" ? __filename : require("url").pathToFileURL(__filename).href
|
|
12696
|
+
);
|
|
12697
|
+
const namespace = requireFrom(PTY_PACKAGE);
|
|
12698
|
+
const module = "spawn" in namespace ? namespace : namespace.default;
|
|
12699
|
+
if (typeof module?.spawn !== "function") {
|
|
12700
|
+
cached = { ok: false, reason: "node-pty loaded but exports no spawn()" };
|
|
12701
|
+
return cached;
|
|
12702
|
+
}
|
|
12703
|
+
const helper = ensureSpawnHelperExecutable(root, {
|
|
12704
|
+
platform: process.platform,
|
|
12705
|
+
arch: process.arch
|
|
12706
|
+
});
|
|
12707
|
+
cached = { ok: true, root, helper, module };
|
|
12708
|
+
return cached;
|
|
12709
|
+
} catch (error) {
|
|
12710
|
+
cached = {
|
|
12711
|
+
ok: false,
|
|
12712
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
12713
|
+
};
|
|
12714
|
+
return cached;
|
|
12715
|
+
}
|
|
12716
|
+
}
|
|
12717
|
+
function spawnPty(module, options, onHeal) {
|
|
12718
|
+
const load = cached;
|
|
12719
|
+
if (load?.ok) {
|
|
12720
|
+
const report = ensureSpawnHelperExecutable(load.root, {
|
|
12721
|
+
platform: process.platform,
|
|
12722
|
+
arch: process.arch
|
|
12723
|
+
});
|
|
12724
|
+
if (report.healed) onHeal?.(report);
|
|
12725
|
+
}
|
|
12726
|
+
return module.spawn(options.file, options.args, {
|
|
12727
|
+
// The surface's own name, so a program that asks what terminal it is on gets
|
|
12728
|
+
// the pinned answer the window's TERM already declares (design 6.6).
|
|
12729
|
+
name: "xterm-256color",
|
|
12730
|
+
cols: options.cols,
|
|
12731
|
+
rows: options.rows,
|
|
12732
|
+
cwd: options.cwd,
|
|
12733
|
+
env: options.env
|
|
12734
|
+
});
|
|
12735
|
+
}
|
|
12736
|
+
const CONSOLE_HOST_ENV = "LOCAL_OPERATOR_UI_CONSOLE_HOST";
|
|
12737
|
+
const CONSOLE_REQUIRED_OPTIONS = [
|
|
12738
|
+
"window",
|
|
12739
|
+
"expectedUrl",
|
|
12740
|
+
"appVersion",
|
|
12741
|
+
"log"
|
|
12742
|
+
];
|
|
12743
|
+
function missingConsoleOptions(options) {
|
|
12744
|
+
return CONSOLE_REQUIRED_OPTIONS.filter(
|
|
12745
|
+
(field) => options[field] === void 0
|
|
12746
|
+
);
|
|
12747
|
+
}
|
|
12748
|
+
function consoleHostEnabled(env = process.env) {
|
|
12749
|
+
const flag = env[CONSOLE_HOST_ENV]?.trim().toLowerCase();
|
|
12750
|
+
return !(flag === "0" || flag === "false" || flag === "off");
|
|
12751
|
+
}
|
|
12752
|
+
function broadcastConsoleState(window) {
|
|
12753
|
+
if (window.isDestroyed()) return;
|
|
12754
|
+
window.webContents.send("console-state-changed");
|
|
12755
|
+
}
|
|
12756
|
+
async function startConsoleHost(options) {
|
|
12757
|
+
const missing = missingConsoleOptions(options);
|
|
12758
|
+
if (missing.length > 0) {
|
|
12759
|
+
throw new Error(
|
|
12760
|
+
`startConsoleHost needs ${missing.join(", ")}: without it the host starts into a condition it invented rather than a failure`
|
|
12761
|
+
);
|
|
12762
|
+
}
|
|
12763
|
+
const { log } = options;
|
|
12764
|
+
if (!consoleHostEnabled()) {
|
|
12765
|
+
log(
|
|
12766
|
+
`[console] disabled by ${CONSOLE_HOST_ENV}; no surfaces will be created and the record carries console: false`
|
|
12767
|
+
);
|
|
12768
|
+
return {
|
|
12769
|
+
ok: false,
|
|
12770
|
+
reason: "disabled",
|
|
12771
|
+
detail: `${CONSOLE_HOST_ENV} is off`,
|
|
12772
|
+
stop: async () => {
|
|
12773
|
+
}
|
|
12774
|
+
};
|
|
12775
|
+
}
|
|
12776
|
+
const load = loadNodePty();
|
|
12777
|
+
if (!load.ok) {
|
|
12778
|
+
log(`[console] node-pty is unavailable: ${load.reason}`);
|
|
12779
|
+
return {
|
|
12780
|
+
ok: false,
|
|
12781
|
+
reason: "pty_unavailable",
|
|
12782
|
+
detail: load.reason,
|
|
12783
|
+
stop: async () => {
|
|
12784
|
+
}
|
|
12785
|
+
};
|
|
12786
|
+
}
|
|
12787
|
+
if (load.helper.healed) {
|
|
12788
|
+
log(
|
|
12789
|
+
`[console] restored the exec bit on ${load.helper.path ?? "spawn-helper"}`
|
|
12790
|
+
);
|
|
12791
|
+
}
|
|
12792
|
+
const configDir = options.configDir ?? localOperatorConfigDir();
|
|
12793
|
+
const history = new ConsoleHistory(
|
|
12794
|
+
path$1.join(configDir, "run", "ui-console", "history")
|
|
12795
|
+
);
|
|
12796
|
+
const registry = new ConsoleRegistry();
|
|
12797
|
+
const host = new ConsoleHost(registry, {
|
|
12798
|
+
window: () => options.window,
|
|
12799
|
+
log,
|
|
12800
|
+
history,
|
|
12801
|
+
now: options.now,
|
|
12802
|
+
spawn: options.spawn ?? ((spawnOptions) => spawnPty(load.module, spawnOptions, (report) => {
|
|
12803
|
+
log(
|
|
12804
|
+
`[console] restored the exec bit on ${report.path ?? "spawn-helper"}`
|
|
12805
|
+
);
|
|
12806
|
+
})),
|
|
12807
|
+
onChanged: () => {
|
|
12808
|
+
broadcastConsoleState(options.window);
|
|
12809
|
+
},
|
|
12810
|
+
onReveal: (request) => {
|
|
12811
|
+
const window = options.window;
|
|
12812
|
+
if (window.isDestroyed()) return;
|
|
12813
|
+
window.webContents.send("console-reveal", {
|
|
12814
|
+
surface: request.surface,
|
|
12815
|
+
session_id: request.sessionId,
|
|
12816
|
+
mode: request.mode
|
|
12817
|
+
});
|
|
12818
|
+
}
|
|
12819
|
+
});
|
|
12820
|
+
if (options.restoreHistory !== false) {
|
|
12821
|
+
const retained = history.list().sort((a, b) => b.meta.last_seen_at - a.meta.last_seen_at).slice(0, MAX_SURFACES_PER_APP);
|
|
12822
|
+
for (const surface of retained) {
|
|
12823
|
+
host.restore(surface.meta, surface.bytes);
|
|
12824
|
+
}
|
|
12825
|
+
if (retained.length > 0) {
|
|
12826
|
+
log(
|
|
12827
|
+
`[console] restored ${retained.length} retained surface(s) from history; none of them is running`
|
|
12828
|
+
);
|
|
12829
|
+
}
|
|
12830
|
+
}
|
|
12831
|
+
const collected = history.collect();
|
|
12832
|
+
if (collected.removedSessions.length > 0) {
|
|
12833
|
+
log(
|
|
12834
|
+
`[console] history GC removed ${collected.removedSurfaces} surface(s) from ${collected.removedSessions.length} session(s) older than the window`
|
|
12835
|
+
);
|
|
12836
|
+
}
|
|
12837
|
+
registerConsoleIpc({
|
|
12838
|
+
window: () => options.window,
|
|
12839
|
+
expectedUrl: options.expectedUrl,
|
|
12840
|
+
host: () => host,
|
|
12841
|
+
log
|
|
12842
|
+
});
|
|
12843
|
+
log(
|
|
12844
|
+
`[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${load.helper.mode === null ? "without a helper" : "ready"})`
|
|
12845
|
+
);
|
|
12846
|
+
return {
|
|
12847
|
+
ok: true,
|
|
12848
|
+
host,
|
|
12849
|
+
registry,
|
|
12850
|
+
counts: () => host.counts(),
|
|
12851
|
+
dispatch: async (method, params) => {
|
|
12852
|
+
if (!isConsoleDispatchMethod(method)) {
|
|
12853
|
+
throw new Error(`not a console method: ${method}`);
|
|
12854
|
+
}
|
|
12855
|
+
return dispatchConsole(host, method, params);
|
|
12856
|
+
},
|
|
12857
|
+
stop: async () => {
|
|
12858
|
+
unregisterConsoleIpc();
|
|
12859
|
+
host.dispose();
|
|
12860
|
+
log("[console] host stopped");
|
|
12861
|
+
}
|
|
12862
|
+
};
|
|
12863
|
+
}
|
|
10171
12864
|
const WEBAUTHN_GROUP_SUFFIX = ".webauthn";
|
|
10172
12865
|
const WEBAUTHN_PROMPT_REASON = "use a passkey for $1";
|
|
10173
12866
|
const WEBAUTHN_CHOOSER_TIMEOUT_MS = 6e4;
|
|
@@ -10613,13 +13306,6 @@ function readJson(path2) {
|
|
|
10613
13306
|
return null;
|
|
10614
13307
|
}
|
|
10615
13308
|
}
|
|
10616
|
-
class BridgeCommandError extends Error {
|
|
10617
|
-
constructor(code, message, data = {}) {
|
|
10618
|
-
super(message);
|
|
10619
|
-
this.code = code;
|
|
10620
|
-
this.data = data;
|
|
10621
|
-
}
|
|
10622
|
-
}
|
|
10623
13309
|
function errorData(error) {
|
|
10624
13310
|
return error instanceof BridgeCommandError ? error.data : {};
|
|
10625
13311
|
}
|
|
@@ -13278,53 +15964,6 @@ function retitle() {
|
|
|
13278
15964
|
reason: "the browser tab is labelled with the page title"
|
|
13279
15965
|
};
|
|
13280
15966
|
}
|
|
13281
|
-
const PROTO_VERSION = 1;
|
|
13282
|
-
const METHODS = [
|
|
13283
|
-
"open",
|
|
13284
|
-
"goto",
|
|
13285
|
-
"read",
|
|
13286
|
-
"snapshot",
|
|
13287
|
-
"screenshot",
|
|
13288
|
-
"click",
|
|
13289
|
-
"type",
|
|
13290
|
-
"close",
|
|
13291
|
-
"status",
|
|
13292
|
-
"tabs",
|
|
13293
|
-
"scroll",
|
|
13294
|
-
"logs",
|
|
13295
|
-
"request_access",
|
|
13296
|
-
"await_access",
|
|
13297
|
-
"cancel_access",
|
|
13298
|
-
"retitle",
|
|
13299
|
-
"owner_recover",
|
|
13300
|
-
"owner_finish",
|
|
13301
|
-
"owner_retain",
|
|
13302
|
-
"owner_release"
|
|
13303
|
-
];
|
|
13304
|
-
const METHOD_SET = new Set(METHODS);
|
|
13305
|
-
function isMethod(value) {
|
|
13306
|
-
return typeof value === "string" && METHOD_SET.has(value);
|
|
13307
|
-
}
|
|
13308
|
-
const ERROR_CODES = [
|
|
13309
|
-
"extension_disconnected",
|
|
13310
|
-
"not_paired",
|
|
13311
|
-
"tab_closed",
|
|
13312
|
-
"nav_failed",
|
|
13313
|
-
"nav_timeout",
|
|
13314
|
-
"element_not_found",
|
|
13315
|
-
"origin_denied",
|
|
13316
|
-
"origin_prompt_pending",
|
|
13317
|
-
"origin_not_allowed",
|
|
13318
|
-
"debugger_conflict",
|
|
13319
|
-
"access_queue_full",
|
|
13320
|
-
"tab_limit",
|
|
13321
|
-
"tab_ambiguous",
|
|
13322
|
-
"busy",
|
|
13323
|
-
"proto_mismatch",
|
|
13324
|
-
"owner_refused",
|
|
13325
|
-
"extension_unresponsive",
|
|
13326
|
-
"internal"
|
|
13327
|
-
];
|
|
13328
15967
|
const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
|
|
13329
15968
|
"read",
|
|
13330
15969
|
"snapshot",
|
|
@@ -14668,119 +17307,6 @@ function flushBrowserStorage(browserSession) {
|
|
|
14668
17307
|
} catch {
|
|
14669
17308
|
}
|
|
14670
17309
|
}
|
|
14671
|
-
const RUN_DIRNAME = path$1.join("run", "ui-browser");
|
|
14672
|
-
const STATE_FILENAME = "host.json";
|
|
14673
|
-
const STATE_DIR_MODE = 448;
|
|
14674
|
-
const STATE_FILE_MODE = 384;
|
|
14675
|
-
const HEARTBEAT_INTERVAL_MS = 15e3;
|
|
14676
|
-
function localOperatorConfigDir() {
|
|
14677
|
-
const configured = process.env.LOCAL_OPERATOR_CONFIG_DIR?.trim();
|
|
14678
|
-
if (configured) return configured;
|
|
14679
|
-
return path$1.join(os.homedir(), ".local-operator");
|
|
14680
|
-
}
|
|
14681
|
-
function stateFilePath(root = localOperatorConfigDir()) {
|
|
14682
|
-
return path$1.join(root, RUN_DIRNAME, STATE_FILENAME);
|
|
14683
|
-
}
|
|
14684
|
-
function mintSessionKey() {
|
|
14685
|
-
return node_crypto.randomBytes(32).toString("base64url");
|
|
14686
|
-
}
|
|
14687
|
-
function keysMatch(presented, expected) {
|
|
14688
|
-
if (typeof presented !== "string") return false;
|
|
14689
|
-
const a = Buffer.from(presented, "utf8");
|
|
14690
|
-
const b = Buffer.from(expected, "utf8");
|
|
14691
|
-
if (a.length !== b.length) return false;
|
|
14692
|
-
return node_crypto.timingSafeEqual(a, b);
|
|
14693
|
-
}
|
|
14694
|
-
class BrowserStateWriter {
|
|
14695
|
-
constructor(path2, facts, options) {
|
|
14696
|
-
this.path = path2;
|
|
14697
|
-
this.facts = facts;
|
|
14698
|
-
this.now = options.now ?? Date.now;
|
|
14699
|
-
this.onError = options.onError ?? (() => {
|
|
14700
|
-
});
|
|
14701
|
-
this.appVersion = options.appVersion;
|
|
14702
|
-
}
|
|
14703
|
-
now;
|
|
14704
|
-
onError;
|
|
14705
|
-
timer = null;
|
|
14706
|
-
written = false;
|
|
14707
|
-
appVersion;
|
|
14708
|
-
/** Publish the record and start the heartbeat. The heartbeat is the
|
|
14709
|
-
* difference between "the app is running and can open a tab on demand" and a
|
|
14710
|
-
* record that names a process that died without cleaning up. */
|
|
14711
|
-
start(port, sessionKey) {
|
|
14712
|
-
const startedAt = this.now() / 1e3;
|
|
14713
|
-
this.startedAt = startedAt;
|
|
14714
|
-
this.port = port;
|
|
14715
|
-
this.sessionKey = sessionKey;
|
|
14716
|
-
this.write();
|
|
14717
|
-
this.timer = setInterval(() => this.write(), HEARTBEAT_INTERVAL_MS);
|
|
14718
|
-
this.timer.unref?.();
|
|
14719
|
-
}
|
|
14720
|
-
startedAt = 0;
|
|
14721
|
-
port = 0;
|
|
14722
|
-
sessionKey = "";
|
|
14723
|
-
/** Re-publish immediately, without waiting for the next heartbeat. Called
|
|
14724
|
-
* when something the record reports has changed (a tab opened or closed). */
|
|
14725
|
-
publishNow() {
|
|
14726
|
-
if (this.port === 0) return;
|
|
14727
|
-
this.write();
|
|
14728
|
-
}
|
|
14729
|
-
write() {
|
|
14730
|
-
try {
|
|
14731
|
-
const facts = this.facts();
|
|
14732
|
-
const state = {
|
|
14733
|
-
pid: process.pid,
|
|
14734
|
-
port: this.port,
|
|
14735
|
-
session_key: this.sessionKey,
|
|
14736
|
-
proto: PROTO_VERSION,
|
|
14737
|
-
host: "ui",
|
|
14738
|
-
app_version: this.appVersion,
|
|
14739
|
-
profile_dir: facts.profileDir,
|
|
14740
|
-
tabs: facts.tabs,
|
|
14741
|
-
agent_tabs: facts.agentTabs,
|
|
14742
|
-
heartbeat_at: this.now() / 1e3,
|
|
14743
|
-
started_at: this.startedAt || this.now() / 1e3
|
|
14744
|
-
};
|
|
14745
|
-
fs.mkdirSync(path$1.dirname(this.path), {
|
|
14746
|
-
recursive: true,
|
|
14747
|
-
mode: STATE_DIR_MODE
|
|
14748
|
-
});
|
|
14749
|
-
const staged = `${this.path}.${process.pid}.tmp`;
|
|
14750
|
-
fs.writeFileSync(staged, `${JSON.stringify(state, null, 2)}
|
|
14751
|
-
`, {
|
|
14752
|
-
mode: STATE_FILE_MODE
|
|
14753
|
-
});
|
|
14754
|
-
fs.renameSync(staged, this.path);
|
|
14755
|
-
enforceStateFileModes(this.path);
|
|
14756
|
-
this.written = true;
|
|
14757
|
-
} catch (error) {
|
|
14758
|
-
this.onError(error);
|
|
14759
|
-
}
|
|
14760
|
-
}
|
|
14761
|
-
/** Remove the record, so discovery is honest when the host is gone. Called on
|
|
14762
|
-
* host stop, on quit, and by the Settings toggle that turns the host off. */
|
|
14763
|
-
clear() {
|
|
14764
|
-
if (this.timer) {
|
|
14765
|
-
clearInterval(this.timer);
|
|
14766
|
-
this.timer = null;
|
|
14767
|
-
}
|
|
14768
|
-
if (!this.written) return;
|
|
14769
|
-
try {
|
|
14770
|
-
fs.unlinkSync(this.path);
|
|
14771
|
-
} catch (error) {
|
|
14772
|
-
this.onError(error);
|
|
14773
|
-
}
|
|
14774
|
-
this.written = false;
|
|
14775
|
-
}
|
|
14776
|
-
}
|
|
14777
|
-
function enforceStateFileModes(path2) {
|
|
14778
|
-
try {
|
|
14779
|
-
fs.chmodSync(path$1.dirname(path2), STATE_DIR_MODE);
|
|
14780
|
-
fs.chmodSync(path2, STATE_FILE_MODE);
|
|
14781
|
-
} catch {
|
|
14782
|
-
}
|
|
14783
|
-
}
|
|
14784
17310
|
const RPC_PATH = "/rpc";
|
|
14785
17311
|
const HEALTH_PATH = "/health";
|
|
14786
17312
|
const KEY_HEADER = "x-bridge-key";
|
|
@@ -14810,6 +17336,16 @@ function readBody(req) {
|
|
|
14810
17336
|
req.on("error", reject);
|
|
14811
17337
|
});
|
|
14812
17338
|
}
|
|
17339
|
+
const CONSOLE_NAME = /^console_[a-z0-9_]+$/;
|
|
17340
|
+
function unknownConsoleMethod(raw) {
|
|
17341
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
17342
|
+
const envelope = raw;
|
|
17343
|
+
if (typeof envelope.id !== "string" || !envelope.id) return null;
|
|
17344
|
+
const method = envelope.method;
|
|
17345
|
+
if (typeof method !== "string" || !CONSOLE_NAME.test(method)) return null;
|
|
17346
|
+
if (isMethod(method)) return null;
|
|
17347
|
+
return { id: envelope.id, method };
|
|
17348
|
+
}
|
|
14813
17349
|
function errorResponse(id2, error) {
|
|
14814
17350
|
if (error instanceof BridgeCommandError) {
|
|
14815
17351
|
const known = ERROR_CODES.includes(error.code);
|
|
@@ -14899,7 +17435,12 @@ async function handle(req, res, options) {
|
|
|
14899
17435
|
const body = {
|
|
14900
17436
|
host: "ui",
|
|
14901
17437
|
proto: PROTO_VERSION,
|
|
14902
|
-
pid: process.pid
|
|
17438
|
+
pid: process.pid,
|
|
17439
|
+
// Additive: an older reader requires `host` and `pid` and ignores the
|
|
17440
|
+
// rest, and a newer one learns whether the console is up without a
|
|
17441
|
+
// second probe (design 10.1). `false` when nobody reported a
|
|
17442
|
+
// capability, which is the honest answer for a host that has none.
|
|
17443
|
+
console: options.capabilities?.().console ?? false
|
|
14903
17444
|
};
|
|
14904
17445
|
send(res, 200, body);
|
|
14905
17446
|
return;
|
|
@@ -14927,6 +17468,22 @@ async function handle(req, res, options) {
|
|
|
14927
17468
|
try {
|
|
14928
17469
|
request = validateRequest(raw);
|
|
14929
17470
|
} catch (error) {
|
|
17471
|
+
const skew = unknownConsoleMethod(raw);
|
|
17472
|
+
if (skew) {
|
|
17473
|
+
send(
|
|
17474
|
+
res,
|
|
17475
|
+
200,
|
|
17476
|
+
errorResponse(
|
|
17477
|
+
skew.id,
|
|
17478
|
+
new BridgeCommandError(
|
|
17479
|
+
"unsupported_method",
|
|
17480
|
+
`this app version has no console method ${JSON.stringify(skew.method)}; update Local Operator`,
|
|
17481
|
+
{ method: skew.method }
|
|
17482
|
+
)
|
|
17483
|
+
)
|
|
17484
|
+
);
|
|
17485
|
+
return;
|
|
17486
|
+
}
|
|
14930
17487
|
send(res, 422, {
|
|
14931
17488
|
detail: error instanceof Error ? error.message : "invalid envelope"
|
|
14932
17489
|
});
|
|
@@ -16175,19 +18732,44 @@ async function startBrowserHost(options) {
|
|
|
16175
18732
|
`[browser] restored ${recorded.length} tab(s) from ${SESSION_FILENAME}; every restored tab is user-owned and holds no handle (design 7.3)`
|
|
16176
18733
|
);
|
|
16177
18734
|
}
|
|
18735
|
+
const consoleStartup = await startConsoleHost({
|
|
18736
|
+
window: options.window,
|
|
18737
|
+
expectedUrl: options.expectedUrl,
|
|
18738
|
+
appVersion: options.appVersion,
|
|
18739
|
+
log
|
|
18740
|
+
});
|
|
16178
18741
|
const sessionKey = mintSessionKey();
|
|
16179
18742
|
const server = await startRpcServer({
|
|
16180
18743
|
key: sessionKey,
|
|
16181
|
-
dispatch: (method, params, requestId2) =>
|
|
16182
|
-
|
|
18744
|
+
dispatch: async (method, params, requestId2) => {
|
|
18745
|
+
if (!isConsoleDispatchMethod(method)) {
|
|
18746
|
+
return host.dispatch(method, params, requestId2);
|
|
18747
|
+
}
|
|
18748
|
+
if (!consoleStartup.ok) {
|
|
18749
|
+
throw new ConsoleError(
|
|
18750
|
+
"console_unavailable",
|
|
18751
|
+
`this app's console is not available (${consoleStartup.reason}): ${consoleStartup.detail}`,
|
|
18752
|
+
{ reason: consoleStartup.reason }
|
|
18753
|
+
);
|
|
18754
|
+
}
|
|
18755
|
+
return consoleStartup.dispatch(method, params);
|
|
18756
|
+
},
|
|
18757
|
+
log,
|
|
18758
|
+
capabilities: () => ({ console: consoleStartup.ok })
|
|
16183
18759
|
});
|
|
16184
18760
|
stateWriter = new BrowserStateWriter(
|
|
16185
18761
|
stateFilePath(),
|
|
16186
|
-
() =>
|
|
16187
|
-
|
|
16188
|
-
|
|
16189
|
-
|
|
16190
|
-
|
|
18762
|
+
() => {
|
|
18763
|
+
const consoleCounts = consoleStartup.ok ? consoleStartup.counts() : { total: 0, agent: 0 };
|
|
18764
|
+
return {
|
|
18765
|
+
tabs: registry.count(),
|
|
18766
|
+
agentTabs: registry.agentTabCount(),
|
|
18767
|
+
profileDir,
|
|
18768
|
+
console: consoleStartup.ok,
|
|
18769
|
+
consoleSurfaces: consoleCounts.total,
|
|
18770
|
+
consoleAgentSurfaces: consoleCounts.agent
|
|
18771
|
+
};
|
|
18772
|
+
},
|
|
16191
18773
|
{
|
|
16192
18774
|
appVersion: options.appVersion,
|
|
16193
18775
|
onError: (error) => log(`[browser] state file: ${String(error)}`)
|
|
@@ -16203,7 +18785,7 @@ async function startBrowserHost(options) {
|
|
|
16203
18785
|
log
|
|
16204
18786
|
});
|
|
16205
18787
|
log(
|
|
16206
|
-
`[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}`
|
|
18788
|
+
`[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}, console ${consoleStartup.ok ? `on (${consoleStartup.counts().total} surface(s))` : `off (${consoleStartup.reason})`}`
|
|
16207
18789
|
);
|
|
16208
18790
|
const handle2 = {
|
|
16209
18791
|
host,
|
|
@@ -16226,6 +18808,7 @@ async function startBrowserHost(options) {
|
|
|
16226
18808
|
registry.destroyAll();
|
|
16227
18809
|
await cdp.close();
|
|
16228
18810
|
await server.close();
|
|
18811
|
+
await consoleStartup.stop();
|
|
16229
18812
|
unregisterBrowserIpc();
|
|
16230
18813
|
detachWebauthn();
|
|
16231
18814
|
webauthn.dispose();
|
|
@@ -20204,15 +22787,15 @@ ${heal.removed.join("\n")}`,
|
|
|
20204
22787
|
);
|
|
20205
22788
|
}
|
|
20206
22789
|
const label = shipItJobLabel(bundleId);
|
|
20207
|
-
const
|
|
22790
|
+
const logPath2 = installerLogPath(
|
|
20208
22791
|
shipItCacheDir(path$1.join(os.homedir(), "Library", "Caches"), bundleId)
|
|
20209
22792
|
);
|
|
20210
22793
|
try {
|
|
20211
|
-
fs.mkdirSync(path$1.dirname(
|
|
20212
|
-
fs.closeSync(fs.openSync(
|
|
22794
|
+
fs.mkdirSync(path$1.dirname(logPath2), { recursive: true });
|
|
22795
|
+
fs.closeSync(fs.openSync(logPath2, "a"));
|
|
20213
22796
|
} catch (error) {
|
|
20214
22797
|
return refuse(
|
|
20215
|
-
`the installer's log ${
|
|
22798
|
+
`the installer's log ${logPath2} could not be opened for appending: ${String(error)}`
|
|
20216
22799
|
);
|
|
20217
22800
|
}
|
|
20218
22801
|
return {
|
|
@@ -20227,7 +22810,7 @@ ${heal.removed.join("\n")}`,
|
|
|
20227
22810
|
// install: the failure panel's detail line and
|
|
20228
22811
|
// scripts/update-window-report.mjs both read that path (see
|
|
20229
22812
|
// installerLogPath).
|
|
20230
|
-
logPath
|
|
22813
|
+
logPath: logPath2
|
|
20231
22814
|
}
|
|
20232
22815
|
};
|
|
20233
22816
|
}
|
|
@@ -20460,12 +23043,12 @@ ${heal.removed.join("\n")}`,
|
|
|
20460
23043
|
} catch (error) {
|
|
20461
23044
|
const code = transientTransportCode(error);
|
|
20462
23045
|
if (code === null || attempt >= delays.length) throw error;
|
|
20463
|
-
const
|
|
23046
|
+
const delay2 = delays[attempt];
|
|
20464
23047
|
logger.warn(
|
|
20465
|
-
`Transient transport failure during the update check (${code}); retrying in ${
|
|
23048
|
+
`Transient transport failure during the update check (${code}); retrying in ${delay2}ms (attempt ${attempt + 2} of ${delays.length + 1})`,
|
|
20466
23049
|
LogFileType.UPDATE_SERVICE
|
|
20467
23050
|
);
|
|
20468
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
23051
|
+
await new Promise((resolve) => setTimeout(resolve, delay2));
|
|
20469
23052
|
}
|
|
20470
23053
|
}
|
|
20471
23054
|
} finally {
|