@ganglion/xacpx-relay 0.14.7 → 0.14.9-beta.0

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 (141) hide show
  1. package/dist/cli.js +1157 -85
  2. package/dist/gateway/desktop-stream-gateway.d.ts +150 -0
  3. package/dist/gateway/desktop-stream-registry.d.ts +79 -0
  4. package/dist/gateway/desktop-ticket-store.d.ts +37 -0
  5. package/dist/gateway/desktop-viewer-cancel.d.ts +73 -0
  6. package/dist/gateway/instance-gateway.d.ts +30 -1
  7. package/dist/gateway/web-gateway.d.ts +7 -0
  8. package/dist/gateway/web-inbound.d.ts +50 -2
  9. package/dist/http/app.d.ts +32 -3
  10. package/dist/interaction-registry.d.ts +171 -0
  11. package/dist/relay-web/assets/DashboardView-BOjJZyPa.js +536 -0
  12. package/dist/relay-web/assets/DashboardView-CxbB9aOt.css +1 -0
  13. package/dist/relay-web/assets/InviteView-BrpEZ62C.js +1 -0
  14. package/dist/relay-web/assets/InviteView-J2ktfVru.css +1 -0
  15. package/dist/relay-web/assets/LoginView-ClWbwt-c.js +1 -0
  16. package/dist/relay-web/assets/LoginView-kC8xjA_t.css +1 -0
  17. package/dist/relay-web/assets/SettingsView-CgRvKsTu.js +6 -0
  18. package/dist/relay-web/assets/{abnfDiagram-VRR7QNED-DaKwODLI.js → abnfDiagram-VRR7QNED-Dck3JZYa.js} +1 -1
  19. package/dist/relay-web/assets/{arc-CV9VS-uW.js → arc-BUwGn8Aw.js} +1 -1
  20. package/dist/relay-web/assets/architectureDiagram-ZJ3FMSHR-xpEg81LX.js +36 -0
  21. package/dist/relay-web/assets/blockDiagram-677ZJIJ3-BubzF_yu.js +132 -0
  22. package/dist/relay-web/assets/c4Diagram-LMCZKHZV-DuWTAHtf.js +10 -0
  23. package/dist/relay-web/assets/channel-BgdSN6lg.js +1 -0
  24. package/dist/relay-web/assets/chunk-2Q5K7J3B-DBDlij9L.js +1 -0
  25. package/dist/relay-web/assets/{chunk-32BRIVSS-DPN74KhB.js → chunk-32BRIVSS-BVy-gEXe.js} +1 -1
  26. package/dist/relay-web/assets/{chunk-5VM5RSS4-DfDDB13s.js → chunk-5VM5RSS4-lG5wevp_.js} +1 -1
  27. package/dist/relay-web/assets/chunk-EX3LRPZG-C39iM1_G.js +231 -0
  28. package/dist/relay-web/assets/chunk-JWPE2WC7-CXMvPmeE.js +1 -0
  29. package/dist/relay-web/assets/chunk-MOJQB5TN-1nAmtQu2.js +88 -0
  30. package/dist/relay-web/assets/chunk-RYQCIY6F-bEx1WMva.js +1 -0
  31. package/dist/relay-web/assets/chunk-V7JOEXUC-Ca-ze-xM.js +206 -0
  32. package/dist/relay-web/assets/chunk-VR4S4FIN-BLJsBZDp.js +1 -0
  33. package/dist/relay-web/assets/{chunk-XXDRQBXY-DJGRRjV3.js → chunk-XXDRQBXY-bsEWv2ey.js} +1 -1
  34. package/dist/relay-web/assets/classDiagram-OUVF2IWQ-DGFgm9zH.js +1 -0
  35. package/dist/relay-web/assets/classDiagram-v2-EOCWNBFH-DGFgm9zH.js +1 -0
  36. package/dist/relay-web/assets/cose-bilkent-JH36ORCC-B6jSr2Nv.js +1 -0
  37. package/dist/relay-web/assets/cynefin-VYW2F7L2-oysjrmsP.js +178 -0
  38. package/dist/relay-web/assets/{cynefinDiagram-TSTJHNR4-BNpeh400.js → cynefinDiagram-TSTJHNR4-s5Y1Nshk.js} +1 -1
  39. package/dist/relay-web/assets/{cytoscape.esm-CUqq0XTU.js → cytoscape.esm-DJK6lGiH.js} +3 -3
  40. package/dist/relay-web/assets/dagre-VKFMJZFB-xovP-3AF.js +4 -0
  41. package/dist/relay-web/assets/diagram-FQU43EPY-BSpqL1K9.js +3 -0
  42. package/dist/relay-web/assets/diagram-G47NLZAW-DqnFRysq.js +24 -0
  43. package/dist/relay-web/assets/diagram-NH7WQ7WH-BBuDaT9C.js +24 -0
  44. package/dist/relay-web/assets/diagram-OA4YK3LP-BsK3Ep2f.js +30 -0
  45. package/dist/relay-web/assets/diagram-WEI45ONY-BjW00xkP.js +41 -0
  46. package/dist/relay-web/assets/{ebnfDiagram-CCIWWBDH-DDO0kQPX.js → ebnfDiagram-CCIWWBDH-D9pd5un2.js} +1 -1
  47. package/dist/relay-web/assets/erDiagram-Q63AITRT-BpiIjChz.js +85 -0
  48. package/dist/relay-web/assets/flowDiagram-23GEKE2U-B_peH4w6.js +156 -0
  49. package/dist/relay-web/assets/{ganttDiagram-NO4QXBWP-bSsTF0DT.js → ganttDiagram-NO4QXBWP-BwQ4FW2W.js} +1 -1
  50. package/dist/relay-web/assets/gitGraphDiagram-IHSO6WYX-DlGg5kSb.js +106 -0
  51. package/dist/relay-web/assets/graph-gxHQN9-b.js +1 -0
  52. package/dist/relay-web/assets/index-CHcIz6UQ.js +151 -0
  53. package/dist/relay-web/assets/index-DEaRIAEf.css +1 -0
  54. package/dist/relay-web/assets/{infoDiagram-FWYZ7A6U-CmX6hKNb.js → infoDiagram-FWYZ7A6U-Bthqui0m.js} +1 -1
  55. package/dist/relay-web/assets/ishikawaDiagram-FXEZZL3T-BBb-68i0.js +70 -0
  56. package/dist/relay-web/assets/{journeyDiagram-5HDEW3XC-b7YoZRs-.js → journeyDiagram-5HDEW3XC-C9vRiAK6.js} +1 -1
  57. package/dist/relay-web/assets/kanban-definition-HUTT4EX6-Bgbviu-S.js +89 -0
  58. package/dist/relay-web/assets/{katex-C5jXJg4s.js → katex-HP8lGamR.js} +1 -1
  59. package/dist/relay-web/assets/{layout-HEHzrzvT.js → layout-CpcJW2by.js} +1 -1
  60. package/dist/relay-web/assets/{linear-qgwU0RG6.js → linear-Bswmlg_d.js} +1 -1
  61. package/dist/relay-web/assets/{map-BQHxRNoK.js → map-CKPHyBGT.js} +1 -1
  62. package/dist/relay-web/assets/mermaid.core-BCis0YlZ.js +312 -0
  63. package/dist/relay-web/assets/mindmap-definition-LN4V7U3C-DZWl9sWH.js +96 -0
  64. package/dist/relay-web/assets/{pegDiagram-2B236MQR-Cfx_WRNK.js → pegDiagram-2B236MQR-fbbfPmLp.js} +1 -1
  65. package/dist/relay-web/assets/pieDiagram-ENE6RG2P-8ANoKukC.js +39 -0
  66. package/dist/relay-web/assets/quadrantDiagram-ABIIQ3AL-Da3tBy4o.js +7 -0
  67. package/dist/relay-web/assets/{railroadDiagram-RFXS5EU6-BwasTCtU.js → railroadDiagram-RFXS5EU6-B9qdvKhl.js} +1 -1
  68. package/dist/relay-web/assets/requirementDiagram-TGXJPOKE-CWN6fXGC.js +84 -0
  69. package/dist/relay-web/assets/rfb-6tS6afZT.js +5 -0
  70. package/dist/relay-web/assets/sankeyDiagram-HTMAVEWB-GAdVFhEf.js +40 -0
  71. package/dist/relay-web/assets/sequenceDiagram-DBY2YBRQ-BsjsMh_N.js +162 -0
  72. package/dist/relay-web/assets/sizeCapture-X5ZJPWSS-BYqM-l-h.js +1 -0
  73. package/dist/relay-web/assets/{stateDiagram-2N3HPSRC-6J7enxP8.js → stateDiagram-2N3HPSRC-CYC4lpgV.js} +1 -1
  74. package/dist/relay-web/assets/stateDiagram-v2-6OUMAXLB-BFB2bl9b.js +1 -0
  75. package/dist/relay-web/assets/swimlanes-5IMT3BWC-DMkHrrNX.js +2 -0
  76. package/dist/relay-web/assets/swimlanesDiagram-G3AALYLV-BiLcwTEU.js +8 -0
  77. package/dist/relay-web/assets/theme-CRHsNTDg.js +1 -0
  78. package/dist/relay-web/assets/timeline-definition-FHXFAJF6-DWlf0bUt.js +120 -0
  79. package/dist/relay-web/assets/vennDiagram-L72KCM5P-CbdnfByf.js +34 -0
  80. package/dist/relay-web/assets/wardleyDiagram-EHGQE667-BCvSYtNJ.js +78 -0
  81. package/dist/relay-web/assets/xterm-CASmyfyk.js +27 -0
  82. package/dist/relay-web/assets/xterm-MhP_vFvK.css +32 -0
  83. package/dist/relay-web/assets/xychartDiagram-FW5EYKEG-BnKnBAs8.js +7 -0
  84. package/dist/relay-web/index.html +2 -2
  85. package/dist/relay-web/sw.js +1 -1
  86. package/dist/server.d.ts +25 -0
  87. package/package.json +2 -2
  88. package/dist/relay-web/assets/DashboardView-4IA9qgyK.js +0 -474
  89. package/dist/relay-web/assets/DashboardView-BhtuSo0h.css +0 -1
  90. package/dist/relay-web/assets/InviteView-Dz5OA4kl.js +0 -1
  91. package/dist/relay-web/assets/InviteView-hJ3Eqm6F.css +0 -1
  92. package/dist/relay-web/assets/LoginView-BDsZDCu4.css +0 -1
  93. package/dist/relay-web/assets/LoginView-DSkzpE2J.js +0 -1
  94. package/dist/relay-web/assets/SettingsView-Dep66dxc.js +0 -6
  95. package/dist/relay-web/assets/architectureDiagram-ZJ3FMSHR-CbaahLkm.js +0 -36
  96. package/dist/relay-web/assets/blockDiagram-677ZJIJ3-56mgVcyf.js +0 -132
  97. package/dist/relay-web/assets/c4Diagram-LMCZKHZV-dmZoIhGg.js +0 -10
  98. package/dist/relay-web/assets/channel-DZd0OnOW.js +0 -1
  99. package/dist/relay-web/assets/chunk-2Q5K7J3B-DlTJ10nZ.js +0 -1
  100. package/dist/relay-web/assets/chunk-EX3LRPZG-C3nLlpx-.js +0 -231
  101. package/dist/relay-web/assets/chunk-JWPE2WC7-BWYtSFDI.js +0 -1
  102. package/dist/relay-web/assets/chunk-MOJQB5TN-B7zlbyBm.js +0 -88
  103. package/dist/relay-web/assets/chunk-RYQCIY6F-CntGwtzD.js +0 -1
  104. package/dist/relay-web/assets/chunk-V7JOEXUC-Bp_pqLJZ.js +0 -206
  105. package/dist/relay-web/assets/chunk-VR4S4FIN-BKVoyZce.js +0 -1
  106. package/dist/relay-web/assets/classDiagram-OUVF2IWQ-B5R0Hfzs.js +0 -1
  107. package/dist/relay-web/assets/classDiagram-v2-EOCWNBFH-B5R0Hfzs.js +0 -1
  108. package/dist/relay-web/assets/cose-bilkent-JH36ORCC-CVFX_TmM.js +0 -1
  109. package/dist/relay-web/assets/cynefin-VYW2F7L2-DSm8ksAf.js +0 -178
  110. package/dist/relay-web/assets/dagre-VKFMJZFB-DE1k216e.js +0 -4
  111. package/dist/relay-web/assets/diagram-FQU43EPY-h2bdCdUk.js +0 -3
  112. package/dist/relay-web/assets/diagram-G47NLZAW-BXdW-FiW.js +0 -24
  113. package/dist/relay-web/assets/diagram-NH7WQ7WH-CRJggoib.js +0 -24
  114. package/dist/relay-web/assets/diagram-OA4YK3LP-BTJubaWI.js +0 -30
  115. package/dist/relay-web/assets/diagram-WEI45ONY-COYBEkjL.js +0 -41
  116. package/dist/relay-web/assets/erDiagram-Q63AITRT-E2feMDQa.js +0 -85
  117. package/dist/relay-web/assets/flowDiagram-23GEKE2U-sHrond1v.js +0 -156
  118. package/dist/relay-web/assets/gitGraphDiagram-IHSO6WYX-Dfftd6A5.js +0 -106
  119. package/dist/relay-web/assets/graph-C2PyVmSI.js +0 -1
  120. package/dist/relay-web/assets/index-B0GiuYnP.css +0 -1
  121. package/dist/relay-web/assets/index-CCsE2eVx.js +0 -151
  122. package/dist/relay-web/assets/ishikawaDiagram-FXEZZL3T-CEafVlSP.js +0 -70
  123. package/dist/relay-web/assets/kanban-definition-HUTT4EX6-CQJjV0wH.js +0 -89
  124. package/dist/relay-web/assets/mermaid.core-BWC59kZs.js +0 -312
  125. package/dist/relay-web/assets/mindmap-definition-LN4V7U3C-BIqI_g8X.js +0 -96
  126. package/dist/relay-web/assets/pieDiagram-ENE6RG2P-BE3DnzCC.js +0 -39
  127. package/dist/relay-web/assets/quadrantDiagram-ABIIQ3AL-BRnaz8YE.js +0 -7
  128. package/dist/relay-web/assets/requirementDiagram-TGXJPOKE-vNkYyFU7.js +0 -84
  129. package/dist/relay-web/assets/sankeyDiagram-HTMAVEWB-C_EfSDJD.js +0 -40
  130. package/dist/relay-web/assets/sequenceDiagram-DBY2YBRQ-s4llcEpj.js +0 -162
  131. package/dist/relay-web/assets/sizeCapture-X5ZJPWSS-DvaH8gFI.js +0 -1
  132. package/dist/relay-web/assets/stateDiagram-v2-6OUMAXLB-laWLPKHD.js +0 -1
  133. package/dist/relay-web/assets/swimlanes-5IMT3BWC-BQCukg8Z.js +0 -2
  134. package/dist/relay-web/assets/swimlanesDiagram-G3AALYLV-OigEZaOT.js +0 -8
  135. package/dist/relay-web/assets/theme-_eLYZJbT.js +0 -1
  136. package/dist/relay-web/assets/timeline-definition-FHXFAJF6-BtoktOBS.js +0 -120
  137. package/dist/relay-web/assets/vennDiagram-L72KCM5P-N_hCOD0c.js +0 -34
  138. package/dist/relay-web/assets/wardleyDiagram-EHGQE667-AmQ55zTN.js +0 -78
  139. package/dist/relay-web/assets/xterm-9CEnUXvW.css +0 -32
  140. package/dist/relay-web/assets/xterm-BT7tBf8A.js +0 -27
  141. package/dist/relay-web/assets/xychartDiagram-FW5EYKEG-CXkp2O4G.js +0 -7
package/dist/cli.js CHANGED
@@ -7251,11 +7251,15 @@ import { dirname as dirname3, join as join2, resolve } from "node:path";
7251
7251
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7252
7252
 
7253
7253
  // packages/relay/src/server.ts
7254
+ import { createServer } from "node:http";
7254
7255
  import { serve } from "@hono/node-server";
7255
7256
  import { WebSocketServer } from "ws";
7256
7257
  import {
7258
+ DESKTOP_TICKET_TTL_MS as DESKTOP_TICKET_TTL_MS2,
7259
+ DESKTOP_WS_MAX_PAYLOAD_BYTES as DESKTOP_WS_MAX_PAYLOAD_BYTES2,
7260
+ MAX_DESKTOP_TICKET_LENGTH as MAX_DESKTOP_TICKET_LENGTH2,
7257
7261
  MAX_TOOL_STEPS,
7258
- MSG as MSG4,
7262
+ MSG as MSG5,
7259
7263
  REASONING_CAP,
7260
7264
  STATE_SYNC_PARTS_CAP,
7261
7265
  STATE_SYNC_TEXT_CAP,
@@ -8175,9 +8179,141 @@ import {
8175
8179
  decodeEnvelope,
8176
8180
  encodeEnvelope,
8177
8181
  errorPayload,
8178
- normalizeCapabilities
8182
+ normalizeCapabilities,
8183
+ validateInteractionRequest,
8184
+ validateInteractionWithdraw
8179
8185
  } from "@ganglion/xacpx-relay-protocol";
8180
8186
 
8187
+ // packages/relay/src/interaction-registry.ts
8188
+ function interactionResultForBrowser(result, accountId) {
8189
+ if (typeof result !== "object" || result === null) {
8190
+ return { responded: false, reason: "aborted" };
8191
+ }
8192
+ const outcome = result;
8193
+ if (outcome.responded !== true) {
8194
+ const reason = typeof outcome.reason === "string" ? outcome.reason : "aborted";
8195
+ return {
8196
+ responded: false,
8197
+ reason: reason === "timeout" || reason === "aborted" || reason === "shutdown" || reason === "unsupported" || reason === "channel-missing" ? reason : "aborted"
8198
+ };
8199
+ }
8200
+ const response = outcome.response;
8201
+ if (typeof response !== "object" || response === null) {
8202
+ return { responded: false, reason: "aborted" };
8203
+ }
8204
+ const decision = response;
8205
+ return {
8206
+ responded: true,
8207
+ response: { ...decision, responderId: accountId }
8208
+ };
8209
+ }
8210
+
8211
+ class InteractionRegistry {
8212
+ logger;
8213
+ pending = new Map;
8214
+ closedListeners = new Set;
8215
+ constructor(logger) {
8216
+ this.logger = logger;
8217
+ }
8218
+ onClose(listener) {
8219
+ this.closedListeners.add(listener);
8220
+ return () => {
8221
+ this.closedListeners.delete(listener);
8222
+ };
8223
+ }
8224
+ get size() {
8225
+ return this.pending.size;
8226
+ }
8227
+ open(entry) {
8228
+ const withTimer = { ...entry, closed: false };
8229
+ withTimer.timer = setTimeout(() => {
8230
+ this.close(entry.requestId, "expired");
8231
+ }, Math.max(0, entry.answerWindowMs));
8232
+ if (typeof withTimer.timer.unref === "function")
8233
+ withTimer.timer.unref();
8234
+ this.pending.set(entry.requestId, withTimer);
8235
+ this.logger.debug("relay.interaction.opened", "interaction opened", {
8236
+ requestId: entry.requestId,
8237
+ instanceId: entry.instanceId,
8238
+ kind: entry.kind
8239
+ });
8240
+ }
8241
+ answer(requestId, decision) {
8242
+ const entry = this.pending.get(requestId);
8243
+ if (!entry || entry.closed)
8244
+ return null;
8245
+ if (entry.kind !== decision.kind) {
8246
+ return null;
8247
+ }
8248
+ if (Date.now() >= entry.expiresAt) {
8249
+ this.close(requestId, "expired");
8250
+ return null;
8251
+ }
8252
+ this.finish(entry, "resolved", decision.action);
8253
+ entry.resolve(decision);
8254
+ return entry;
8255
+ }
8256
+ close(requestId, reason) {
8257
+ const entry = this.pending.get(requestId);
8258
+ if (!entry || entry.closed)
8259
+ return false;
8260
+ this.finish(entry, reason);
8261
+ entry.reject(reason);
8262
+ return true;
8263
+ }
8264
+ listForInstance(instanceId) {
8265
+ const entries = [];
8266
+ for (const entry of this.pending.values()) {
8267
+ if (entry.instanceId === instanceId)
8268
+ entries.push(entry);
8269
+ }
8270
+ return entries;
8271
+ }
8272
+ listForAccount(accountId) {
8273
+ const entries = [];
8274
+ for (const entry of this.pending.values()) {
8275
+ if (entry.accountId === accountId)
8276
+ entries.push(entry);
8277
+ }
8278
+ return entries;
8279
+ }
8280
+ get(requestId) {
8281
+ const entry = this.pending.get(requestId);
8282
+ return entry && !entry.closed ? entry : null;
8283
+ }
8284
+ finish(entry, reason, action) {
8285
+ entry.closed = true;
8286
+ if (entry.timer !== undefined) {
8287
+ clearTimeout(entry.timer);
8288
+ entry.timer = undefined;
8289
+ }
8290
+ this.pending.delete(entry.requestId);
8291
+ this.logger.debug("relay.interaction.closed", "interaction closed", {
8292
+ requestId: entry.requestId,
8293
+ instanceId: entry.instanceId,
8294
+ reason,
8295
+ ...action !== undefined ? { action } : {}
8296
+ });
8297
+ for (const listener of this.closedListeners) {
8298
+ try {
8299
+ listener({
8300
+ requestId: entry.requestId,
8301
+ instanceId: entry.instanceId,
8302
+ accountId: entry.accountId,
8303
+ kind: entry.kind,
8304
+ reason,
8305
+ ...action !== undefined ? { action } : {}
8306
+ });
8307
+ } catch (error) {
8308
+ this.logger.debug("relay.interaction.close_listener_failed", "interaction close listener threw", {
8309
+ requestId: entry.requestId,
8310
+ message: error instanceof Error ? error.message : String(error)
8311
+ });
8312
+ }
8313
+ }
8314
+ }
8315
+ }
8316
+
8181
8317
  // packages/relay/src/logging.ts
8182
8318
  var ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
8183
8319
  function createNoopRelayLogger() {
@@ -8339,6 +8475,9 @@ class InstanceGateway {
8339
8475
  isOnline(instanceId) {
8340
8476
  return this.connections.has(instanceId);
8341
8477
  }
8478
+ broadcastControlEvent(accountId, event) {
8479
+ this.deps.broadcastControlEvent?.(accountId, event);
8480
+ }
8342
8481
  handleConnection(socket) {
8343
8482
  let authed = null;
8344
8483
  startHeartbeat(socket, this.deps.heartbeatIntervalMs, undefined, this.logger);
@@ -8374,6 +8513,12 @@ class InstanceGateway {
8374
8513
  p.reject(new Error("instance-offline"));
8375
8514
  }
8376
8515
  }
8516
+ const interactions = this.deps.interactions;
8517
+ if (interactions) {
8518
+ for (const entry of interactions.listForInstance(instanceId)) {
8519
+ interactions.close(entry.requestId, "withdrawn");
8520
+ }
8521
+ }
8377
8522
  this.deps.onStatusChange?.(instanceId, accountId, false);
8378
8523
  this.broadcastDirectorySnapshot(accountId);
8379
8524
  }
@@ -8622,6 +8767,76 @@ class InstanceGateway {
8622
8767
  }).catch((err) => respond(errorPayload(err.code ?? "DELIVERY_FAILED", err instanceof Error ? err.message : String(err))));
8623
8768
  return;
8624
8769
  }
8770
+ if (envelope.type === MSG.interactionRequest) {
8771
+ const parsed = validateInteractionRequest(envelope.payload);
8772
+ if (!parsed) {
8773
+ respond(errorPayload("invalid-payload", `${MSG.interactionRequest}: malformed payload`));
8774
+ return;
8775
+ }
8776
+ const registry = this.deps.interactions;
8777
+ if (!registry) {
8778
+ respond(errorPayload("interaction-unavailable", "interaction registry is not configured"));
8779
+ return;
8780
+ }
8781
+ const instanceId = authed.instanceId;
8782
+ const accountId = authed.accountId;
8783
+ const chatKey = `relay:${accountId}`;
8784
+ const requestId = parsed.requestId;
8785
+ const now = Date.now();
8786
+ if (parsed.expiresAt <= now) {
8787
+ respond({ responded: false, reason: "timeout" });
8788
+ return;
8789
+ }
8790
+ if (parsed.kind !== "elicitation") {
8791
+ respond({ responded: false, reason: "unsupported" });
8792
+ return;
8793
+ }
8794
+ const answerWindowMs = parsed.expiresAt - now;
8795
+ registry.open({
8796
+ requestId,
8797
+ instanceId,
8798
+ accountId,
8799
+ kind: parsed.kind,
8800
+ expiresAt: parsed.expiresAt,
8801
+ chatKey,
8802
+ sessionAlias: "",
8803
+ answerWindowMs,
8804
+ timeoutMs: answerWindowMs + REQUEST_RESPONSE_RESERVE_MS,
8805
+ ...parsed.conversation !== undefined ? { conversation: parsed.conversation } : {},
8806
+ ...parsed.elicitation !== undefined ? { elicitation: parsed.elicitation } : {},
8807
+ ...parsed.permission !== undefined ? { permission: parsed.permission } : {},
8808
+ resolve: (decision) => respond(interactionResultForBrowser({ responded: true, response: decision }, accountId)),
8809
+ reject: (reason) => respond({ responded: false, reason: reason === "expired" ? "timeout" : "withdrawn" })
8810
+ });
8811
+ this.deps.broadcastControlEvent?.(accountId, {
8812
+ type: "interaction-opened",
8813
+ chatKey,
8814
+ sessionAlias: "",
8815
+ instanceId,
8816
+ interaction: parsed
8817
+ });
8818
+ return;
8819
+ }
8820
+ if (envelope.type === MSG.interactionWithdraw) {
8821
+ const parsed = validateInteractionWithdraw(envelope.payload);
8822
+ if (!parsed) {
8823
+ respond(errorPayload("invalid-payload", `${MSG.interactionWithdraw}: malformed payload`));
8824
+ return;
8825
+ }
8826
+ const registry = this.deps.interactions;
8827
+ if (!registry) {
8828
+ respond(errorPayload("interaction-unavailable", "interaction registry is not configured"));
8829
+ return;
8830
+ }
8831
+ const pending = registry.get(parsed.requestId);
8832
+ if (!pending || pending.instanceId !== authed.instanceId || pending.accountId !== authed.accountId) {
8833
+ respond({ ok: true });
8834
+ return;
8835
+ }
8836
+ registry.close(parsed.requestId, "withdrawn");
8837
+ respond({ ok: true });
8838
+ return;
8839
+ }
8625
8840
  respond(errorPayload("invalid-request", `unknown request type: ${envelope.type}`));
8626
8841
  return;
8627
8842
  }
@@ -8816,6 +9031,7 @@ class WebGateway {
8816
9031
  this.options.logger?.debug("relay.web.connected", "web client connected", { accountId });
8817
9032
  startHeartbeat(socket, this.options.heartbeatIntervalMs, undefined, this.options.logger);
8818
9033
  socket.on("close", () => {
9034
+ const closedViewerId = this.viewerBySocket.get(socket);
8819
9035
  this.clearSocketAttachments(socket);
8820
9036
  set.delete(socket);
8821
9037
  this.subscriptions.delete(socket);
@@ -8824,6 +9040,8 @@ class WebGateway {
8824
9040
  this.attachmentsBySocket.delete(socket);
8825
9041
  if (set.size === 0)
8826
9042
  this.byAccount.delete(accountId);
9043
+ if (closedViewerId)
9044
+ this.options.onViewerClosed?.(closedViewerId);
8827
9045
  this.options.logger?.debug("relay.web.disconnected", "web client disconnected", { accountId });
8828
9046
  });
8829
9047
  return viewerId;
@@ -8956,6 +9174,443 @@ class WebGateway {
8956
9174
  }
8957
9175
  }
8958
9176
 
9177
+ // packages/relay/src/gateway/desktop-stream-gateway.ts
9178
+ import {
9179
+ DESKTOP_BUFFERED_HARD_CLOSE_BYTES,
9180
+ DESKTOP_WS_MAX_PAYLOAD_BYTES
9181
+ } from "@ganglion/xacpx-relay-protocol";
9182
+
9183
+ // packages/relay/src/gateway/desktop-ticket-store.ts
9184
+ import { randomBytes as randomBytes2 } from "node:crypto";
9185
+ import {
9186
+ DESKTOP_TICKET_TTL_MS,
9187
+ MAX_DESKTOP_STREAM_ID_LENGTH,
9188
+ MAX_DESKTOP_TICKET_LENGTH
9189
+ } from "@ganglion/xacpx-relay-protocol";
9190
+ function defaultMint() {
9191
+ return randomBytes2(32).toString("base64url");
9192
+ }
9193
+
9194
+ class DesktopTicketStore {
9195
+ tickets = new Map;
9196
+ now;
9197
+ mint;
9198
+ ttlMs;
9199
+ constructor(options = {}) {
9200
+ this.now = options.now ?? Date.now;
9201
+ this.mint = options.mint ?? defaultMint;
9202
+ this.ttlMs = options.ttlMs ?? DESKTOP_TICKET_TTL_MS;
9203
+ }
9204
+ mintTicket(input) {
9205
+ if (!input.streamId || input.streamId.length > MAX_DESKTOP_STREAM_ID_LENGTH) {
9206
+ throw new Error("desktop ticket requires a bounded streamId");
9207
+ }
9208
+ if (!input.accountId || !input.instanceId) {
9209
+ throw new Error("desktop ticket requires accountId and instanceId");
9210
+ }
9211
+ const issued = this.mint();
9212
+ const ticket = issued.length > MAX_DESKTOP_TICKET_LENGTH ? issued.slice(0, MAX_DESKTOP_TICKET_LENGTH) : issued;
9213
+ if (!ticket)
9214
+ throw new Error("desktop ticket mint produced an empty ticket");
9215
+ const record = {
9216
+ ticket,
9217
+ streamId: input.streamId,
9218
+ accountId: input.accountId,
9219
+ instanceId: input.instanceId,
9220
+ side: input.side,
9221
+ expiresAt: this.now() + this.ttlMs
9222
+ };
9223
+ const existing = this.tickets.get(ticket);
9224
+ if (existing && (existing.expiresAt > this.now() || existing.streamId !== record.streamId)) {
9225
+ throw new Error("desktop ticket collision");
9226
+ }
9227
+ this.tickets.set(ticket, record);
9228
+ return record;
9229
+ }
9230
+ consume(ticket, side, accountId) {
9231
+ const record = this.tickets.get(ticket);
9232
+ if (!record)
9233
+ return null;
9234
+ this.tickets.delete(ticket);
9235
+ if (record.side !== side)
9236
+ return null;
9237
+ if (record.expiresAt <= this.now())
9238
+ return null;
9239
+ if (accountId !== undefined && record.accountId !== accountId)
9240
+ return null;
9241
+ return record;
9242
+ }
9243
+ revokeForStream(streamId) {
9244
+ for (const [ticket, record] of [...this.tickets]) {
9245
+ if (record.streamId === streamId)
9246
+ this.tickets.delete(ticket);
9247
+ }
9248
+ }
9249
+ sweepExpired() {
9250
+ const now = this.now();
9251
+ let removed = 0;
9252
+ for (const [ticket, record] of [...this.tickets]) {
9253
+ if (record.expiresAt <= now) {
9254
+ this.tickets.delete(ticket);
9255
+ removed += 1;
9256
+ }
9257
+ }
9258
+ return removed;
9259
+ }
9260
+ size() {
9261
+ return this.tickets.size;
9262
+ }
9263
+ }
9264
+
9265
+ // packages/relay/src/gateway/desktop-stream-registry.ts
9266
+ import {
9267
+ DESKTOP_MAX_STREAMS_PER_ACCOUNT,
9268
+ DESKTOP_MAX_STREAMS_PER_INSTANCE
9269
+ } from "@ganglion/xacpx-relay-protocol";
9270
+
9271
+ class DesktopStreamRegistry {
9272
+ records = new Map;
9273
+ now;
9274
+ maxPerInstance;
9275
+ maxPerAccount;
9276
+ createStreamId;
9277
+ seq = 0;
9278
+ constructor(options = {}) {
9279
+ this.now = options.now ?? Date.now;
9280
+ this.maxPerInstance = options.maxStreamsPerInstance ?? DESKTOP_MAX_STREAMS_PER_INSTANCE;
9281
+ this.maxPerAccount = options.maxStreamsPerAccount ?? DESKTOP_MAX_STREAMS_PER_ACCOUNT;
9282
+ this.createStreamId = options.createStreamId ?? (() => `ds-${Date.now().toString(36)}-${(this.seq += 1).toString(36)}`);
9283
+ }
9284
+ reserve(input) {
9285
+ const now = this.now();
9286
+ this.pruneClosed();
9287
+ const liveForInstance = [...this.records.values()].filter((r) => r.instanceId === input.instanceId && r.accountId === input.accountId && r.state !== "closed");
9288
+ if (liveForInstance.length >= this.maxPerInstance) {
9289
+ return { ok: false, code: "desktop-busy", scope: "instance" };
9290
+ }
9291
+ const liveForAccount = [...this.records.values()].filter((r) => r.accountId === input.accountId && r.state !== "closed");
9292
+ if (liveForAccount.length >= this.maxPerAccount) {
9293
+ return { ok: false, code: "desktop-busy", scope: "account" };
9294
+ }
9295
+ const record = {
9296
+ streamId: this.createStreamId(),
9297
+ accountId: input.accountId,
9298
+ instanceId: input.instanceId,
9299
+ state: "preparing",
9300
+ createdAt: now,
9301
+ expiresAt: now + input.ttlMs
9302
+ };
9303
+ this.records.set(record.streamId, record);
9304
+ return { ok: true, record };
9305
+ }
9306
+ get(streamId) {
9307
+ return this.records.get(streamId);
9308
+ }
9309
+ setState(streamId, state) {
9310
+ const record = this.records.get(streamId);
9311
+ if (!record || record.state === "closed")
9312
+ return;
9313
+ record.state = state;
9314
+ return record;
9315
+ }
9316
+ extendExpiry(streamId, expiresAt) {
9317
+ const record = this.records.get(streamId);
9318
+ if (!record || record.state === "closed")
9319
+ return;
9320
+ if (expiresAt > record.expiresAt)
9321
+ record.expiresAt = expiresAt;
9322
+ return record;
9323
+ }
9324
+ isLive(record, now = this.now()) {
9325
+ return record !== undefined && record.state !== "closed" && record.expiresAt > now;
9326
+ }
9327
+ close(streamId) {
9328
+ const record = this.records.get(streamId);
9329
+ if (!record)
9330
+ return;
9331
+ record.state = "closed";
9332
+ return record;
9333
+ }
9334
+ closeForInstance(instanceId) {
9335
+ return [...this.records.values()].filter((r) => r.instanceId === instanceId && r.state !== "closed");
9336
+ }
9337
+ sweepExpired(now = this.now()) {
9338
+ return [...this.records.values()].filter((r) => (r.state === "preparing" || r.state === "waiting-browser") && r.expiresAt <= now);
9339
+ }
9340
+ pruneClosed() {
9341
+ for (const [id, record] of [...this.records]) {
9342
+ if (record.state === "closed")
9343
+ this.records.delete(id);
9344
+ }
9345
+ }
9346
+ }
9347
+
9348
+ // packages/relay/src/gateway/desktop-stream-gateway.ts
9349
+ function wireCloseReason(reason) {
9350
+ const bytes = Buffer.byteLength(reason, "utf8");
9351
+ if (bytes <= 64)
9352
+ return reason;
9353
+ return "stream-closed";
9354
+ }
9355
+ function wireCloseCode(reason) {
9356
+ return reason === "backpressure" ? 1013 : 1000;
9357
+ }
9358
+ var PRE_ATTACH_MAX_STREAMS = 64;
9359
+ var PRE_ATTACH_MAX_BYTES_PER_STREAM = 64 * 1024;
9360
+
9361
+ class DesktopStreamGateway {
9362
+ tickets;
9363
+ streams;
9364
+ paired = new Map;
9365
+ preAttach = new Map;
9366
+ logger;
9367
+ maxPayloadBytes;
9368
+ hardCloseBufferedBytes;
9369
+ onStreamClosed;
9370
+ constructor(options = {}) {
9371
+ this.tickets = options.tickets ?? new DesktopTicketStore;
9372
+ this.streams = options.streams ?? new DesktopStreamRegistry;
9373
+ this.logger = options.logger ?? createNoopRelayLogger();
9374
+ this.maxPayloadBytes = options.maxPayloadBytes ?? DESKTOP_WS_MAX_PAYLOAD_BYTES;
9375
+ this.hardCloseBufferedBytes = options.hardCloseBufferedBytes ?? DESKTOP_BUFFERED_HARD_CLOSE_BYTES;
9376
+ this.onStreamClosed = options.onStreamClosed;
9377
+ }
9378
+ get ticketStore() {
9379
+ return this.tickets;
9380
+ }
9381
+ get streamRegistry() {
9382
+ return this.streams;
9383
+ }
9384
+ reserve(input) {
9385
+ this.sweepExpired();
9386
+ return this.streams.reserve(input);
9387
+ }
9388
+ sweepExpired() {
9389
+ const expired = this.streams.sweepExpired();
9390
+ for (const record of expired) {
9391
+ this.closeStream(record.streamId, "stream-expired");
9392
+ }
9393
+ if (expired.length > 0)
9394
+ this.streams.pruneClosed();
9395
+ this.tickets.sweepExpired();
9396
+ return expired.length;
9397
+ }
9398
+ mintBrowserTicket(input) {
9399
+ const ticket = this.tickets.mintTicket({ ...input, side: "browser" });
9400
+ this.streams.extendExpiry(ticket.streamId, ticket.expiresAt);
9401
+ return ticket;
9402
+ }
9403
+ attachBrowser(ticket, socket, authenticatedAccountId) {
9404
+ const record = this.tickets.consume(ticket, "browser", authenticatedAccountId);
9405
+ if (!record)
9406
+ return this.reject(socket, "unknown-or-reused-ticket");
9407
+ return this.pair(record, "browser", socket);
9408
+ }
9409
+ precheckConnectorTicket(ticket) {
9410
+ const record = this.tickets.consume(ticket, "connector");
9411
+ if (!record)
9412
+ return { ok: false, reason: "unknown-or-reused-ticket" };
9413
+ const registryRecord = this.streams.get(record.streamId);
9414
+ if (!this.streams.isLive(registryRecord))
9415
+ return { ok: false, reason: "stream-expired" };
9416
+ if (registryRecord.accountId !== record.accountId || registryRecord.instanceId !== record.instanceId) {
9417
+ return { ok: false, reason: "ticket-identity-mismatch" };
9418
+ }
9419
+ return { ok: true, claim: { streamId: record.streamId, accountId: record.accountId, instanceId: record.instanceId } };
9420
+ }
9421
+ attachConnector(ticketOrClaim, socket) {
9422
+ if (typeof ticketOrClaim !== "string")
9423
+ return this.pair(ticketOrClaim, "connector", socket);
9424
+ const record = this.tickets.consume(ticketOrClaim, "connector");
9425
+ if (!record)
9426
+ return this.reject(socket, "unknown-or-reused-ticket");
9427
+ return this.pair(record, "connector", socket);
9428
+ }
9429
+ streamState(streamId) {
9430
+ const record = this.streams.get(streamId);
9431
+ return record?.state;
9432
+ }
9433
+ goActive(streamId, security) {
9434
+ const record = this.streams.get(streamId);
9435
+ if (!record || record.state === "closed")
9436
+ return;
9437
+ this.streams.setState(streamId, "active");
9438
+ if (!this.flushPreAttach(streamId))
9439
+ return;
9440
+ this.logger.info("relay.desktop.stream_active", "desktop stream active", { streamId, security });
9441
+ }
9442
+ reportConnectorReady(streamId, security) {
9443
+ const record = this.streams.get(streamId);
9444
+ if (!record || record.state === "closed")
9445
+ return false;
9446
+ if (security !== "vnc-auth")
9447
+ return false;
9448
+ const pair = this.paired.get(streamId) ?? {};
9449
+ pair.security = security;
9450
+ this.paired.set(streamId, pair);
9451
+ if (pair.browser && pair.connector)
9452
+ this.goActive(streamId, security);
9453
+ else
9454
+ this.streams.setState(streamId, "waiting-browser");
9455
+ return true;
9456
+ }
9457
+ closeStream(streamId, reason = "closed") {
9458
+ const pair = this.paired.get(streamId);
9459
+ const record = this.streams.get(streamId);
9460
+ const known = pair !== undefined || record !== undefined && record.state !== "closed";
9461
+ this.paired.delete(streamId);
9462
+ this.preAttach.delete(streamId);
9463
+ this.streams.close(streamId);
9464
+ this.tickets.revokeForStream(streamId);
9465
+ if (!known)
9466
+ return;
9467
+ this.logger.info("relay.desktop.stream_closed", "desktop stream closed", { streamId, reason });
9468
+ const wireReason = wireCloseReason(reason);
9469
+ const wireCode = wireCloseCode(reason);
9470
+ try {
9471
+ pair?.browser?.close(wireCode, wireReason);
9472
+ } catch {}
9473
+ try {
9474
+ pair?.connector?.close(wireCode, wireReason);
9475
+ } catch {}
9476
+ this.onStreamClosed?.(streamId);
9477
+ }
9478
+ closeForInstance(instanceId, reason = "instance-offline") {
9479
+ for (const record of this.streams.closeForInstance(instanceId)) {
9480
+ this.closeStream(record.streamId, reason);
9481
+ }
9482
+ }
9483
+ pair(record, side, socket) {
9484
+ const streamId = record.streamId;
9485
+ const registryRecord = this.streams.get(streamId);
9486
+ if (!this.streams.isLive(registryRecord))
9487
+ return this.reject(socket, "stream-expired");
9488
+ if (registryRecord.accountId !== record.accountId || registryRecord.instanceId !== record.instanceId) {
9489
+ return this.reject(socket, "ticket-identity-mismatch");
9490
+ }
9491
+ const pair = this.paired.get(streamId) ?? {};
9492
+ if (pair[side])
9493
+ return this.reject(socket, "side-already-attached");
9494
+ pair[side] = socket;
9495
+ this.paired.set(streamId, pair);
9496
+ socket.on("message", (data, isBinary) => this.onFrame(streamId, side, socket, data, isBinary));
9497
+ socket.on("close", () => this.closeStream(streamId, `${side}-close`));
9498
+ if (pair.browser && pair.connector && pair.security) {
9499
+ this.goActive(streamId, pair.security);
9500
+ }
9501
+ return { ok: true, streamId };
9502
+ }
9503
+ onFrame(streamId, from, socket, data, isBinary) {
9504
+ const record = this.streams.get(streamId);
9505
+ const pair = this.paired.get(streamId);
9506
+ if (!record || record.state === "closed" || !pair) {
9507
+ return;
9508
+ }
9509
+ if (!isBinary || !(data instanceof Uint8Array)) {
9510
+ this.logger.warn("relay.desktop.text_frame", "desktop text frame rejected", { streamId });
9511
+ this.closeStream(streamId, "text-frame");
9512
+ return;
9513
+ }
9514
+ if (data.byteLength === 0 || data.byteLength > this.maxPayloadBytes) {
9515
+ this.logger.warn("relay.desktop.oversize_frame", "desktop oversize frame rejected", {
9516
+ streamId,
9517
+ bytes: data.byteLength
9518
+ });
9519
+ this.closeStream(streamId, "oversize-frame");
9520
+ return;
9521
+ }
9522
+ const peer = from === "browser" ? pair.connector : pair.browser;
9523
+ if (!peer) {
9524
+ this.bufferPreAttach(streamId, data);
9525
+ return;
9526
+ }
9527
+ if (peer.bufferedAmount > this.hardCloseBufferedBytes || socket.bufferedAmount > this.hardCloseBufferedBytes) {
9528
+ this.logger.warn("relay.desktop.backpressure_close", "slow desktop peer evicted", { streamId });
9529
+ this.closeStream(streamId, "backpressure");
9530
+ return;
9531
+ }
9532
+ try {
9533
+ peer.send(data);
9534
+ } catch {
9535
+ this.closeStream(streamId, "send-failed");
9536
+ }
9537
+ }
9538
+ reject(socket, reason) {
9539
+ try {
9540
+ socket.close(4403, reason);
9541
+ } catch {}
9542
+ return { ok: false, reason };
9543
+ }
9544
+ bufferPreAttach(streamId, data) {
9545
+ const existing = this.preAttach.get(streamId);
9546
+ const bytes = (existing?.bytes ?? 0) + data.byteLength;
9547
+ if (bytes > PRE_ATTACH_MAX_BYTES_PER_STREAM) {
9548
+ this.logger.warn("relay.desktop.preattach_overflow", "pre-attach desktop buffer overflow", { streamId });
9549
+ this.closeStream(streamId, "preattach-overflow");
9550
+ return;
9551
+ }
9552
+ if (!existing && this.preAttach.size >= PRE_ATTACH_MAX_STREAMS) {
9553
+ this.logger.warn("relay.desktop.preattach_overflow", "too many pre-attach desktop streams", { streamId });
9554
+ this.closeStream(streamId, "preattach-overflow");
9555
+ return;
9556
+ }
9557
+ const entry = existing ?? { chunks: [], bytes: 0 };
9558
+ entry.chunks.push(data);
9559
+ entry.bytes = bytes;
9560
+ this.preAttach.set(streamId, entry);
9561
+ }
9562
+ flushPreAttach(streamId) {
9563
+ const entry = this.preAttach.get(streamId);
9564
+ if (!entry)
9565
+ return true;
9566
+ this.preAttach.delete(streamId);
9567
+ const pair = this.paired.get(streamId);
9568
+ const peer = pair?.browser;
9569
+ if (!peer || pair?.connector === undefined)
9570
+ return true;
9571
+ for (const chunk of entry.chunks) {
9572
+ try {
9573
+ peer.send(chunk);
9574
+ } catch {
9575
+ this.closeStream(streamId, "send-failed");
9576
+ return false;
9577
+ }
9578
+ }
9579
+ return true;
9580
+ }
9581
+ }
9582
+
9583
+ // packages/relay/src/gateway/desktop-viewer-cancel.ts
9584
+ import { MSG as MSG2 } from "@ganglion/xacpx-relay-protocol";
9585
+ function sendDesktopCancel(gateway, instanceId, streamId) {
9586
+ try {
9587
+ gateway?.sendEvent(instanceId, MSG2.desktopCancel, { streamId });
9588
+ } catch {}
9589
+ }
9590
+ function cancelViewerDesktopStreamByRequest(gateway, desktop, owners, requestId, viewerId, reason = "viewer-disconnected") {
9591
+ for (const [streamId, owner] of [...owners]) {
9592
+ if (owner.requestId !== requestId || owner.viewerId !== viewerId)
9593
+ continue;
9594
+ const state = desktop.streamState(streamId);
9595
+ if (state !== "preparing" && state !== "waiting-browser")
9596
+ continue;
9597
+ sendDesktopCancel(gateway, owner.instanceId, streamId);
9598
+ owners.delete(streamId);
9599
+ desktop.closeStream(streamId, reason);
9600
+ return true;
9601
+ }
9602
+ return false;
9603
+ }
9604
+ function cancelViewerDesktopStreams(gateway, desktop, owners, viewerId) {
9605
+ for (const [streamId, owner] of [...owners]) {
9606
+ if (owner.viewerId !== viewerId)
9607
+ continue;
9608
+ sendDesktopCancel(gateway, owner.instanceId, streamId);
9609
+ owners.delete(streamId);
9610
+ desktop.closeStream(streamId, "viewer-disconnected");
9611
+ }
9612
+ }
9613
+
8959
9614
  // packages/relay/src/push.ts
8960
9615
  var import_web_push = __toESM(require_src2(), 1);
8961
9616
  import { createECDH } from "node:crypto";
@@ -9190,7 +9845,9 @@ class PushSubscriptionStore {
9190
9845
  import {
9191
9846
  decodeEnvelope as decodeEnvelope2,
9192
9847
  isErrorPayload,
9193
- MSG as MSG2,
9848
+ DESKTOP_HUB_REQUEST_TIMEOUT_MS,
9849
+ MAX_DESKTOP_ERROR_MESSAGE_LENGTH,
9850
+ MSG as MSG3,
9194
9851
  parseTerminalEventPayload,
9195
9852
  parseWebClientMessage
9196
9853
  } from "@ganglion/xacpx-relay-protocol";
@@ -9203,6 +9860,15 @@ function fail(deps, socket, requestId, instanceId, code, message) {
9203
9860
  message
9204
9861
  });
9205
9862
  }
9863
+ function failDesktop(deps, socket, requestId, instanceId, code, message) {
9864
+ deps.webGateway.send(socket, {
9865
+ kind: "desktop-request-failed",
9866
+ requestId,
9867
+ instanceId,
9868
+ code,
9869
+ message
9870
+ });
9871
+ }
9206
9872
  function mapConnectorError(err) {
9207
9873
  if (err instanceof Error) {
9208
9874
  if (err.message === "instance-offline") {
@@ -9218,16 +9884,24 @@ function mapConnectorError(err) {
9218
9884
  }
9219
9885
  return { code: "terminal-protocol-error", message: String(err) };
9220
9886
  }
9221
- async function sendConnectorRequest(deps, instanceId, type, payload) {
9887
+ function mapDesktopConnectorError(err) {
9888
+ if (err instanceof Error) {
9889
+ if (err.message === "instance-offline" || err.message === "instance-reconnected") {
9890
+ return { code: "desktop-instance-offline", message: "instance is offline" };
9891
+ }
9892
+ if (err.message === "timeout") {
9893
+ return { code: "desktop-stream-timeout", message: "desktop prepare timed out" };
9894
+ }
9895
+ return { code: "desktop-protocol-error", message: err.message.slice(0, 160) };
9896
+ }
9897
+ return { code: "desktop-protocol-error", message: String(err).slice(0, 160) };
9898
+ }
9899
+ async function sendConnectorRequest(deps, instanceId, type, payload, timeoutMs = TERMINAL_REQUEST_TIMEOUT_MS) {
9222
9900
  try {
9223
- return await deps.gateway.sendRequest(instanceId, type, payload, {
9224
- timeoutMs: TERMINAL_REQUEST_TIMEOUT_MS
9225
- });
9901
+ return await deps.gateway.sendRequest(instanceId, type, payload, { timeoutMs });
9226
9902
  } catch (err) {
9227
9903
  if (err instanceof Error && err.message === "instance-reconnected" && deps.gateway.isOnline(instanceId)) {
9228
- return await deps.gateway.sendRequest(instanceId, type, payload, {
9229
- timeoutMs: TERMINAL_REQUEST_TIMEOUT_MS
9230
- });
9904
+ return await deps.gateway.sendRequest(instanceId, type, payload, { timeoutMs });
9231
9905
  }
9232
9906
  throw err;
9233
9907
  }
@@ -9263,6 +9937,35 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9263
9937
  instanceId,
9264
9938
  ...deps.stateSnapshot(instanceId)
9265
9939
  });
9940
+ const open = (deps.interactions?.listForInstance(instanceId) ?? []).filter((entry) => entry.expiresAt > Date.now());
9941
+ const frames = open.map((entry) => ({
9942
+ chatKey: entry.chatKey,
9943
+ sessionAlias: entry.sessionAlias,
9944
+ instanceId,
9945
+ interaction: {
9946
+ requestId: entry.requestId,
9947
+ kind: entry.kind,
9948
+ ...entry.conversation !== undefined ? { conversation: entry.conversation } : {},
9949
+ expiresAt: entry.expiresAt,
9950
+ ...entry.kind === "elicitation" ? { elicitation: entry.elicitation } : { permission: entry.permission }
9951
+ }
9952
+ }));
9953
+ for (const frame of frames) {
9954
+ deps.webGateway.send(socket, {
9955
+ kind: "control-event",
9956
+ instanceId,
9957
+ event: { type: "interaction-opened", ...frame }
9958
+ });
9959
+ }
9960
+ deps.webGateway.send(socket, {
9961
+ kind: "interaction-snapshot",
9962
+ instanceId,
9963
+ interactions: frames.map((frame) => ({
9964
+ chatKey: frame.chatKey,
9965
+ sessionAlias: frame.sessionAlias,
9966
+ interaction: frame.interaction
9967
+ }))
9968
+ });
9266
9969
  }
9267
9970
  return;
9268
9971
  }
@@ -9276,12 +9979,20 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9276
9979
  const viewerId = deps.webGateway.getViewerId(socket);
9277
9980
  if (!viewerId || !deps.webGateway.socketOwnsAttachment(socket, msg.attachmentId))
9278
9981
  return;
9279
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalStreamStart, {
9982
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalStreamStart, {
9280
9983
  attachmentId: msg.attachmentId,
9281
9984
  viewerId
9282
9985
  });
9283
9986
  return;
9284
9987
  }
9988
+ if (msg.kind === "desktop-open") {
9989
+ await handleDesktopOpen(deps, accountId, socket, msg);
9990
+ return;
9991
+ }
9992
+ if (msg.kind === "desktop-close") {
9993
+ handleDesktopClose(deps, accountId, socket, msg);
9994
+ return;
9995
+ }
9285
9996
  if (msg.kind === "terminal-take-control") {
9286
9997
  await handleTakeControl(deps, socket, msg);
9287
9998
  return;
@@ -9298,7 +10009,7 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9298
10009
  const viewerId = deps.webGateway.getViewerId(socket);
9299
10010
  if (!viewerId || !deps.webGateway.socketOwnsAttachment(socket, msg.attachmentId))
9300
10011
  return;
9301
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalInput, {
10012
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalInput, {
9302
10013
  attachmentId: msg.attachmentId,
9303
10014
  generation: msg.generation,
9304
10015
  viewerId,
@@ -9310,7 +10021,7 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9310
10021
  const viewerId = deps.webGateway.getViewerId(socket);
9311
10022
  if (!viewerId || !deps.webGateway.socketOwnsAttachment(socket, msg.attachmentId))
9312
10023
  return;
9313
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalResize, {
10024
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalResize, {
9314
10025
  attachmentId: msg.attachmentId,
9315
10026
  generation: msg.generation,
9316
10027
  viewerId,
@@ -9323,7 +10034,7 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9323
10034
  const viewerId = deps.webGateway.getViewerId(socket);
9324
10035
  if (!viewerId || !deps.webGateway.socketOwnsAttachment(socket, msg.attachmentId))
9325
10036
  return;
9326
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalHeartbeat, {
10037
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalHeartbeat, {
9327
10038
  attachmentId: msg.attachmentId,
9328
10039
  viewerId
9329
10040
  });
@@ -9334,7 +10045,7 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9334
10045
  return;
9335
10046
  const binding = deps.webGateway.unbindAttachment(msg.attachmentId);
9336
10047
  if (binding) {
9337
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalDetach, {
10048
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalDetach, {
9338
10049
  attachmentId: msg.attachmentId,
9339
10050
  viewerId: binding.viewerId
9340
10051
  });
@@ -9342,18 +10053,18 @@ async function handleWebClientMessageAsync(deps, accountId, socket, raw) {
9342
10053
  return;
9343
10054
  }
9344
10055
  if (msg.kind === "terminal-input" && "terminalId" in msg) {
9345
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalInput, {
10056
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalInput, {
9346
10057
  terminalId: msg.terminalId,
9347
10058
  data: msg.data
9348
10059
  });
9349
10060
  } else if (msg.kind === "terminal-resize" && "terminalId" in msg) {
9350
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalResize, {
10061
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalResize, {
9351
10062
  terminalId: msg.terminalId,
9352
10063
  cols: msg.cols,
9353
10064
  rows: msg.rows
9354
10065
  });
9355
10066
  } else if (msg.kind === "terminal-close") {
9356
- deps.gateway.sendEvent(msg.instanceId, MSG2.terminalClose, { terminalId: msg.terminalId });
10067
+ deps.gateway.sendEvent(msg.instanceId, MSG3.terminalClose, { terminalId: msg.terminalId });
9357
10068
  }
9358
10069
  }
9359
10070
  async function handleTerminalOpen(deps, accountId, socket, msg) {
@@ -9368,7 +10079,7 @@ async function handleTerminalOpen(deps, accountId, socket, msg) {
9368
10079
  }
9369
10080
  let payload;
9370
10081
  try {
9371
- payload = await sendConnectorRequest(deps, msg.instanceId, MSG2.terminalOpen, {
10082
+ payload = await sendConnectorRequest(deps, msg.instanceId, MSG3.terminalOpen, {
9372
10083
  chatKey: `relay:${accountId}`,
9373
10084
  sessionAlias: msg.sessionAlias,
9374
10085
  viewerId,
@@ -9421,8 +10132,113 @@ async function handleTerminalOpen(deps, accountId, socket, msg) {
9421
10132
  detachConnectorAttachment(deps, msg.instanceId, result.attachmentId, viewerId);
9422
10133
  }
9423
10134
  }
10135
+ async function handleDesktopOpen(deps, accountId, socket, msg) {
10136
+ if (!deps.desktop) {
10137
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, "desktop-protocol-error", "desktop is not enabled on this hub");
10138
+ return;
10139
+ }
10140
+ const ownerViewerId = deps.webGateway.getViewerId(socket);
10141
+ if (!ownerViewerId) {
10142
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, "desktop-protocol-error", "missing viewer identity");
10143
+ return;
10144
+ }
10145
+ const owned = deps.instances.getOwned(msg.instanceId, accountId);
10146
+ if (!owned)
10147
+ return;
10148
+ if (!deps.gateway.isOnline(msg.instanceId)) {
10149
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, "desktop-instance-offline", "instance is offline");
10150
+ return;
10151
+ }
10152
+ if (!owned.capabilities?.includes("desktop.rfb.v1")) {
10153
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, "desktop-disabled", "desktop is not enabled on this instance");
10154
+ return;
10155
+ }
10156
+ const reserved = deps.desktop.reserve(accountId, msg.instanceId);
10157
+ if (!reserved.ok) {
10158
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, reserved.code, reserved.scope === "account" ? "too many active desktop viewers on this account" : "another desktop viewer is active");
10159
+ return;
10160
+ }
10161
+ const streamId = reserved.streamId;
10162
+ deps.desktop.trackOwner(streamId, { viewerId: ownerViewerId, accountId, instanceId: msg.instanceId, requestId: msg.requestId });
10163
+ const cancelStream = (reason) => {
10164
+ sendDesktopCancel(deps.gateway, msg.instanceId, streamId);
10165
+ deps.desktop?.cancel(streamId, reason);
10166
+ };
10167
+ const failWith = (code, message) => {
10168
+ cancelStream(code);
10169
+ failDesktop(deps, socket, msg.requestId, msg.instanceId, code, message);
10170
+ };
10171
+ const connectorTicket = deps.desktop.mintConnectorTicket(streamId, accountId, msg.instanceId);
10172
+ let payload;
10173
+ try {
10174
+ payload = await deps.gateway.sendRequest(msg.instanceId, MSG3.desktopPrepare, {
10175
+ streamId,
10176
+ ticket: connectorTicket.ticket,
10177
+ expiresAt: connectorTicket.expiresAt
10178
+ }, { timeoutMs: DESKTOP_HUB_REQUEST_TIMEOUT_MS });
10179
+ } catch (err) {
10180
+ const mapped = mapDesktopConnectorError(err);
10181
+ failWith(mapped.code, mapped.message);
10182
+ return;
10183
+ }
10184
+ if (isErrorPayload(payload)) {
10185
+ const code = payload.error.code.length > 0 ? payload.error.code.slice(0, 128) : "desktop-protocol-error";
10186
+ failWith(code, payload.error.message.slice(0, MAX_DESKTOP_ERROR_MESSAGE_LENGTH));
10187
+ return;
10188
+ }
10189
+ const result = payload;
10190
+ if (!result || result.streamId !== streamId || result.security !== "vnc-auth" && result.security !== "ard") {
10191
+ failWith("desktop-protocol-error", "malformed prepare result");
10192
+ return;
10193
+ }
10194
+ if (result.security !== "vnc-auth") {
10195
+ failWith("desktop-auth-unsupported", "Apple Remote Desktop auth needs Phase B");
10196
+ return;
10197
+ }
10198
+ if (deps.webGateway.getViewerId(socket) !== ownerViewerId || !deps.desktop.ownsStream(streamId, ownerViewerId)) {
10199
+ failWith("desktop-stream-timeout", "requesting viewer disconnected");
10200
+ return;
10201
+ }
10202
+ if (!deps.desktop.markReady(streamId, result.security)) {
10203
+ failWith("desktop-stream-timeout", "desktop stream expired");
10204
+ return;
10205
+ }
10206
+ const browserTicket = deps.desktop.mintBrowserTicket(streamId, accountId, msg.instanceId);
10207
+ const sent = deps.webGateway.send(socket, {
10208
+ kind: "desktop-opened",
10209
+ requestId: msg.requestId,
10210
+ instanceId: msg.instanceId,
10211
+ streamId,
10212
+ wsPath: `/desktop/observe?ticket=${browserTicket.ticket}`,
10213
+ expiresAt: browserTicket.expiresAt,
10214
+ security: result.security
10215
+ });
10216
+ if (!sent) {
10217
+ failWith("desktop-stream-timeout", "requesting viewer disconnected");
10218
+ return;
10219
+ }
10220
+ }
10221
+ function handleDesktopClose(deps, accountId, socket, msg) {
10222
+ if (!deps.desktop)
10223
+ return;
10224
+ const viewerId = deps.webGateway.getViewerId(socket) ?? "";
10225
+ if (msg.streamId !== undefined) {
10226
+ const owner = deps.desktop.streamOwner(msg.streamId);
10227
+ if (!owner || owner.viewerId !== viewerId)
10228
+ return;
10229
+ if (owner.accountId !== accountId)
10230
+ return;
10231
+ sendDesktopCancel(deps.gateway, owner.instanceId, msg.streamId);
10232
+ deps.desktop.cancel(msg.streamId, "browser-close");
10233
+ return;
10234
+ }
10235
+ if (msg.requestId === undefined)
10236
+ return;
10237
+ if (!deps.desktop.cancelPendingByRequest(msg.requestId, viewerId, "browser-close"))
10238
+ return;
10239
+ }
9424
10240
  function detachConnectorAttachment(deps, instanceId, attachmentId, viewerId) {
9425
- deps.gateway.sendEvent(instanceId, MSG2.terminalDetach, {
10241
+ deps.gateway.sendEvent(instanceId, MSG3.terminalDetach, {
9426
10242
  attachmentId,
9427
10243
  viewerId
9428
10244
  });
@@ -9434,7 +10250,7 @@ async function handleTakeControl(deps, socket, msg) {
9434
10250
  return;
9435
10251
  }
9436
10252
  try {
9437
- const payload = await sendConnectorRequest(deps, msg.instanceId, MSG2.terminalTakeControl, { attachmentId: msg.attachmentId, generation: msg.generation, viewerId });
10253
+ const payload = await sendConnectorRequest(deps, msg.instanceId, MSG3.terminalTakeControl, { attachmentId: msg.attachmentId, generation: msg.generation, viewerId });
9438
10254
  if (isErrorPayload(payload)) {
9439
10255
  fail(deps, socket, msg.requestId, msg.instanceId, payload.error.code, payload.error.message);
9440
10256
  return;
@@ -9462,7 +10278,7 @@ async function handleResync(deps, socket, msg) {
9462
10278
  return;
9463
10279
  }
9464
10280
  try {
9465
- const payload = await sendConnectorRequest(deps, msg.instanceId, MSG2.terminalResync, { attachmentId: msg.attachmentId, generation: msg.generation, viewerId });
10281
+ const payload = await sendConnectorRequest(deps, msg.instanceId, MSG3.terminalResync, { attachmentId: msg.attachmentId, generation: msg.generation, viewerId });
9466
10282
  if (isErrorPayload(payload)) {
9467
10283
  fail(deps, socket, msg.requestId, msg.instanceId, payload.error.code, payload.error.message);
9468
10284
  return;
@@ -9481,7 +10297,7 @@ async function handleResync(deps, socket, msg) {
9481
10297
  }
9482
10298
  async function handleTerminate(deps, socket, msg) {
9483
10299
  try {
9484
- const payload = await sendConnectorRequest(deps, msg.instanceId, MSG2.terminalTerminate, { terminalId: msg.terminalId, generation: msg.generation });
10300
+ const payload = await sendConnectorRequest(deps, msg.instanceId, MSG3.terminalTerminate, { terminalId: msg.terminalId, generation: msg.generation });
9485
10301
  if (isErrorPayload(payload)) {
9486
10302
  fail(deps, socket, msg.requestId, msg.instanceId, payload.error.code, payload.error.message);
9487
10303
  return;
@@ -9507,8 +10323,8 @@ async function handleTerminate(deps, socket, msg) {
9507
10323
  }
9508
10324
  }
9509
10325
  function handleConnectorTerminalEvent(webGateway, instanceId, envelopeType, payload) {
9510
- if (envelopeType === MSG2.terminalViewerEvent) {
9511
- const parsed = parseTerminalEventPayload(MSG2.terminalViewerEvent, payload);
10326
+ if (envelopeType === MSG3.terminalViewerEvent) {
10327
+ const parsed = parseTerminalEventPayload(MSG3.terminalViewerEvent, payload);
9512
10328
  if (!parsed)
9513
10329
  return true;
9514
10330
  const p = parsed;
@@ -9599,8 +10415,8 @@ function handleConnectorTerminalEvent(webGateway, instanceId, envelopeType, payl
9599
10415
  webGateway.sendToAttachment(p.viewerId, p.attachmentId, event);
9600
10416
  return true;
9601
10417
  }
9602
- if (envelopeType === MSG2.terminalResourceExit) {
9603
- const parsed = parseTerminalEventPayload(MSG2.terminalResourceExit, payload);
10418
+ if (envelopeType === MSG3.terminalResourceExit) {
10419
+ const parsed = parseTerminalEventPayload(MSG3.terminalResourceExit, payload);
9604
10420
  if (!parsed)
9605
10421
  return true;
9606
10422
  const p = parsed;
@@ -9626,7 +10442,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
9626
10442
  import { createECDH as createECDH2, randomUUID as randomUUID4 } from "node:crypto";
9627
10443
  import {
9628
10444
  isErrorPayload as isErrorPayload2,
9629
- MSG as MSG3,
10445
+ MSG as MSG4,
9630
10446
  parseControlPayload
9631
10447
  } from "@ganglion/xacpx-relay-protocol";
9632
10448
 
@@ -9698,12 +10514,18 @@ function compactDetail(detail, isSubagent) {
9698
10514
  return {
9699
10515
  type: "command",
9700
10516
  command: detail.command,
9701
- ...detail.exitCode !== undefined ? { exitCode: detail.exitCode } : {}
10517
+ ...detail.exitCode !== undefined ? { exitCode: detail.exitCode } : {},
10518
+ ...detail.truncated !== undefined ? { truncated: detail.truncated } : {}
9702
10519
  };
9703
10520
  case "read":
9704
10521
  return { type: "read", path: detail.path, ...detail.lines ? { lines: detail.lines } : {} };
9705
10522
  case "search":
9706
- return { type: "search", query: detail.query };
10523
+ return {
10524
+ type: "search",
10525
+ query: detail.query,
10526
+ ...detail.count !== undefined ? { count: detail.count } : {},
10527
+ ...detail.truncated !== undefined ? { truncated: detail.truncated } : {}
10528
+ };
9707
10529
  case "text":
9708
10530
  return { type: "text", text: clip(detail.text, COMPACT_DETAIL_PREVIEW) };
9709
10531
  case "fields":
@@ -9878,25 +10700,25 @@ var LOGIN_FAILURES_SWEEP_AT = 1024;
9878
10700
  var LOGIN_FAILURES_MAX = 4096;
9879
10701
  var GLOBAL_MAX_FAILURES = 200;
9880
10702
  var CHAT_SCOPED_TYPES = new Set([
9881
- MSG3.prompt,
9882
- MSG3.promptCancel,
9883
- MSG3.commandExecute,
9884
- MSG3.queueCancel,
9885
- MSG3.scheduledList,
9886
- MSG3.scheduledCreate,
9887
- MSG3.scheduledCancel,
9888
- MSG3.sessionsList,
9889
- MSG3.sessionsCreate,
9890
- MSG3.sessionsNativeList,
9891
- MSG3.sessionsRemove,
9892
- MSG3.sessionsArchive,
9893
- MSG3.sessionsUnarchive,
9894
- MSG3.sessionsRename,
9895
- MSG3.sessionModelGet,
9896
- MSG3.sessionModelSet,
9897
- MSG3.sessionEffortGet,
9898
- MSG3.sessionEffortSet,
9899
- MSG3.terminalCreate
10703
+ MSG4.prompt,
10704
+ MSG4.promptCancel,
10705
+ MSG4.commandExecute,
10706
+ MSG4.queueCancel,
10707
+ MSG4.scheduledList,
10708
+ MSG4.scheduledCreate,
10709
+ MSG4.scheduledCancel,
10710
+ MSG4.sessionsList,
10711
+ MSG4.sessionsCreate,
10712
+ MSG4.sessionsNativeList,
10713
+ MSG4.sessionsRemove,
10714
+ MSG4.sessionsArchive,
10715
+ MSG4.sessionsUnarchive,
10716
+ MSG4.sessionsRename,
10717
+ MSG4.sessionModelGet,
10718
+ MSG4.sessionModelSet,
10719
+ MSG4.sessionEffortGet,
10720
+ MSG4.sessionEffortSet,
10721
+ MSG4.terminalCreate
9900
10722
  ]);
9901
10723
  function requireJson(contentType) {
9902
10724
  return (contentType ?? "").toLowerCase().includes("application/json");
@@ -9908,6 +10730,12 @@ function safePreviewUrl(v) {
9908
10730
  return;
9909
10731
  }
9910
10732
  var RPC_MAX_BODY_BYTES = 16 * 1024 * 1024;
10733
+ function validateInteractionResponsePayload(payload) {
10734
+ const parsed = parseControlPayload(MSG4.interactionRespond, payload);
10735
+ if (!parsed)
10736
+ return null;
10737
+ return parsed;
10738
+ }
9911
10739
  var MAX_PERSISTED_ATTACHMENTS = 5;
9912
10740
  var MAX_ATTACHMENT_FIELD_LEN = 256;
9913
10741
  function boundField(v) {
@@ -9915,16 +10743,16 @@ function boundField(v) {
9915
10743
  }
9916
10744
  function rpcSessionLocks(type, payload) {
9917
10745
  const value = payload;
9918
- if (type === MSG3.prompt) {
10746
+ if (type === MSG4.prompt) {
9919
10747
  return typeof value.sessionAlias === "string" && value.sessionAlias ? { alias: value.sessionAlias, lifecycle: false, turn: "shared" } : undefined;
9920
10748
  }
9921
- if (type === MSG3.commandExecute) {
10749
+ if (type === MSG4.commandExecute) {
9922
10750
  return typeof value.sessionAlias === "string" && value.sessionAlias ? { alias: value.sessionAlias, lifecycle: false, turn: "exclusive" } : undefined;
9923
10751
  }
9924
- if (type === MSG3.sessionsRename) {
10752
+ if (type === MSG4.sessionsRename) {
9925
10753
  return typeof value.alias === "string" && value.alias ? { alias: value.alias, lifecycle: true, turn: "none" } : undefined;
9926
10754
  }
9927
- if (type === MSG3.sessionsCreate || type === MSG3.sessionsRemove || type === MSG3.sessionsArchive || type === MSG3.sessionsUnarchive) {
10755
+ if (type === MSG4.sessionsCreate || type === MSG4.sessionsRemove || type === MSG4.sessionsArchive || type === MSG4.sessionsUnarchive) {
9928
10756
  return typeof value.alias === "string" && value.alias ? { alias: value.alias, lifecycle: true, turn: "exclusive" } : undefined;
9929
10757
  }
9930
10758
  return;
@@ -10016,6 +10844,20 @@ function createApp(deps) {
10016
10844
  const now = deps.now ?? (() => new Date);
10017
10845
  const acquireSessionLifecycleRpcLock = createKeyedRpcLock();
10018
10846
  const sessionTurnRpcLock = createKeyedRwLock();
10847
+ deps.interactions?.onClose((closed) => {
10848
+ const owner = deps.instances.getOwned(closed.instanceId, closed.accountId);
10849
+ if (owner === null)
10850
+ return;
10851
+ deps.gateway.broadcastControlEvent?.(closed.accountId, {
10852
+ type: "interaction-closed",
10853
+ chatKey: `relay:${closed.accountId}`,
10854
+ sessionAlias: "",
10855
+ instanceId: owner.id,
10856
+ requestId: closed.requestId,
10857
+ reason: closed.reason,
10858
+ ...closed.action !== undefined && (closed.action === "accept" || closed.action === "decline" || closed.action === "cancel") ? { action: closed.action } : {}
10859
+ });
10860
+ });
10019
10861
  const loginFailures = new Map;
10020
10862
  let globalFailures = { count: 0, windowStart: 0 };
10021
10863
  function sweepPerIpLoginFailures(nowMs) {
@@ -10286,27 +11128,61 @@ function createApp(deps) {
10286
11128
  ...payload,
10287
11129
  chatKey: `relay:${account.id}`,
10288
11130
  senderId: account.id,
10289
- isOwner: true
11131
+ isOwner: true,
11132
+ chatType: "direct"
10290
11133
  };
10291
11134
  }
11135
+ if (body.type === MSG4.conversationPrompt || body.type === MSG4.conversationPromptWithPolicy) {
11136
+ payload = {
11137
+ ...payload,
11138
+ humanIngress: {
11139
+ chatKey: `relay:${account.id}`,
11140
+ senderId: account.id,
11141
+ accountId: account.id,
11142
+ senderName: account.username,
11143
+ isOwner: true,
11144
+ chatType: "direct"
11145
+ }
11146
+ };
11147
+ }
11148
+ if (body.type === MSG4.interactionRespond) {
11149
+ const answer = validateInteractionResponsePayload(payload);
11150
+ if (!answer)
11151
+ return c.json({ error: "invalid-payload" }, 400);
11152
+ const registry = deps.interactions;
11153
+ if (!registry)
11154
+ return c.json({ error: "interaction-unavailable" }, 503);
11155
+ const pending = registry.get(answer.requestId);
11156
+ if (!pending || pending.accountId !== account.id || pending.kind !== answer.kind) {
11157
+ return c.json({ error: "interaction-gone" }, 409);
11158
+ }
11159
+ const closed = registry.answer(answer.requestId, answer);
11160
+ if (!closed) {
11161
+ return c.json({ error: "interaction-gone" }, 409);
11162
+ }
11163
+ return c.json({ ok: true });
11164
+ }
11165
+ if (body.type === MSG4.interactionRequest || body.type === MSG4.interactionWithdraw) {
11166
+ return c.json({ error: "connector-only" }, 403);
11167
+ }
10292
11168
  const releaseSessionRpcLocks = [];
10293
11169
  let persistedPromptId;
10294
11170
  let webPromptRequestId;
10295
11171
  try {
10296
- if (body.type === MSG3.prompt && !parseControlPayload(MSG3.prompt, payload)) {
11172
+ if (body.type === MSG4.prompt && !parseControlPayload(MSG4.prompt, payload)) {
10297
11173
  return c.json({ error: "invalid-payload" }, 400);
10298
11174
  }
10299
- if (body.type === MSG3.commandExecute && !parseControlPayload(MSG3.commandExecute, payload)) {
11175
+ if (body.type === MSG4.commandExecute && !parseControlPayload(MSG4.commandExecute, payload)) {
10300
11176
  return c.json({ error: "invalid-payload" }, 400);
10301
11177
  }
10302
- if (body.type === MSG3.upload && !parseControlPayload(MSG3.upload, payload)) {
11178
+ if (body.type === MSG4.upload && !parseControlPayload(MSG4.upload, payload)) {
10303
11179
  return c.json({ error: "invalid-payload" }, 400);
10304
11180
  }
10305
- const removeInput = body.type === MSG3.sessionsRemove ? parseControlPayload(MSG3.sessionsRemove, payload) : undefined;
10306
- if (body.type === MSG3.sessionsRemove && !removeInput) {
11181
+ const removeInput = body.type === MSG4.sessionsRemove ? parseControlPayload(MSG4.sessionsRemove, payload) : undefined;
11182
+ if (body.type === MSG4.sessionsRemove && !removeInput) {
10307
11183
  return c.json({ error: "invalid-payload" }, 400);
10308
11184
  }
10309
- if (body.type === MSG3.upload) {
11185
+ if (body.type === MSG4.upload) {
10310
11186
  const up = payload;
10311
11187
  const approxBytes = up.content ? Math.floor(up.content.length * 3 / 4) : 0;
10312
11188
  if (approxBytes > 10 * 1024 * 1024)
@@ -10324,7 +11200,7 @@ function createApp(deps) {
10324
11200
  releaseSessionRpcLocks.push(await sessionTurnRpcLock.acquireExclusive(key));
10325
11201
  }
10326
11202
  }
10327
- if (body.type === MSG3.prompt || body.type === MSG3.commandExecute) {
11203
+ if (body.type === MSG4.prompt || body.type === MSG4.commandExecute) {
10328
11204
  const p = payload;
10329
11205
  if (p.sessionAlias && p.text !== undefined) {
10330
11206
  const attachments = (p.media ?? []).slice(0, MAX_PERSISTED_ATTACHMENTS).map((m) => {
@@ -10339,11 +11215,11 @@ function createApp(deps) {
10339
11215
  };
10340
11216
  });
10341
11217
  const promptRequestId = randomUUID4();
10342
- if (body.type === MSG3.prompt) {
11218
+ if (body.type === MSG4.prompt) {
10343
11219
  webPromptRequestId = promptRequestId;
10344
11220
  }
10345
11221
  persistedPromptId = deps.messages.append(instance.id, p.sessionAlias, "in", p.text, undefined, attachments, promptRequestId);
10346
- if (body.type === MSG3.prompt && typeof payload === "object" && payload !== null) {
11222
+ if (body.type === MSG4.prompt && typeof payload === "object" && payload !== null) {
10347
11223
  payload.promptRequestId = promptRequestId;
10348
11224
  deps.onWebPromptCreated?.({
10349
11225
  promptRequestId,
@@ -10354,11 +11230,11 @@ function createApp(deps) {
10354
11230
  }
10355
11231
  }
10356
11232
  const result = await deps.gateway.sendRequest(instance.id, body.type, payload);
10357
- const isPromptRejection = body.type === MSG3.prompt && typeof result === "object" && result !== null && "ok" in result && result.ok === false;
11233
+ const isPromptRejection = body.type === MSG4.prompt && typeof result === "object" && result !== null && "ok" in result && result.ok === false;
10358
11234
  if (webPromptRequestId && (isErrorPayload2(result) || isPromptRejection)) {
10359
11235
  deps.onWebPromptRejected?.(webPromptRequestId);
10360
11236
  }
10361
- if (body.type === MSG3.prompt && persistedPromptId !== undefined && typeof result === "object" && result !== null && result.queued === true && typeof result.queueItemId === "string") {
11237
+ if (body.type === MSG4.prompt && persistedPromptId !== undefined && typeof result === "object" && result !== null && result.queued === true && typeof result.queueItemId === "string") {
10362
11238
  const p = payload;
10363
11239
  const queueItemId = result.queueItemId;
10364
11240
  deps.messages.markQueued(persistedPromptId, {
@@ -10374,7 +11250,7 @@ function createApp(deps) {
10374
11250
  });
10375
11251
  }
10376
11252
  }
10377
- if (body.type === MSG3.queueCancel && !isErrorPayload2(result)) {
11253
+ if (body.type === MSG4.queueCancel && !isErrorPayload2(result)) {
10378
11254
  const p = payload;
10379
11255
  if (typeof p.itemId === "string") {
10380
11256
  const cancelResult = result;
@@ -10383,7 +11259,7 @@ function createApp(deps) {
10383
11259
  }
10384
11260
  }
10385
11261
  }
10386
- if ((body.type === MSG3.sessionsRemove || body.type === MSG3.sessionsArchive) && !isErrorPayload2(result)) {
11262
+ if ((body.type === MSG4.sessionsRemove || body.type === MSG4.sessionsArchive) && !isErrorPayload2(result)) {
10387
11263
  const targetAlias = removeInput ? removeInput.alias : typeof payload === "object" && payload !== null && ("alias" in payload) && typeof payload.alias === "string" ? payload.alias : undefined;
10388
11264
  if (targetAlias) {
10389
11265
  deps.onWebPromptSessionCleared?.(instance.id, targetAlias);
@@ -10393,7 +11269,7 @@ function createApp(deps) {
10393
11269
  deps.messages.deleteBySession(instance.id, removeInput.alias);
10394
11270
  deps.turnSlotAnchors?.deleteBySession(instance.id, removeInput.alias);
10395
11271
  }
10396
- if (body.type === MSG3.commandExecute) {
11272
+ if (body.type === MSG4.commandExecute) {
10397
11273
  const p = payload;
10398
11274
  const output = result?.output;
10399
11275
  if (p.sessionAlias && typeof output === "string")
@@ -10533,13 +11409,23 @@ async function createRelayRuntime(dbPath, options = {}) {
10533
11409
  const instances = new InstanceStore(db);
10534
11410
  const messages = new MessageStore(db);
10535
11411
  let gatewayRef = null;
11412
+ const desktopStreamOwners = new Map;
11413
+ const desktop = new DesktopStreamGateway({
11414
+ logger,
11415
+ onStreamClosed: (streamId) => {
11416
+ desktopStreamOwners.delete(streamId);
11417
+ }
11418
+ });
10536
11419
  const webGateway = new WebGateway({
10537
11420
  logger,
10538
11421
  onAttachmentDetached: (info) => {
10539
- gatewayRef?.sendEvent(info.instanceId, MSG4.terminalDetach, {
11422
+ gatewayRef?.sendEvent(info.instanceId, MSG5.terminalDetach, {
10540
11423
  attachmentId: info.attachmentId,
10541
11424
  viewerId: info.viewerId
10542
11425
  });
11426
+ },
11427
+ onViewerClosed: (viewerId) => {
11428
+ cancelViewerDesktopStreams(gatewayRef, desktop, desktopStreamOwners, viewerId);
10543
11429
  }
10544
11430
  });
10545
11431
  const pushSubscriptions = new PushSubscriptionStore(db);
@@ -10667,7 +11553,9 @@ async function createRelayRuntime(dbPath, options = {}) {
10667
11553
  parts: a.parts,
10668
11554
  status: a.text ? "streaming" : "working",
10669
11555
  startedAt: a.startedAt,
10670
- ...typeof a.slotAfterId === "number" ? { slotAfterId: a.slotAfterId } : {}
11556
+ ...typeof a.slotAfterId === "number" ? { slotAfterId: a.slotAfterId } : {},
11557
+ ...a.conversation ? { conversation: a.conversation } : {},
11558
+ ...a.truncated ? { truncated: true } : {}
10671
11559
  });
10672
11560
  }
10673
11561
  return out;
@@ -10702,17 +11590,30 @@ async function createRelayRuntime(dbPath, options = {}) {
10702
11590
  a.parts.push({ type: "tool", step });
10703
11591
  };
10704
11592
  const pendingCompletionRoutes = new PendingCompletionRouteStore(db);
11593
+ const interactions = new InteractionRegistry({
11594
+ debug: (event, message, fields) => logger.debug(event, message, fields)
11595
+ });
11596
+ const broadcastControlEvent = (accountId, event) => {
11597
+ webGateway.broadcast(accountId, {
11598
+ kind: "control-event",
11599
+ instanceId: event.instanceId ?? "",
11600
+ event
11601
+ });
11602
+ };
10705
11603
  const gateway = new InstanceGateway({
10706
11604
  instances,
10707
11605
  accounts,
10708
11606
  requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
10709
11607
  logger,
10710
11608
  pendingCompletionRoutes,
11609
+ interactions,
11610
+ broadcastControlEvent,
10711
11611
  onDirectoryChange: (accountId, endpoints) => {
10712
11612
  webGateway.broadcast(accountId, { kind: "agent-directory", endpoints });
10713
11613
  },
10714
11614
  onStatusChange: (instanceId, accountId, online) => {
10715
11615
  if (!online) {
11616
+ desktop.closeForInstance(instanceId, "instance-offline");
10716
11617
  const prefix = `${instanceId}\x00`;
10717
11618
  for (const k of turnBuffers.keys())
10718
11619
  if (k.startsWith(prefix))
@@ -10822,7 +11723,7 @@ async function createRelayRuntime(dbPath, options = {}) {
10822
11723
  messages.append(instanceId, sessionAlias, "out", text, structured, undefined, undefined, undefined, slot.startedAt, slot.slotAfterId, slot.startedAfterSeq);
10823
11724
  };
10824
11725
  try {
10825
- if (envelope.type === MSG4.instanceEvent) {
11726
+ if (envelope.type === MSG5.instanceEvent) {
10826
11727
  const raw = envelope.payload?.event;
10827
11728
  if (!validControlEvent(raw)) {
10828
11729
  logger.debug("relay.event.invalid", "dropped malformed instance event", {
@@ -10870,7 +11771,8 @@ async function createRelayRuntime(dbPath, options = {}) {
10870
11771
  startedAt,
10871
11772
  slotAfterId: slot.slotAfterId,
10872
11773
  ...slot.startedAfterSeq !== undefined ? { startedAfterSeq: slot.startedAfterSeq } : {},
10873
- ...notification ? { notification } : {}
11774
+ ...notification ? { notification } : {},
11775
+ ...event.conversation ? { conversation: event.conversation } : {}
10874
11776
  });
10875
11777
  outbound = { ...event, slotAfterId: slot.slotAfterId, startedAt };
10876
11778
  } catch (err) {
@@ -10961,7 +11863,7 @@ async function createRelayRuntime(dbPath, options = {}) {
10961
11863
  if (promptRequestId) {
10962
11864
  removePendingWebPrompt(promptRequestId);
10963
11865
  }
10964
- gateway.sendEvent(instanceId, MSG4.instanceRecoveryAck, { recoveryIds: [recoveryId] });
11866
+ gateway.sendEvent(instanceId, MSG5.instanceRecoveryAck, { recoveryIds: [recoveryId] });
10965
11867
  } else {
10966
11868
  flush();
10967
11869
  if (promptRequestId) {
@@ -10991,9 +11893,13 @@ async function createRelayRuntime(dbPath, options = {}) {
10991
11893
  });
10992
11894
  }
10993
11895
  } else if (event.type === "turn-usage") {
10994
- sessionUsage.set(key(instanceId, event.sessionAlias), { used: event.used, size: event.size, ...event.cost ? { cost: event.cost } : {}, ...event.breakdown ? { breakdown: event.breakdown } : {} });
11896
+ if (!event.conversation) {
11897
+ sessionUsage.set(key(instanceId, event.sessionAlias), { used: event.used, size: event.size, ...event.cost ? { cost: event.cost } : {}, ...event.breakdown ? { breakdown: event.breakdown } : {} });
11898
+ }
10995
11899
  } else if (event.type === "agent-commands") {
10996
- sessionCommands.set(key(instanceId, event.sessionAlias), event.commands);
11900
+ if (!event.conversation) {
11901
+ sessionCommands.set(key(instanceId, event.sessionAlias), event.commands);
11902
+ }
10997
11903
  } else if (event.type === "session-history") {
10998
11904
  const existing = messages.listBySession(accountId, instanceId, event.sessionAlias, { limit: 1 });
10999
11905
  if (existing.messages.length === 0) {
@@ -11010,7 +11916,7 @@ async function createRelayRuntime(dbPath, options = {}) {
11010
11916
  } else if (event.type === "agent-message-completion") {
11011
11917
  messages.patchAgentMessageCompletionStatus(instanceId, event.sessionAlias, event.messageId, event.completionStatus);
11012
11918
  }
11013
- } else if (envelope.type === MSG4.instanceStateSync) {
11919
+ } else if (envelope.type === MSG5.instanceStateSync) {
11014
11920
  if (!validInstanceStateSync(envelope.payload)) {
11015
11921
  logger.debug("relay.event.invalid", "dropped malformed instance state sync", { instanceId });
11016
11922
  return;
@@ -11117,7 +12023,7 @@ async function createRelayRuntime(dbPath, options = {}) {
11117
12023
  }
11118
12024
  }
11119
12025
  if (ackedRecoveryIds.length > 0) {
11120
- gateway.sendEvent(instanceId, MSG4.instanceRecoveryAck, { recoveryIds: ackedRecoveryIds });
12026
+ gateway.sendEvent(instanceId, MSG5.instanceRecoveryAck, { recoveryIds: ackedRecoveryIds });
11121
12027
  }
11122
12028
  for (const turn of sync.turns) {
11123
12029
  reconcileInboundPrompt(turn.sessionAlias, { prompt: turn.prompt, queueItemId: turn.queueItemId, scheduled: turn.scheduled, promptRequestId: turn.promptRequestId }, false);
@@ -11170,13 +12076,27 @@ async function createRelayRuntime(dbPath, options = {}) {
11170
12076
  };
11171
12077
  })(),
11172
12078
  ...turn.truncated ? { truncated: true } : {},
11173
- ...notification ? { notification } : {}
12079
+ ...notification ? { notification } : {},
12080
+ ...turn.conversation ? { conversation: turn.conversation } : {}
11174
12081
  });
11175
12082
  }
12083
+ const productSessionAliases = new Set;
12084
+ for (const turn of sync.turns) {
12085
+ if (turn.conversation)
12086
+ productSessionAliases.add(turn.sessionAlias);
12087
+ }
12088
+ for (const finished of sync.finishedOffline) {
12089
+ if (finished.conversation)
12090
+ productSessionAliases.add(finished.sessionAlias);
12091
+ }
11176
12092
  for (const meter of sync.usage) {
12093
+ if (productSessionAliases.has(meter.sessionAlias))
12094
+ continue;
11177
12095
  sessionUsage.set(key(instanceId, meter.sessionAlias), { used: meter.used, size: meter.size, ...meter.cost ? { cost: meter.cost } : {}, ...meter.breakdown ? { breakdown: meter.breakdown } : {} });
11178
12096
  }
11179
12097
  for (const entry of sync.commands) {
12098
+ if (productSessionAliases.has(entry.sessionAlias))
12099
+ continue;
11180
12100
  sessionCommands.set(key(instanceId, entry.sessionAlias), entry.commands);
11181
12101
  }
11182
12102
  const keepAnchors = new Set;
@@ -11190,7 +12110,7 @@ async function createRelayRuntime(dbPath, options = {}) {
11190
12110
  gateway.disconnect(instanceId);
11191
12111
  throw err;
11192
12112
  }
11193
- } else if (envelope.type === MSG4.instanceNotice) {
12113
+ } else if (envelope.type === MSG5.instanceNotice) {
11194
12114
  const notice = envelope.payload;
11195
12115
  webGateway.broadcast(accountId, { kind: "notice", instanceId, notice });
11196
12116
  if (notice.kind === "task-completion") {
@@ -11220,6 +12140,7 @@ async function createRelayRuntime(dbPath, options = {}) {
11220
12140
  checkUpdate: createRelayUpdateChecker({ current: readRelayVersion() }),
11221
12141
  vapidPublicKey: vapid ? () => vapid.publicKey : undefined,
11222
12142
  pushSubscriptions,
12143
+ interactions,
11223
12144
  onWebPromptCreated: ({ promptRequestId, instanceId, sessionAlias }) => {
11224
12145
  recordPendingWebPrompt(promptRequestId, instanceId, sessionAlias);
11225
12146
  },
@@ -11239,6 +12160,8 @@ async function createRelayRuntime(dbPath, options = {}) {
11239
12160
  });
11240
12161
  const completionRouteSweepTimer = setInterval(() => gateway.sweepExpiredCompletionRoutes(), 60 * 60000);
11241
12162
  completionRouteSweepTimer.unref?.();
12163
+ const desktopSweepTimer = setInterval(() => desktop.sweepExpired(), DESKTOP_TICKET_TTL_MS2);
12164
+ desktopSweepTimer.unref?.();
11242
12165
  return {
11243
12166
  db,
11244
12167
  accounts,
@@ -11250,11 +12173,15 @@ async function createRelayRuntime(dbPath, options = {}) {
11250
12173
  pushNotifier,
11251
12174
  gateway,
11252
12175
  webGateway,
12176
+ desktop,
12177
+ desktopStreamOwners,
11253
12178
  stateSnapshot,
12179
+ interactions,
11254
12180
  pendingWebPromptsCount: () => pendingWebPrompts.size,
11255
12181
  app,
11256
12182
  close: () => {
11257
12183
  clearInterval(completionRouteSweepTimer);
12184
+ clearInterval(desktopSweepTimer);
11258
12185
  db.close();
11259
12186
  }
11260
12187
  };
@@ -11280,16 +12207,61 @@ async function startRelayServer(options) {
11280
12207
  }
11281
12208
  });
11282
12209
  const dedicated = options.wsPort !== undefined;
12210
+ const webWss = new WebSocketServer({ noServer: true, maxPayload: WEB_CLIENT_MAX_PAYLOAD_BYTES });
12211
+ const desktopBrowserWss = new WebSocketServer({ noServer: true, maxPayload: DESKTOP_WS_MAX_PAYLOAD_BYTES2 });
12212
+ const desktopConnectorWss = new WebSocketServer({ noServer: true, maxPayload: DESKTOP_WS_MAX_PAYLOAD_BYTES2 });
11283
12213
  let wss;
11284
12214
  let gatewayWss;
12215
+ let dedicatedControlWss;
12216
+ let gatewayHttpServer;
11285
12217
  if (dedicated) {
11286
- wss = new WebSocketServer({ port: options.wsPort, host });
11287
- await new Promise((resolve) => wss.on("listening", () => resolve()));
11288
- wss.on("connection", (socket) => runtime.gateway.handleConnection(socket));
12218
+ const dedicatedHttpServer = createServer((req, res) => {
12219
+ res.writeHead(426, { "Content-Type": "text/plain" });
12220
+ res.end("Upgrade Required");
12221
+ });
12222
+ gatewayHttpServer = dedicatedHttpServer;
12223
+ wss = new WebSocketServer({ noServer: true, maxPayload: DESKTOP_WS_MAX_PAYLOAD_BYTES2 });
12224
+ dedicatedControlWss = new WebSocketServer({ noServer: true });
12225
+ dedicatedHttpServer.on("upgrade", (req, socket, head) => {
12226
+ const path = (req.url ?? "").split("?")[0] ?? "";
12227
+ if (path === "/desktop/instance") {
12228
+ const ticket = desktopTicketFromUrl(req.url ?? "");
12229
+ if (!ticket) {
12230
+ try {
12231
+ socket.destroy();
12232
+ } catch {}
12233
+ return;
12234
+ }
12235
+ const precheck = runtime.desktop.precheckConnectorTicket(ticket);
12236
+ if (!precheck.ok) {
12237
+ try {
12238
+ socket.destroy();
12239
+ } catch {}
12240
+ return;
12241
+ }
12242
+ desktopConnectorWss.handleUpgrade(req, socket, head, (ws) => {
12243
+ const attached = runtime.desktop.attachConnector(precheck.claim, adaptDesktopSocket(ws));
12244
+ if (!attached.ok) {
12245
+ try {
12246
+ ws.close(4403, attached.reason);
12247
+ } catch {}
12248
+ }
12249
+ });
12250
+ return;
12251
+ }
12252
+ dedicatedControlWss.handleUpgrade(req, socket, head, (ws) => runtime.gateway.handleConnection(ws));
12253
+ });
12254
+ await new Promise((resolve, reject) => {
12255
+ const onErr = (err) => reject(err instanceof Error ? err : new Error(String(err)));
12256
+ dedicatedHttpServer.once("error", onErr);
12257
+ dedicatedHttpServer.listen(options.wsPort, host, () => {
12258
+ dedicatedHttpServer.removeListener("error", onErr);
12259
+ resolve();
12260
+ });
12261
+ });
11289
12262
  } else {
11290
12263
  gatewayWss = new WebSocketServer({ noServer: true });
11291
12264
  }
11292
- const webWss = new WebSocketServer({ noServer: true, maxPayload: WEB_CLIENT_MAX_PAYLOAD_BYTES });
11293
12265
  httpServer.on("upgrade", (req, socket, head) => {
11294
12266
  const path = (req.url ?? "").split("?")[0] ?? "";
11295
12267
  if (path === "/ws") {
@@ -11305,19 +12277,88 @@ async function startRelayServer(options) {
11305
12277
  instances: runtime.instances,
11306
12278
  gateway: runtime.gateway,
11307
12279
  webGateway: runtime.webGateway,
11308
- stateSnapshot: runtime.stateSnapshot
12280
+ stateSnapshot: runtime.stateSnapshot,
12281
+ desktop: {
12282
+ reserve: (reserveAccountId, instanceId) => {
12283
+ const reserved = runtime.desktop.reserve({
12284
+ accountId: reserveAccountId,
12285
+ instanceId,
12286
+ ttlMs: DESKTOP_TICKET_TTL_MS2
12287
+ });
12288
+ if (!reserved.ok)
12289
+ return reserved;
12290
+ return { ok: true, streamId: reserved.record.streamId };
12291
+ },
12292
+ mintConnectorTicket: (streamId, ticketAccountId, instanceId) => runtime.desktop.ticketStore.mintTicket({ streamId, accountId: ticketAccountId, instanceId, side: "connector" }),
12293
+ mintBrowserTicket: (streamId, ticketAccountId, instanceId) => runtime.desktop.mintBrowserTicket({ streamId, accountId: ticketAccountId, instanceId }),
12294
+ markReady: (streamId, security) => runtime.desktop.reportConnectorReady(streamId, security),
12295
+ cancel: (streamId, reason) => {
12296
+ runtime.desktopStreamOwners.delete(streamId);
12297
+ runtime.desktop.closeStream(streamId, reason);
12298
+ },
12299
+ cancelPendingByRequest: (requestId, ownerViewerId, reason) => cancelViewerDesktopStreamByRequest(runtime.gateway, runtime.desktop, runtime.desktopStreamOwners, requestId, ownerViewerId, reason),
12300
+ ownsStream: (streamId, ownerViewerId) => runtime.desktopStreamOwners.get(streamId)?.viewerId === ownerViewerId,
12301
+ streamOwner: (streamId) => runtime.desktopStreamOwners.get(streamId),
12302
+ trackOwner: (streamId, owner) => {
12303
+ runtime.desktopStreamOwners.set(streamId, owner);
12304
+ }
12305
+ },
12306
+ interactions: runtime.interactions
11309
12307
  }, account.id, ws, String(data)));
11310
12308
  });
11311
12309
  return;
11312
12310
  }
12311
+ if (path === "/desktop/observe") {
12312
+ const token = parseCookie(req.headers.cookie ?? "")["xrelay_session"];
12313
+ const account = token ? runtime.accounts.getSessionAccount(token) : null;
12314
+ if (!account) {
12315
+ socket.destroy();
12316
+ return;
12317
+ }
12318
+ const ticket = desktopTicketFromUrl(req.url ?? "");
12319
+ if (!ticket) {
12320
+ socket.destroy();
12321
+ return;
12322
+ }
12323
+ desktopBrowserWss.handleUpgrade(req, socket, head, (ws) => {
12324
+ const attached = runtime.desktop.attachBrowser(ticket, adaptDesktopSocket(ws), account.id);
12325
+ if (!attached.ok) {
12326
+ try {
12327
+ ws.close(4403, attached.reason);
12328
+ } catch {}
12329
+ }
12330
+ });
12331
+ return;
12332
+ }
11313
12333
  if (gatewayWss && (path === "/" || path === "/gateway" || path.startsWith("/gateway/"))) {
11314
12334
  gatewayWss.handleUpgrade(req, socket, head, (ws) => runtime.gateway.handleConnection(ws));
11315
12335
  return;
11316
12336
  }
12337
+ if (!dedicated && path === "/desktop/instance") {
12338
+ const ticket = desktopTicketFromUrl(req.url ?? "");
12339
+ if (!ticket) {
12340
+ socket.destroy();
12341
+ return;
12342
+ }
12343
+ const precheck = runtime.desktop.precheckConnectorTicket(ticket);
12344
+ if (!precheck.ok) {
12345
+ socket.destroy();
12346
+ return;
12347
+ }
12348
+ desktopConnectorWss.handleUpgrade(req, socket, head, (ws) => {
12349
+ const attached = runtime.desktop.attachConnector(precheck.claim, adaptDesktopSocket(ws));
12350
+ if (!attached.ok) {
12351
+ try {
12352
+ ws.close(4403, attached.reason);
12353
+ } catch {}
12354
+ }
12355
+ });
12356
+ return;
12357
+ }
11317
12358
  socket.destroy();
11318
12359
  });
11319
12360
  const httpPort = httpServer.address().port;
11320
- const wsPort = wss ? wss.address().port : null;
12361
+ const wsPort = dedicated && gatewayHttpServer ? gatewayHttpServer.address()?.port ?? null : null;
11321
12362
  return {
11322
12363
  runtime,
11323
12364
  httpPort,
@@ -11325,15 +12366,46 @@ async function startRelayServer(options) {
11325
12366
  close: async () => {
11326
12367
  stopMaintenance();
11327
12368
  await new Promise((resolve) => webWss.close(() => resolve()));
12369
+ await new Promise((resolve) => desktopBrowserWss.close(() => resolve()));
12370
+ await new Promise((resolve) => desktopConnectorWss.close(() => resolve()));
11328
12371
  if (gatewayWss)
11329
12372
  await new Promise((resolve) => gatewayWss.close(() => resolve()));
12373
+ if (dedicatedControlWss)
12374
+ await new Promise((resolve) => dedicatedControlWss.close(() => resolve()));
11330
12375
  if (wss)
11331
12376
  await new Promise((resolve) => wss.close(() => resolve()));
12377
+ if (gatewayHttpServer) {
12378
+ await new Promise((resolve) => gatewayHttpServer.close(() => resolve()));
12379
+ }
11332
12380
  await new Promise((resolve) => httpServer.close(() => resolve()));
11333
12381
  runtime.close();
11334
12382
  }
11335
12383
  };
11336
12384
  }
12385
+ function desktopTicketFromUrl(url) {
12386
+ const query = url.split("?")[1] ?? "";
12387
+ for (const part of query.split("&")) {
12388
+ const idx = part.indexOf("=");
12389
+ if (idx === -1)
12390
+ continue;
12391
+ if (part.slice(0, idx) !== "ticket")
12392
+ continue;
12393
+ let ticket;
12394
+ try {
12395
+ ticket = decodeURIComponent(part.slice(idx + 1));
12396
+ } catch {
12397
+ return null;
12398
+ }
12399
+ if (ticket.length === 0 || ticket.length > MAX_DESKTOP_TICKET_LENGTH2)
12400
+ return null;
12401
+ return ticket;
12402
+ }
12403
+ return null;
12404
+ }
12405
+ function adaptDesktopSocket(ws) {
12406
+ ws.on("error", () => {});
12407
+ return ws;
12408
+ }
11337
12409
  function parseCookie(header) {
11338
12410
  const out = {};
11339
12411
  for (const part of header.split(";")) {