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.
Files changed (71) hide show
  1. package/out/main/index.js +2769 -186
  2. package/out/preload/index.js +113 -0
  3. package/out/renderer/assets/{_basePickBy-gHcXb1KL.js → _basePickBy-1H7gCAbJ.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-Cjg_TxLc.js → _baseUniq-9lNuDyr9.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-BLVP0oIP.js → agent-details-page-DljI0xn2.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-DAzUXdCl.js → agent-hub-page-BrDV_UN_.js} +1 -1
  7. package/out/renderer/assets/{agents-page-BqqWqJRL.js → agents-page-DwxRQf7x.js} +2 -2
  8. package/out/renderer/assets/{arc-7-q8aDG3.js → arc-Bo4fsGBf.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-Ba7yCUVS.js → architectureDiagram-IEHRJDOE-CFazObUZ.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-t6SLXRXN.js → blockDiagram-JOT3LUYC-Bg72Epup.js} +1 -1
  11. package/out/renderer/assets/browser-page-NToP8sAi.js +1 -0
  12. package/out/renderer/assets/{browser-webauthn-prompt-BiFtkxJX.js → browser-webauthn-prompt-Bp6ZNrfE.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-C6nxJeUu.js → c4Diagram-VJAJSXHY-BGeUxHWC.js} +1 -1
  14. package/out/renderer/assets/channel-M9BWKnVc.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-iV0vwEQH.js → chunk-4BMEZGHF-DzG5AABJ.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-Bo_VOUDE.js → chunk-A2AXSNBT-BDE1k_Sd.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-CdHexsQ8.js → chunk-AEK57VVT-Bnqy1REO.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-CeLv_5W7.js → chunk-D6G4REZN-OJwou3dn.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-BnQffRFA.js → chunk-RZ5BOZE2-BBpDVXLp.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-5It_CStB.js → chunk-XZIHB7SX-HO33Rcol.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-C2Z-Z1K9.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2Z-Z1K9.js +1 -0
  23. package/out/renderer/assets/clone-StYPNe3g.js +1 -0
  24. package/out/renderer/assets/{compact-pagination-BJfBJjZq.js → compact-pagination-YLr5UxEq.js} +1 -1
  25. package/out/renderer/assets/{dagre-OKDRZEBW-CPHSSmct.js → dagre-OKDRZEBW-DaKzvkro.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-uoifaHV7.js → diagram-SSKATNLV-BJ85p8MH.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-DiPorn-j.js → diagram-VNBRO52H-BDylL4pB.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-D3_RVsgS.js → erDiagram-Q7BY3M3F-Bmuo6n4L.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-9kpTgY8l.js → flowDiagram-4HSFHLVR-CxswEMEz.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-C1hwJAlh.js → ganttDiagram-APWFNJXF-CXUPGd8I.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BvA7T2vP.js → gitGraphDiagram-7IBYFJ6S-By5CUo46.js} +1 -1
  32. package/out/renderer/assets/{graph-DpmQwtUv.js → graph-CMfReLAi.js} +1 -1
  33. package/out/renderer/assets/icon-DGoQRttI.css +1 -0
  34. package/out/renderer/assets/{index-CxwcyLWF.js → index-COoXd8RY.js} +1 -1
  35. package/out/renderer/assets/{index-Bw89Hsw-.js → index-CbVSom_R.js} +373 -373
  36. package/out/renderer/assets/{index-Dl2E40WV.js → index-CpOPM-iW.js} +1 -1
  37. package/out/renderer/assets/{infoDiagram-PH2N3AL5-W9OqWzoG.js → infoDiagram-PH2N3AL5-BWOjcWnS.js} +1 -1
  38. package/out/renderer/assets/{installer-Cf6g2y9a.js → installer-BfhAHY4r.js} +1 -1
  39. package/out/renderer/assets/{journeyDiagram-U35MCT3I-Deb5RarE.js → journeyDiagram-U35MCT3I-B-x8rfW8.js} +1 -1
  40. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BsbQgmLq.js → kanban-definition-NDS4AKOZ-v7_cq85Q.js} +1 -1
  41. package/out/renderer/assets/{layout-CrJ6Fu9B.js → layout-DD4W9deB.js} +1 -1
  42. package/out/renderer/assets/{legacy-agents-page-egSNYRnK.js → legacy-agents-page-Ciisz9T5.js} +1 -1
  43. package/out/renderer/assets/{mermaid.core-BYxmKNp-.js → mermaid.core-DMA_yzhO.js} +5 -5
  44. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-C-9egivQ.js → mindmap-definition-ALO5MXBD-BO-1wp5Z.js} +1 -1
  45. package/out/renderer/assets/{page-header-B9ZaEm1G.js → page-header-kampE4Cn.js} +1 -1
  46. package/out/renderer/assets/{parseISO-B1ekvrn4.js → parseISO-fIBQPPHT.js} +1 -1
  47. package/out/renderer/assets/{pieDiagram-IB7DONF6-B8qVlKET.js → pieDiagram-IB7DONF6-SvGNVrTF.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DFTB7_6X.js → quadrantDiagram-7GDLP6J5-BQ9fNSZT.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-BBqHNsuC.js → radar-MK3ICKWK-BdQH-zQG.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-D7Z-gfFA.js → radient-auth-buttons-BMIRfsdU.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-nfWLgIi1.js → requirementDiagram-KVF5MWMF-B5b6b0Cc.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-kz3aMr3g.js → sankeyDiagram-QLVOVGJD-DBrSxuPt.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-9k1r0G5Z.js → schedules-page-BiLy5_Sv.js} +1 -1
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BlPj6-VP.js → sequenceDiagram-X6HHIX6F-CQ7cg-pc.js} +1 -1
  55. package/out/renderer/assets/{settings-page-7FNkwi6H.js → settings-page-Bt7hCm86.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-U-C3BZ-1.js → stateDiagram-DGXRK772-C9ED7Xhx.js} +1 -1
  57. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DNMp6of4.js +1 -0
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-Bg_ZEH7m.js → timeline-definition-BDJGKUSR-DKvZ80yG.js} +1 -1
  59. package/out/renderer/assets/{use-agent-like-mutation-B5uInGPk.js → use-agent-like-mutation-C_Y-w2IJ.js} +1 -1
  60. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CUjkSuIE.js → xychartDiagram-VJFVF3MP-peQEFG2j.js} +1 -1
  61. package/out/renderer/index.html +3 -3
  62. package/out/renderer/installer.html +3 -3
  63. package/package.json +12 -4
  64. package/out/renderer/assets/browser-page-B3ww4TIa.js +0 -1
  65. package/out/renderer/assets/channel-D05avaQg.js +0 -1
  66. package/out/renderer/assets/classDiagram-GIVACNV2-DjtkcyI4.js +0 -1
  67. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DjtkcyI4.js +0 -1
  68. package/out/renderer/assets/clone-CkDaYVxE.js +0 -1
  69. package/out/renderer/assets/icon-C7JJ2uBI.css +0 -1
  70. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Co-dAGQa.js +0 -1
  71. /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 delay = Math.min(
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
- }, delay);
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) => host.dispatch(method, params, requestId2),
16182
- log
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
- tabs: registry.count(),
16188
- agentTabs: registry.agentTabCount(),
16189
- profileDir
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 logPath = installerLogPath(
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(logPath), { recursive: true });
20212
- fs.closeSync(fs.openSync(logPath, "a"));
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 ${logPath} could not be opened for appending: ${String(error)}`
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 delay = delays[attempt];
23046
+ const delay2 = delays[attempt];
20464
23047
  logger.warn(
20465
- `Transient transport failure during the update check (${code}); retrying in ${delay}ms (attempt ${attempt + 2} of ${delays.length + 1})`,
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, delay));
23051
+ await new Promise((resolve) => setTimeout(resolve, delay2));
20469
23052
  }
20470
23053
  }
20471
23054
  } finally {