flaio-cli 0.3.1

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 (271) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +461 -0
  3. package/dist/agents/agent-detector.d.ts +21 -0
  4. package/dist/agents/agent-detector.d.ts.map +1 -0
  5. package/dist/agents/agent-detector.js +109 -0
  6. package/dist/agents/agent-detector.js.map +1 -0
  7. package/dist/agents/agent-registry.d.ts +6 -0
  8. package/dist/agents/agent-registry.d.ts.map +1 -0
  9. package/dist/agents/agent-registry.js +25 -0
  10. package/dist/agents/agent-registry.js.map +1 -0
  11. package/dist/agents/agent-session.d.ts +60 -0
  12. package/dist/agents/agent-session.d.ts.map +1 -0
  13. package/dist/agents/agent-session.js +251 -0
  14. package/dist/agents/agent-session.js.map +1 -0
  15. package/dist/agents/drivers/base-driver.d.ts +37 -0
  16. package/dist/agents/drivers/base-driver.d.ts.map +1 -0
  17. package/dist/agents/drivers/base-driver.js +18 -0
  18. package/dist/agents/drivers/base-driver.js.map +1 -0
  19. package/dist/agents/drivers/claude-driver.d.ts +23 -0
  20. package/dist/agents/drivers/claude-driver.d.ts.map +1 -0
  21. package/dist/agents/drivers/claude-driver.js +61 -0
  22. package/dist/agents/drivers/claude-driver.js.map +1 -0
  23. package/dist/agents/drivers/gemini-driver.d.ts +23 -0
  24. package/dist/agents/drivers/gemini-driver.d.ts.map +1 -0
  25. package/dist/agents/drivers/gemini-driver.js +60 -0
  26. package/dist/agents/drivers/gemini-driver.js.map +1 -0
  27. package/dist/agents/session-metadata.d.ts +33 -0
  28. package/dist/agents/session-metadata.d.ts.map +1 -0
  29. package/dist/agents/session-metadata.js +34 -0
  30. package/dist/agents/session-metadata.js.map +1 -0
  31. package/dist/agents/sideband/hook-scripts.d.ts +29 -0
  32. package/dist/agents/sideband/hook-scripts.d.ts.map +1 -0
  33. package/dist/agents/sideband/hook-scripts.js +259 -0
  34. package/dist/agents/sideband/hook-scripts.js.map +1 -0
  35. package/dist/agents/sideband/sideband-receiver.d.ts +37 -0
  36. package/dist/agents/sideband/sideband-receiver.d.ts.map +1 -0
  37. package/dist/agents/sideband/sideband-receiver.js +203 -0
  38. package/dist/agents/sideband/sideband-receiver.js.map +1 -0
  39. package/dist/agents/sideband/status-resolver.d.ts +55 -0
  40. package/dist/agents/sideband/status-resolver.d.ts.map +1 -0
  41. package/dist/agents/sideband/status-resolver.js +194 -0
  42. package/dist/agents/sideband/status-resolver.js.map +1 -0
  43. package/dist/app.d.ts +3 -0
  44. package/dist/app.d.ts.map +1 -0
  45. package/dist/app.js +94 -0
  46. package/dist/app.js.map +1 -0
  47. package/dist/cli.d.ts +3 -0
  48. package/dist/cli.d.ts.map +1 -0
  49. package/dist/cli.js +188 -0
  50. package/dist/cli.js.map +1 -0
  51. package/dist/config/config.d.ts +341 -0
  52. package/dist/config/config.d.ts.map +1 -0
  53. package/dist/config/config.js +94 -0
  54. package/dist/config/config.js.map +1 -0
  55. package/dist/config/defaults.d.ts +33 -0
  56. package/dist/config/defaults.d.ts.map +1 -0
  57. package/dist/config/defaults.js +33 -0
  58. package/dist/config/defaults.js.map +1 -0
  59. package/dist/connectors/adapters/discord-adapter.d.ts +25 -0
  60. package/dist/connectors/adapters/discord-adapter.d.ts.map +1 -0
  61. package/dist/connectors/adapters/discord-adapter.js +154 -0
  62. package/dist/connectors/adapters/discord-adapter.js.map +1 -0
  63. package/dist/connectors/adapters/slack-adapter.d.ts +38 -0
  64. package/dist/connectors/adapters/slack-adapter.d.ts.map +1 -0
  65. package/dist/connectors/adapters/slack-adapter.js +502 -0
  66. package/dist/connectors/adapters/slack-adapter.js.map +1 -0
  67. package/dist/connectors/adapters/telegram-adapter.d.ts +25 -0
  68. package/dist/connectors/adapters/telegram-adapter.d.ts.map +1 -0
  69. package/dist/connectors/adapters/telegram-adapter.js +164 -0
  70. package/dist/connectors/adapters/telegram-adapter.js.map +1 -0
  71. package/dist/connectors/connector-interface.d.ts +41 -0
  72. package/dist/connectors/connector-interface.d.ts.map +1 -0
  73. package/dist/connectors/connector-interface.js +2 -0
  74. package/dist/connectors/connector-interface.js.map +1 -0
  75. package/dist/connectors/connector-manager.d.ts +24 -0
  76. package/dist/connectors/connector-manager.d.ts.map +1 -0
  77. package/dist/connectors/connector-manager.js +82 -0
  78. package/dist/connectors/connector-manager.js.map +1 -0
  79. package/dist/connectors/debug.d.ts +3 -0
  80. package/dist/connectors/debug.d.ts.map +1 -0
  81. package/dist/connectors/debug.js +16 -0
  82. package/dist/connectors/debug.js.map +1 -0
  83. package/dist/hooks/hook-server.d.ts +27 -0
  84. package/dist/hooks/hook-server.d.ts.map +1 -0
  85. package/dist/hooks/hook-server.js +149 -0
  86. package/dist/hooks/hook-server.js.map +1 -0
  87. package/dist/hooks/hook.d.ts +10 -0
  88. package/dist/hooks/hook.d.ts.map +1 -0
  89. package/dist/hooks/hook.js +58 -0
  90. package/dist/hooks/hook.js.map +1 -0
  91. package/dist/hooks/install.d.ts +8 -0
  92. package/dist/hooks/install.d.ts.map +1 -0
  93. package/dist/hooks/install.js +160 -0
  94. package/dist/hooks/install.js.map +1 -0
  95. package/dist/hooks/notification-hook.d.ts +9 -0
  96. package/dist/hooks/notification-hook.d.ts.map +1 -0
  97. package/dist/hooks/notification-hook.js +38 -0
  98. package/dist/hooks/notification-hook.js.map +1 -0
  99. package/dist/hooks/post-tool-hook.d.ts +9 -0
  100. package/dist/hooks/post-tool-hook.d.ts.map +1 -0
  101. package/dist/hooks/post-tool-hook.js +39 -0
  102. package/dist/hooks/post-tool-hook.js.map +1 -0
  103. package/dist/hooks/stop-hook.d.ts +9 -0
  104. package/dist/hooks/stop-hook.d.ts.map +1 -0
  105. package/dist/hooks/stop-hook.js +37 -0
  106. package/dist/hooks/stop-hook.js.map +1 -0
  107. package/dist/portal/ansi-renderer.d.ts +15 -0
  108. package/dist/portal/ansi-renderer.d.ts.map +1 -0
  109. package/dist/portal/ansi-renderer.js +75 -0
  110. package/dist/portal/ansi-renderer.js.map +1 -0
  111. package/dist/portal/portal-client.d.ts +18 -0
  112. package/dist/portal/portal-client.d.ts.map +1 -0
  113. package/dist/portal/portal-client.js +335 -0
  114. package/dist/portal/portal-client.js.map +1 -0
  115. package/dist/portal/portal-picker.d.ts +8 -0
  116. package/dist/portal/portal-picker.d.ts.map +1 -0
  117. package/dist/portal/portal-picker.js +145 -0
  118. package/dist/portal/portal-picker.js.map +1 -0
  119. package/dist/portal/portal-server.d.ts +24 -0
  120. package/dist/portal/portal-server.d.ts.map +1 -0
  121. package/dist/portal/portal-server.js +330 -0
  122. package/dist/portal/portal-server.js.map +1 -0
  123. package/dist/portal/shared.d.ts +79 -0
  124. package/dist/portal/shared.d.ts.map +1 -0
  125. package/dist/portal/shared.js +5 -0
  126. package/dist/portal/shared.js.map +1 -0
  127. package/dist/relay/rate-limiter.d.ts +16 -0
  128. package/dist/relay/rate-limiter.d.ts.map +1 -0
  129. package/dist/relay/rate-limiter.js +37 -0
  130. package/dist/relay/rate-limiter.js.map +1 -0
  131. package/dist/relay/relay-auth.d.ts +29 -0
  132. package/dist/relay/relay-auth.d.ts.map +1 -0
  133. package/dist/relay/relay-auth.js +129 -0
  134. package/dist/relay/relay-auth.js.map +1 -0
  135. package/dist/relay/relay-client.d.ts +52 -0
  136. package/dist/relay/relay-client.d.ts.map +1 -0
  137. package/dist/relay/relay-client.js +1102 -0
  138. package/dist/relay/relay-client.js.map +1 -0
  139. package/dist/relay/relay-crypto.d.ts +29 -0
  140. package/dist/relay/relay-crypto.d.ts.map +1 -0
  141. package/dist/relay/relay-crypto.js +100 -0
  142. package/dist/relay/relay-crypto.js.map +1 -0
  143. package/dist/relay/relay-message-schemas.d.ts +276 -0
  144. package/dist/relay/relay-message-schemas.d.ts.map +1 -0
  145. package/dist/relay/relay-message-schemas.js +137 -0
  146. package/dist/relay/relay-message-schemas.js.map +1 -0
  147. package/dist/relay/relay-protocol.d.ts +444 -0
  148. package/dist/relay/relay-protocol.d.ts.map +1 -0
  149. package/dist/relay/relay-protocol.js +15 -0
  150. package/dist/relay/relay-protocol.js.map +1 -0
  151. package/dist/relay/relay-store.d.ts +23 -0
  152. package/dist/relay/relay-store.d.ts.map +1 -0
  153. package/dist/relay/relay-store.js +54 -0
  154. package/dist/relay/relay-store.js.map +1 -0
  155. package/dist/relay/ticket-tracker.d.ts +18 -0
  156. package/dist/relay/ticket-tracker.d.ts.map +1 -0
  157. package/dist/relay/ticket-tracker.js +58 -0
  158. package/dist/relay/ticket-tracker.js.map +1 -0
  159. package/dist/store/app-store.d.ts +66 -0
  160. package/dist/store/app-store.d.ts.map +1 -0
  161. package/dist/store/app-store.js +227 -0
  162. package/dist/store/app-store.js.map +1 -0
  163. package/dist/store/connector-store.d.ts +21 -0
  164. package/dist/store/connector-store.d.ts.map +1 -0
  165. package/dist/store/connector-store.js +591 -0
  166. package/dist/store/connector-store.js.map +1 -0
  167. package/dist/store/portal-store.d.ts +5 -0
  168. package/dist/store/portal-store.d.ts.map +1 -0
  169. package/dist/store/portal-store.js +5 -0
  170. package/dist/store/portal-store.js.map +1 -0
  171. package/dist/store/settings-store.d.ts +13 -0
  172. package/dist/store/settings-store.d.ts.map +1 -0
  173. package/dist/store/settings-store.js +59 -0
  174. package/dist/store/settings-store.js.map +1 -0
  175. package/dist/terminal/pty-manager.d.ts +19 -0
  176. package/dist/terminal/pty-manager.d.ts.map +1 -0
  177. package/dist/terminal/pty-manager.js +58 -0
  178. package/dist/terminal/pty-manager.js.map +1 -0
  179. package/dist/terminal/screen-buffer.d.ts +36 -0
  180. package/dist/terminal/screen-buffer.d.ts.map +1 -0
  181. package/dist/terminal/screen-buffer.js +109 -0
  182. package/dist/terminal/screen-buffer.js.map +1 -0
  183. package/dist/terminal/xterm-bridge.d.ts +32 -0
  184. package/dist/terminal/xterm-bridge.d.ts.map +1 -0
  185. package/dist/terminal/xterm-bridge.js +168 -0
  186. package/dist/terminal/xterm-bridge.js.map +1 -0
  187. package/dist/ui/components/adopt-agent-dialog.d.ts +10 -0
  188. package/dist/ui/components/adopt-agent-dialog.d.ts.map +1 -0
  189. package/dist/ui/components/adopt-agent-dialog.js +31 -0
  190. package/dist/ui/components/adopt-agent-dialog.js.map +1 -0
  191. package/dist/ui/components/agent-tab.d.ts +17 -0
  192. package/dist/ui/components/agent-tab.d.ts.map +1 -0
  193. package/dist/ui/components/agent-tab.js +33 -0
  194. package/dist/ui/components/agent-tab.js.map +1 -0
  195. package/dist/ui/components/agents-settings-content.d.ts +7 -0
  196. package/dist/ui/components/agents-settings-content.d.ts.map +1 -0
  197. package/dist/ui/components/agents-settings-content.js +238 -0
  198. package/dist/ui/components/agents-settings-content.js.map +1 -0
  199. package/dist/ui/components/help-modal.d.ts +7 -0
  200. package/dist/ui/components/help-modal.d.ts.map +1 -0
  201. package/dist/ui/components/help-modal.js +29 -0
  202. package/dist/ui/components/help-modal.js.map +1 -0
  203. package/dist/ui/components/new-session-dialog.d.ts +8 -0
  204. package/dist/ui/components/new-session-dialog.d.ts.map +1 -0
  205. package/dist/ui/components/new-session-dialog.js +84 -0
  206. package/dist/ui/components/new-session-dialog.js.map +1 -0
  207. package/dist/ui/components/path-input.d.ts +8 -0
  208. package/dist/ui/components/path-input.d.ts.map +1 -0
  209. package/dist/ui/components/path-input.js +151 -0
  210. package/dist/ui/components/path-input.js.map +1 -0
  211. package/dist/ui/components/settings-panel.d.ts +7 -0
  212. package/dist/ui/components/settings-panel.d.ts.map +1 -0
  213. package/dist/ui/components/settings-panel.js +134 -0
  214. package/dist/ui/components/settings-panel.js.map +1 -0
  215. package/dist/ui/components/standalone-agents.d.ts +9 -0
  216. package/dist/ui/components/standalone-agents.d.ts.map +1 -0
  217. package/dist/ui/components/standalone-agents.js +28 -0
  218. package/dist/ui/components/standalone-agents.js.map +1 -0
  219. package/dist/ui/components/terminal-view.d.ts +12 -0
  220. package/dist/ui/components/terminal-view.d.ts.map +1 -0
  221. package/dist/ui/components/terminal-view.js +53 -0
  222. package/dist/ui/components/terminal-view.js.map +1 -0
  223. package/dist/ui/components/viewer-approval-dialog.d.ts +9 -0
  224. package/dist/ui/components/viewer-approval-dialog.d.ts.map +1 -0
  225. package/dist/ui/components/viewer-approval-dialog.js +38 -0
  226. package/dist/ui/components/viewer-approval-dialog.js.map +1 -0
  227. package/dist/ui/hooks/use-git-info.d.ts +8 -0
  228. package/dist/ui/hooks/use-git-info.d.ts.map +1 -0
  229. package/dist/ui/hooks/use-git-info.js +72 -0
  230. package/dist/ui/hooks/use-git-info.js.map +1 -0
  231. package/dist/ui/hooks/use-keybindings.d.ts +10 -0
  232. package/dist/ui/hooks/use-keybindings.d.ts.map +1 -0
  233. package/dist/ui/hooks/use-keybindings.js +91 -0
  234. package/dist/ui/hooks/use-keybindings.js.map +1 -0
  235. package/dist/ui/hooks/use-portal-connected.d.ts +2 -0
  236. package/dist/ui/hooks/use-portal-connected.d.ts.map +1 -0
  237. package/dist/ui/hooks/use-portal-connected.js +10 -0
  238. package/dist/ui/hooks/use-portal-connected.js.map +1 -0
  239. package/dist/ui/hooks/use-raw-input.d.ts +8 -0
  240. package/dist/ui/hooks/use-raw-input.d.ts.map +1 -0
  241. package/dist/ui/hooks/use-raw-input.js +106 -0
  242. package/dist/ui/hooks/use-raw-input.js.map +1 -0
  243. package/dist/ui/hooks/use-spinner.d.ts +2 -0
  244. package/dist/ui/hooks/use-spinner.d.ts.map +1 -0
  245. package/dist/ui/hooks/use-spinner.js +13 -0
  246. package/dist/ui/hooks/use-spinner.js.map +1 -0
  247. package/dist/ui/hooks/use-terminal-size.d.ts +5 -0
  248. package/dist/ui/hooks/use-terminal-size.d.ts.map +1 -0
  249. package/dist/ui/hooks/use-terminal-size.js +22 -0
  250. package/dist/ui/hooks/use-terminal-size.js.map +1 -0
  251. package/dist/ui/hooks/use-update-check.d.ts +6 -0
  252. package/dist/ui/hooks/use-update-check.d.ts.map +1 -0
  253. package/dist/ui/hooks/use-update-check.js +62 -0
  254. package/dist/ui/hooks/use-update-check.js.map +1 -0
  255. package/dist/ui/layout/main-pane.d.ts +13 -0
  256. package/dist/ui/layout/main-pane.d.ts.map +1 -0
  257. package/dist/ui/layout/main-pane.js +36 -0
  258. package/dist/ui/layout/main-pane.js.map +1 -0
  259. package/dist/ui/layout/shell.d.ts +17 -0
  260. package/dist/ui/layout/shell.d.ts.map +1 -0
  261. package/dist/ui/layout/shell.js +24 -0
  262. package/dist/ui/layout/shell.js.map +1 -0
  263. package/dist/ui/layout/sidebar.d.ts +12 -0
  264. package/dist/ui/layout/sidebar.d.ts.map +1 -0
  265. package/dist/ui/layout/sidebar.js +9 -0
  266. package/dist/ui/layout/sidebar.js.map +1 -0
  267. package/dist/ui/layout/status-bar.d.ts +9 -0
  268. package/dist/ui/layout/status-bar.d.ts.map +1 -0
  269. package/dist/ui/layout/status-bar.js +138 -0
  270. package/dist/ui/layout/status-bar.js.map +1 -0
  271. package/package.json +70 -0
@@ -0,0 +1,1102 @@
1
+ import { EventEmitter } from "node:events";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { PING_INTERVAL_MS, PONG_TIMEOUT_MS, DEFAULT_DRIVER_NAME, } from "./relay-protocol.js";
6
+ import { ticketTracker } from "./ticket-tracker.js";
7
+ import { setRelayConnectionStatus, setSessionEncryptionStatus, updateViewerCount, clearViewerCounts, } from "./relay-store.js";
8
+ import { refreshAuthToken } from "./relay-auth.js";
9
+ import { appStore, getSessionInstance } from "../store/app-store.js";
10
+ import { settingsStore } from "../store/settings-store.js";
11
+ import { getAllDrivers } from "../agents/agent-registry.js";
12
+ import { generateSessionKeyPair, generateSessionContentKey, importPeerPublicKey, deriveKeyEncryptionKey, wrapSessionContentKey, encryptData, decryptData, } from "./relay-crypto.js";
13
+ import { makeDebugLog } from "../connectors/debug.js";
14
+ import { RelayToCliMsgSchema } from "./relay-message-schemas.js";
15
+ import { RateLimiter } from "./rate-limiter.js";
16
+ import { sessionMetadataStore } from "../agents/session-metadata.js";
17
+ const debugLog = makeDebugLog("relay");
18
+ // ---------------------------------------------------------------------------
19
+ // Replay buffer — ring buffer of recent PTY output per session
20
+ // ---------------------------------------------------------------------------
21
+ class ReplayBuffer {
22
+ buffers = new Map();
23
+ sizes = new Map();
24
+ maxBytes;
25
+ constructor(maxKB) {
26
+ this.maxBytes = maxKB * 1024;
27
+ }
28
+ push(sessionId, data) {
29
+ let chunks = this.buffers.get(sessionId);
30
+ let size = this.sizes.get(sessionId) ?? 0;
31
+ if (!chunks) {
32
+ chunks = [];
33
+ this.buffers.set(sessionId, chunks);
34
+ }
35
+ chunks.push(data);
36
+ size += data.length;
37
+ // Evict oldest chunks when over budget
38
+ while (size > this.maxBytes && chunks.length > 1) {
39
+ const removed = chunks.shift();
40
+ size -= removed.length;
41
+ }
42
+ this.sizes.set(sessionId, size);
43
+ }
44
+ get(sessionId) {
45
+ return this.buffers.get(sessionId) ?? [];
46
+ }
47
+ remove(sessionId) {
48
+ this.buffers.delete(sessionId);
49
+ this.sizes.delete(sessionId);
50
+ }
51
+ clear() {
52
+ this.buffers.clear();
53
+ this.sizes.clear();
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // RelayClient
58
+ // ---------------------------------------------------------------------------
59
+ export class RelayClient extends EventEmitter {
60
+ ws = null;
61
+ trackedSessions = new Map();
62
+ replayBuffer;
63
+ storeUnsub = null;
64
+ // Reconnection state
65
+ reconnectTimer = null;
66
+ reconnectAttempt = 0;
67
+ shouldReconnect = false;
68
+ // Heartbeat state
69
+ pingTimer = null;
70
+ pongTimer = null;
71
+ started = false;
72
+ // Rate limiters
73
+ inputLimiter = new RateLimiter(30, 30); // 30 inputs/sec per key
74
+ browseLimiter = new RateLimiter(10, 10); // 10 browse requests/sec
75
+ createSessionLimiter = new RateLimiter(1, 0.2); // 1 per 5 seconds
76
+ constructor() {
77
+ super();
78
+ const maxKB = settingsStore.getState().config.relay.maxReplayBufferKB;
79
+ this.replayBuffer = new ReplayBuffer(maxKB);
80
+ }
81
+ get e2eEnabled() {
82
+ return settingsStore.getState().config.relay.e2eEncryption;
83
+ }
84
+ // -------------------------------------------------------------------------
85
+ // Lifecycle
86
+ // -------------------------------------------------------------------------
87
+ async start() {
88
+ if (this.started)
89
+ return;
90
+ this.started = true;
91
+ this.shouldReconnect = true;
92
+ this.reconnectAttempt = 0;
93
+ await this.watchSessions();
94
+ await this.connect();
95
+ }
96
+ async stop() {
97
+ if (!this.started)
98
+ return;
99
+ this.started = false;
100
+ this.shouldReconnect = false;
101
+ if (this.storeUnsub) {
102
+ this.storeUnsub();
103
+ this.storeUnsub = null;
104
+ }
105
+ this.clearReconnectTimer();
106
+ this.clearHeartbeat();
107
+ // Untrack all sessions
108
+ for (const tracked of this.trackedSessions.values()) {
109
+ this.untrackSession(tracked);
110
+ }
111
+ this.trackedSessions.clear();
112
+ this.replayBuffer.clear();
113
+ clearViewerCounts();
114
+ this.inputLimiter.clear();
115
+ this.browseLimiter.clear();
116
+ this.createSessionLimiter.clear();
117
+ if (this.ws) {
118
+ this.ws.close(1000);
119
+ this.ws = null;
120
+ }
121
+ setRelayConnectionStatus("disconnected");
122
+ }
123
+ // -------------------------------------------------------------------------
124
+ // WebSocket connection
125
+ // -------------------------------------------------------------------------
126
+ async connect() {
127
+ const token = settingsStore.getState().config.relay.authToken;
128
+ if (!token) {
129
+ debugLog("relay: no auth token — skipping connect");
130
+ setRelayConnectionStatus("disconnected");
131
+ return;
132
+ }
133
+ setRelayConnectionStatus("connecting");
134
+ try {
135
+ // Dynamic import — ws is a dependency we expect to be available
136
+ const { default: WebSocket } = await import("ws");
137
+ const relayUrl = process.env.RELAY_URL || settingsStore.getState().config.relay.relayUrl;
138
+ this.ws = new WebSocket(relayUrl);
139
+ this.ws.on("open", () => {
140
+ debugLog("relay: connected to relay server");
141
+ setRelayConnectionStatus("authenticating");
142
+ this.send({ type: "cli_auth", token: token });
143
+ this.startHeartbeat();
144
+ });
145
+ this.ws.on("message", (raw) => {
146
+ try {
147
+ const parsed = JSON.parse(raw.toString());
148
+ const result = RelayToCliMsgSchema.safeParse(parsed);
149
+ if (!result.success) {
150
+ debugLog(`relay: invalid message schema: ${result.error.message}`);
151
+ return;
152
+ }
153
+ this.handleMessage(result.data);
154
+ }
155
+ catch {
156
+ debugLog("relay: invalid JSON from relay");
157
+ }
158
+ });
159
+ this.ws.on("close", (code) => {
160
+ debugLog(`relay: disconnected (code ${code})`);
161
+ this.ws = null;
162
+ this.clearHeartbeat();
163
+ clearViewerCounts();
164
+ // Clear viewer keys on disconnect — viewers must re-handshake
165
+ for (const tracked of this.trackedSessions.values()) {
166
+ tracked.viewerKeys.clear();
167
+ }
168
+ if (this.shouldReconnect) {
169
+ setRelayConnectionStatus("disconnected");
170
+ this.scheduleReconnect();
171
+ }
172
+ });
173
+ this.ws.on("error", (err) => {
174
+ debugLog(`relay: WebSocket error: ${err.message}`);
175
+ setRelayConnectionStatus("error", err.message);
176
+ });
177
+ }
178
+ catch (err) {
179
+ const message = err instanceof Error ? err.message : String(err);
180
+ debugLog(`relay: failed to connect: ${message}`);
181
+ setRelayConnectionStatus("error", message);
182
+ if (this.shouldReconnect) {
183
+ this.scheduleReconnect();
184
+ }
185
+ }
186
+ }
187
+ // -------------------------------------------------------------------------
188
+ // Message handling
189
+ // -------------------------------------------------------------------------
190
+ handleMessage(msg) {
191
+ switch (msg.type) {
192
+ case "relay_auth_ok":
193
+ debugLog("relay: authenticated");
194
+ setRelayConnectionStatus("connected");
195
+ this.reconnectAttempt = 0;
196
+ // Register all current sessions
197
+ this.registerAllSessions();
198
+ break;
199
+ case "relay_auth_fail":
200
+ debugLog(`relay: auth failed: ${msg.reason}`);
201
+ setRelayConnectionStatus("error", `Auth failed: ${msg.reason}`);
202
+ // Try refreshing the token before giving up
203
+ this.handleAuthFailure();
204
+ break;
205
+ case "relay_input":
206
+ this.handleRelayInput(msg.sessionId, msg.data);
207
+ break;
208
+ case "relay_resize":
209
+ this.handleRelayResize(msg.sessionId, msg.cols, msg.rows);
210
+ break;
211
+ case "relay_viewer_joined":
212
+ debugLog(`relay: viewer ${msg.viewerId} joined session ${msg.sessionId}`);
213
+ updateViewerCount(msg.sessionId, 1);
214
+ break;
215
+ case "relay_viewer_left":
216
+ debugLog(`relay: viewer ${msg.viewerId} left session ${msg.sessionId}`);
217
+ updateViewerCount(msg.sessionId, -1);
218
+ this.cleanupViewerCrypto(msg.sessionId, msg.viewerId);
219
+ break;
220
+ case "relay_create_session":
221
+ this.handleRelayCreateSession(msg.driverName, msg.cwd);
222
+ break;
223
+ case "relay_browse_dir":
224
+ this.handleBrowseDir(msg.requestId, msg.viewerId, msg.path);
225
+ break;
226
+ case "relay_browse_files":
227
+ this.handleBrowseFiles(msg.requestId, msg.viewerId, msg.path);
228
+ break;
229
+ case "relay_ping":
230
+ this.send({ type: "cli_pong" });
231
+ this.resetPongTimer();
232
+ break;
233
+ case "relay_viewer_public_key":
234
+ this.handleViewerPublicKey(msg.sessionId, msg.viewerId, msg.publicKey);
235
+ break;
236
+ case "relay_encrypted_input":
237
+ this.handleEncryptedInput(msg.sessionId, msg.viewerId, msg.data);
238
+ break;
239
+ case "relay_start_planning":
240
+ this.handleStartPlanning(msg);
241
+ break;
242
+ case "relay_start_interactive_planning":
243
+ this.handleStartInteractivePlanning(msg);
244
+ break;
245
+ case "relay_start_implementation":
246
+ this.handleStartImplementation(msg);
247
+ break;
248
+ case "relay_request_changes":
249
+ this.handleRequestChanges(msg);
250
+ break;
251
+ case "relay_request_git_info":
252
+ this.handleRequestGitInfo(msg.requestId, msg.viewerId, msg.cwd);
253
+ break;
254
+ case "relay_close_session":
255
+ debugLog(`relay: close session request for ${msg.sessionId}`);
256
+ appStore.getState().closeSession(msg.sessionId);
257
+ break;
258
+ case "relay_list_drivers":
259
+ this.handleListDrivers(msg.viewerId);
260
+ break;
261
+ }
262
+ }
263
+ async handleAuthFailure() {
264
+ debugLog("relay: attempting token refresh...");
265
+ const newToken = await refreshAuthToken();
266
+ if (newToken) {
267
+ debugLog("relay: token refreshed, reconnecting");
268
+ this.reconnectAttempt = 0;
269
+ this.scheduleReconnect();
270
+ }
271
+ else {
272
+ debugLog("relay: token refresh failed — giving up");
273
+ this.shouldReconnect = false;
274
+ }
275
+ }
276
+ handleRelayInput(sessionId, base64Data) {
277
+ const shareMode = settingsStore.getState().config.relay.defaultShareMode;
278
+ if (shareMode === "read-only") {
279
+ debugLog(`relay: ignoring input for ${sessionId} (read-only mode)`);
280
+ return;
281
+ }
282
+ if (!this.inputLimiter.allow(sessionId)) {
283
+ debugLog(`relay: rate limited input for ${sessionId}`);
284
+ return;
285
+ }
286
+ const session = getSessionInstance(sessionId);
287
+ if (!session)
288
+ return;
289
+ const data = Buffer.from(base64Data, "base64").toString("utf-8");
290
+ session.scrollToBottom();
291
+ session.write(data);
292
+ }
293
+ handleRelayResize(sessionId, cols, rows) {
294
+ const session = getSessionInstance(sessionId);
295
+ if (!session)
296
+ return;
297
+ session.resize(cols, rows);
298
+ }
299
+ resolveCwd(cwd) {
300
+ if (cwd.startsWith("~/")) {
301
+ return cwd.replace("~", process.env.HOME ?? os.homedir());
302
+ }
303
+ if (cwd === "~") {
304
+ return process.env.HOME ?? os.homedir();
305
+ }
306
+ return cwd;
307
+ }
308
+ handleRelayCreateSession(driverName, cwd) {
309
+ if (!this.createSessionLimiter.allow("global")) {
310
+ debugLog("relay: rate limited session creation");
311
+ return;
312
+ }
313
+ const resolvedCwd = this.resolveCwd(cwd);
314
+ debugLog(`relay: create session request: driver=${driverName} cwd=${resolvedCwd}`);
315
+ try {
316
+ const session = appStore.getState().createSession(driverName, resolvedCwd);
317
+ if (session) {
318
+ session.start().catch((err) => debugLog(`relay: session start failed: ${err}`));
319
+ }
320
+ else {
321
+ debugLog(`relay: createSession returned null (driver "${driverName}" not found?)`);
322
+ }
323
+ }
324
+ catch (err) {
325
+ const message = err instanceof Error ? err.message : String(err);
326
+ debugLog(`relay: failed to create session: ${message}`);
327
+ }
328
+ }
329
+ async handleListDrivers(viewerId) {
330
+ const allDrivers = getAllDrivers();
331
+ const drivers = await Promise.all(allDrivers.map(async (d) => ({
332
+ name: d.name,
333
+ displayName: d.displayName,
334
+ installed: await d.checkInstalled(),
335
+ })));
336
+ this.send({ type: "cli_drivers_result", viewerId, drivers });
337
+ }
338
+ async handleBrowseDir(requestId, viewerId, dirPath) {
339
+ if (!this.browseLimiter.allow(viewerId)) {
340
+ debugLog(`relay: rate limited browse dir from ${viewerId}`);
341
+ this.send({
342
+ type: "cli_browse_dir_result",
343
+ requestId,
344
+ viewerId,
345
+ resolvedPath: dirPath,
346
+ directories: [],
347
+ error: "Rate limited — try again",
348
+ });
349
+ return;
350
+ }
351
+ const resolvedPath = this.resolveCwd(dirPath);
352
+ try {
353
+ const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
354
+ const directories = entries
355
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
356
+ .map((e) => e.name)
357
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
358
+ this.send({
359
+ type: "cli_browse_dir_result",
360
+ requestId,
361
+ viewerId,
362
+ resolvedPath: path.resolve(resolvedPath),
363
+ directories,
364
+ error: null,
365
+ });
366
+ }
367
+ catch (err) {
368
+ const message = err instanceof Error ? err.message : String(err);
369
+ debugLog(`relay: browse dir failed: ${message}`);
370
+ this.send({
371
+ type: "cli_browse_dir_result",
372
+ requestId,
373
+ viewerId,
374
+ resolvedPath: path.resolve(resolvedPath),
375
+ directories: [],
376
+ error: message,
377
+ });
378
+ }
379
+ }
380
+ async handleBrowseFiles(requestId, viewerId, dirPath) {
381
+ if (!this.browseLimiter.allow(viewerId)) {
382
+ debugLog(`relay: rate limited browse files from ${viewerId}`);
383
+ this.send({
384
+ type: "cli_browse_files_result",
385
+ requestId,
386
+ viewerId,
387
+ resolvedPath: dirPath,
388
+ directories: [],
389
+ files: [],
390
+ error: "Rate limited — try again",
391
+ });
392
+ return;
393
+ }
394
+ const resolvedPath = this.resolveCwd(dirPath);
395
+ try {
396
+ const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
397
+ const directories = entries
398
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
399
+ .map((e) => e.name)
400
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
401
+ const files = entries
402
+ .filter((e) => e.isFile() && !e.name.startsWith("."))
403
+ .map((e) => e.name)
404
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
405
+ this.send({
406
+ type: "cli_browse_files_result",
407
+ requestId,
408
+ viewerId,
409
+ resolvedPath: path.resolve(resolvedPath),
410
+ directories,
411
+ files,
412
+ error: null,
413
+ });
414
+ }
415
+ catch (err) {
416
+ const message = err instanceof Error ? err.message : String(err);
417
+ debugLog(`relay: browse files failed: ${message}`);
418
+ this.send({
419
+ type: "cli_browse_files_result",
420
+ requestId,
421
+ viewerId,
422
+ resolvedPath: path.resolve(resolvedPath),
423
+ directories: [],
424
+ files: [],
425
+ error: message,
426
+ });
427
+ }
428
+ }
429
+ async handleRequestGitInfo(requestId, viewerId, cwd) {
430
+ const resolvedPath = this.resolveCwd(cwd);
431
+ const { execFile } = await import("node:child_process");
432
+ const { promisify } = await import("node:util");
433
+ const execFileAsync = promisify(execFile);
434
+ const execGit = async (args) => {
435
+ const { stdout } = await execFileAsync("git", args, {
436
+ cwd: resolvedPath,
437
+ timeout: 5000,
438
+ });
439
+ return stdout.trim();
440
+ };
441
+ try {
442
+ // Run git commands in parallel
443
+ const [branchResult, remoteResult, logResult, statusResult] = await Promise.allSettled([
444
+ execGit(["rev-parse", "--abbrev-ref", "HEAD"]),
445
+ execGit(["remote", "get-url", "origin"]),
446
+ execGit(["log", "--format=%H%n%s%n%an%n%ct", "-5"]),
447
+ execGit(["status", "--porcelain"]),
448
+ ]);
449
+ const branch = branchResult.status === "fulfilled" ? branchResult.value : null;
450
+ const remoteUrl = remoteResult.status === "fulfilled" ? remoteResult.value : null;
451
+ // Parse commits from git log output
452
+ const commits = [];
453
+ if (logResult.status === "fulfilled" && logResult.value) {
454
+ const lines = logResult.value.split("\n");
455
+ for (let i = 0; i + 3 < lines.length; i += 4) {
456
+ commits.push({
457
+ hash: lines[i],
458
+ message: lines[i + 1],
459
+ author: lines[i + 2],
460
+ timestamp: parseInt(lines[i + 3], 10),
461
+ });
462
+ }
463
+ }
464
+ // Parse status output
465
+ const statusOutput = statusResult.status === "fulfilled" ? statusResult.value : "";
466
+ const changedFiles = statusOutput ? statusOutput.split("\n").filter(Boolean).length : 0;
467
+ const isClean = changedFiles === 0;
468
+ this.send({
469
+ type: "cli_git_info_result",
470
+ requestId,
471
+ viewerId,
472
+ branch,
473
+ remoteUrl,
474
+ commits,
475
+ changedFiles,
476
+ isClean,
477
+ error: null,
478
+ });
479
+ }
480
+ catch (err) {
481
+ const message = err instanceof Error ? err.message : String(err);
482
+ debugLog(`relay: git info failed: ${message}`);
483
+ this.send({
484
+ type: "cli_git_info_result",
485
+ requestId,
486
+ viewerId,
487
+ branch: null,
488
+ remoteUrl: null,
489
+ commits: [],
490
+ changedFiles: 0,
491
+ isClean: true,
492
+ error: message,
493
+ });
494
+ }
495
+ }
496
+ // -------------------------------------------------------------------------
497
+ // Phase 3: Ticket dev-loop handlers
498
+ // -------------------------------------------------------------------------
499
+ handleStartPlanning(msg) {
500
+ const resolvedCwd = this.resolveCwd(msg.cwd);
501
+ const iteration = msg.iteration ?? 0;
502
+ const promptParts = [
503
+ "You are planning the implementation of a ticket.",
504
+ "",
505
+ `Title: ${msg.ticketTitle}`,
506
+ `Description: ${msg.ticketDescription}`,
507
+ "",
508
+ ...msg.systemInstructions.map((i) => `System Instruction:\n${i}`),
509
+ "",
510
+ ];
511
+ if (msg.previousPlan && msg.feedback) {
512
+ promptParts.push("A previous plan was created but the user requested revisions.", "", "Previous Plan:", msg.previousPlan, "", "User Feedback:", msg.feedback, "", "Revise the plan based on the feedback above.");
513
+ }
514
+ else {
515
+ promptParts.push("Analyze the requirements and create a detailed implementation plan.");
516
+ }
517
+ promptParts.push("", "The plan must include:", "- Files to create/modify", "- Key implementation steps with code snippets where helpful", "- Any potential issues or considerations", "", "IMPORTANT: Output ONLY the complete plan. Do NOT add a summary, introduction, or conclusion.", "Your entire output will be captured and shown to the user as the plan.");
518
+ const planningPrompt = promptParts.join("\n");
519
+ const driverName = msg.driverName || DEFAULT_DRIVER_NAME;
520
+ debugLog(`relay: start planning ticket=${msg.ticketId} iteration=${iteration} cwd=${resolvedCwd} driver=${driverName}`);
521
+ try {
522
+ const session = appStore.getState().createSession(driverName, resolvedCwd);
523
+ if (!session) {
524
+ debugLog("relay: failed to create planning session");
525
+ return;
526
+ }
527
+ ticketTracker.startPlanning(msg.ticketId, session.id, msg.ticketTitle);
528
+ this.send({
529
+ type: "cli_ticket_status",
530
+ ticketId: msg.ticketId,
531
+ sessionId: session.id,
532
+ status: "planning",
533
+ });
534
+ // Accumulate raw PTY output for plan capture.
535
+ // We can't rely on xterm buffer because the app-store's exit listener
536
+ // calls kill() → xterm.dispose() before we can read it.
537
+ let rawOutput = "";
538
+ const rawUnsub = session.onRawData((data) => {
539
+ rawOutput += data;
540
+ });
541
+ session.start({
542
+ prompt: planningPrompt,
543
+ mode: "print",
544
+ allowedTools: ["Read", "Glob", "Grep", "Bash(git *)"],
545
+ }).catch((err) => debugLog(`relay: planning session start failed: ${err}`));
546
+ // Set session metadata so clients know this is non-interactive
547
+ const command = `${driverName} -p "<planning prompt>"`;
548
+ appStore.getState().setSessionMeta(session.id, { interactive: false, command });
549
+ this.registerSession(session.id);
550
+ // In print mode, claude -p exits when done — listen for exit
551
+ const onExit = () => {
552
+ rawUnsub();
553
+ // Strip ANSI escape sequences from raw PTY output
554
+ const plainText = rawOutput.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
555
+ ticketTracker.updateStatus(msg.ticketId, "plan_ready");
556
+ this.send({
557
+ type: "cli_plan_ready",
558
+ ticketId: msg.ticketId,
559
+ sessionId: session.id,
560
+ plan: plainText,
561
+ iteration,
562
+ feedback: msg.feedback ?? null,
563
+ });
564
+ this.send({
565
+ type: "cli_ticket_status",
566
+ ticketId: msg.ticketId,
567
+ sessionId: session.id,
568
+ status: "plan_ready",
569
+ });
570
+ };
571
+ session.on("exit", onExit);
572
+ }
573
+ catch (err) {
574
+ const message = err instanceof Error ? err.message : String(err);
575
+ debugLog(`relay: handleStartPlanning error: ${message}`);
576
+ }
577
+ }
578
+ handleStartInteractivePlanning(msg) {
579
+ const resolvedCwd = this.resolveCwd(msg.cwd);
580
+ const planningPrompt = [
581
+ "You are planning the implementation of a ticket.",
582
+ "",
583
+ `Title: ${msg.ticketTitle}`,
584
+ `Description: ${msg.ticketDescription}`,
585
+ "",
586
+ ...msg.systemInstructions.map((i) => `System Instruction:\n${i}`),
587
+ "",
588
+ "Please analyze the requirements and create a detailed implementation plan. Include:",
589
+ "- Files to create/modify",
590
+ "- Key implementation steps",
591
+ "- Any potential issues or considerations",
592
+ ].join("\n");
593
+ const driverName = msg.driverName || DEFAULT_DRIVER_NAME;
594
+ debugLog(`relay: start interactive planning ticket=${msg.ticketId} cwd=${resolvedCwd} driver=${driverName}`);
595
+ try {
596
+ const session = appStore.getState().createSession(driverName, resolvedCwd);
597
+ if (!session) {
598
+ debugLog("relay: failed to create interactive planning session");
599
+ return;
600
+ }
601
+ ticketTracker.startPlanning(msg.ticketId, session.id, msg.ticketTitle);
602
+ this.send({
603
+ type: "cli_ticket_status",
604
+ ticketId: msg.ticketId,
605
+ sessionId: session.id,
606
+ status: "planning",
607
+ });
608
+ session.start({ prompt: planningPrompt }).catch((err) => debugLog(`relay: interactive planning start failed: ${err}`));
609
+ // Set session metadata so clients know this is interactive
610
+ appStore.getState().setSessionMeta(session.id, {
611
+ interactive: true,
612
+ command: `${driverName} "<planning prompt>"`,
613
+ });
614
+ this.registerSession(session.id);
615
+ }
616
+ catch (err) {
617
+ const message = err instanceof Error ? err.message : String(err);
618
+ debugLog(`relay: handleStartInteractivePlanning error: ${message}`);
619
+ }
620
+ }
621
+ handleStartImplementation(msg) {
622
+ const resolvedCwd = this.resolveCwd(msg.cwd);
623
+ const implementPrompt = [
624
+ "You are implementing a plan. Follow the plan exactly.",
625
+ "",
626
+ "Plan:",
627
+ msg.plan,
628
+ "",
629
+ ...msg.systemInstructions.map((i) => `System Instruction:\n${i}`),
630
+ "",
631
+ "Implement the plan step by step. When done, create a git branch and commit your changes.",
632
+ ].join("\n");
633
+ const driverName = msg.driverName || DEFAULT_DRIVER_NAME;
634
+ debugLog(`relay: start implementation ticket=${msg.ticketId} cwd=${resolvedCwd} driver=${driverName}`);
635
+ try {
636
+ const session = appStore.getState().createSession(driverName, resolvedCwd);
637
+ if (!session) {
638
+ debugLog("relay: failed to create implementation session");
639
+ return;
640
+ }
641
+ ticketTracker.startImplementation(msg.ticketId, session.id);
642
+ this.send({
643
+ type: "cli_ticket_status",
644
+ ticketId: msg.ticketId,
645
+ sessionId: session.id,
646
+ status: "implementing",
647
+ });
648
+ session.start({ prompt: implementPrompt }).catch((err) => debugLog(`relay: implementation start failed: ${err}`));
649
+ // Set session metadata so clients know this is interactive
650
+ appStore.getState().setSessionMeta(session.id, {
651
+ interactive: true,
652
+ command: `${driverName} "<implementation prompt>"`,
653
+ });
654
+ this.registerSession(session.id);
655
+ // Monitor session status for implementation completion
656
+ const onStatus = (status) => {
657
+ if (status === "waiting_input" || status === "exited") {
658
+ session.removeListener("status", onStatus);
659
+ const plainText = session.getPlainText(500).join("\n");
660
+ ticketTracker.updateStatus(msg.ticketId, "done");
661
+ this.send({
662
+ type: "cli_implementation_done",
663
+ ticketId: msg.ticketId,
664
+ sessionId: session.id,
665
+ summary: plainText,
666
+ gitContext: {},
667
+ });
668
+ this.send({
669
+ type: "cli_ticket_status",
670
+ ticketId: msg.ticketId,
671
+ sessionId: session.id,
672
+ status: "done",
673
+ });
674
+ }
675
+ };
676
+ session.on("status", onStatus);
677
+ }
678
+ catch (err) {
679
+ const message = err instanceof Error ? err.message : String(err);
680
+ debugLog(`relay: handleStartImplementation error: ${message}`);
681
+ }
682
+ }
683
+ handleRequestChanges(msg) {
684
+ debugLog(`relay: request changes ticket=${msg.ticketId} session=${msg.sessionId}`);
685
+ const sessionId = ticketTracker.getSessionForTicket(msg.ticketId);
686
+ if (!sessionId) {
687
+ debugLog(`relay: no tracked session for ticket ${msg.ticketId}`);
688
+ return;
689
+ }
690
+ const session = getSessionInstance(sessionId);
691
+ if (!session) {
692
+ debugLog(`relay: session instance ${sessionId} not found for changes request`);
693
+ return;
694
+ }
695
+ session.write(msg.feedback + "\n");
696
+ }
697
+ // -------------------------------------------------------------------------
698
+ // E2E key exchange handlers
699
+ // -------------------------------------------------------------------------
700
+ async handleViewerPublicKey(sessionId, viewerId, peerPubKeyBase64) {
701
+ const tracked = this.trackedSessions.get(sessionId);
702
+ if (!tracked?.keyPair || !tracked.sck) {
703
+ debugLog(`relay: viewer key for ${sessionId} but no E2E state`);
704
+ return;
705
+ }
706
+ try {
707
+ setSessionEncryptionStatus(sessionId, "key-exchange");
708
+ const peerPubKey = await importPeerPublicKey(peerPubKeyBase64);
709
+ const { kek, salt } = await deriveKeyEncryptionKey(tracked.keyPair.privateKey, peerPubKey, tracked.keyPair.publicKeyBase64, peerPubKeyBase64);
710
+ // Wrap SCK for this viewer
711
+ const wrappedKey = await wrapSessionContentKey(tracked.sck, kek);
712
+ tracked.viewerKeys.set(viewerId, { kek, keyDelivered: true });
713
+ // Send wrapped SCK + salt to viewer (via relay)
714
+ this.send({
715
+ type: "cli_wrapped_key",
716
+ sessionId,
717
+ viewerId,
718
+ wrappedKey,
719
+ salt,
720
+ });
721
+ debugLog(`relay: delivered wrapped SCK to viewer ${viewerId} for ${sessionId}`);
722
+ setSessionEncryptionStatus(sessionId, "active");
723
+ // Send replay buffer encrypted for the new viewer
724
+ await this.sendEncryptedReplay(tracked);
725
+ }
726
+ catch (err) {
727
+ const message = err instanceof Error ? err.message : String(err);
728
+ debugLog(`relay: key exchange failed for viewer ${viewerId}: ${message}`);
729
+ }
730
+ }
731
+ async handleEncryptedInput(sessionId, viewerId, encryptedData) {
732
+ const shareMode = settingsStore.getState().config.relay.defaultShareMode;
733
+ if (shareMode === "read-only") {
734
+ debugLog(`relay: ignoring encrypted input for ${sessionId} (read-only mode)`);
735
+ return;
736
+ }
737
+ if (!this.inputLimiter.allow(viewerId)) {
738
+ debugLog(`relay: rate limited encrypted input from ${viewerId}`);
739
+ return;
740
+ }
741
+ const tracked = this.trackedSessions.get(sessionId);
742
+ if (!tracked?.sck) {
743
+ debugLog(`relay: encrypted input for ${sessionId} but no SCK`);
744
+ return;
745
+ }
746
+ const session = getSessionInstance(sessionId);
747
+ if (!session)
748
+ return;
749
+ try {
750
+ const plaintext = await decryptData(encryptedData, tracked.sck);
751
+ session.scrollToBottom();
752
+ session.write(plaintext.toString("utf-8"));
753
+ }
754
+ catch (err) {
755
+ const message = err instanceof Error ? err.message : String(err);
756
+ debugLog(`relay: failed to decrypt input from ${viewerId}: ${message}`);
757
+ // Silently drop — never crash
758
+ }
759
+ }
760
+ cleanupViewerCrypto(sessionId, viewerId) {
761
+ const tracked = this.trackedSessions.get(sessionId);
762
+ if (tracked) {
763
+ tracked.viewerKeys.delete(viewerId);
764
+ }
765
+ }
766
+ // -------------------------------------------------------------------------
767
+ // Encrypted PTY data sending
768
+ // -------------------------------------------------------------------------
769
+ async sendPtyData(tracked, rawData) {
770
+ if (tracked.sck) {
771
+ // E2E enabled — encrypt with SCK
772
+ try {
773
+ const plaintext = Buffer.from(rawData, "utf-8");
774
+ const encrypted = await encryptData(plaintext, tracked.sck);
775
+ const seq = tracked.encryptSeq++;
776
+ this.send({
777
+ type: "cli_encrypted_pty_data",
778
+ sessionId: tracked.sessionId,
779
+ data: encrypted,
780
+ seq,
781
+ });
782
+ }
783
+ catch (err) {
784
+ const message = err instanceof Error ? err.message : String(err);
785
+ debugLog(`relay: encrypt PTY data failed: ${message}`);
786
+ }
787
+ }
788
+ else if (this.e2eEnabled) {
789
+ // E2E is enabled but no SCK — key generation must have failed.
790
+ // Do NOT send plaintext — drop the data and flag the failure.
791
+ debugLog(`relay: dropping PTY data for ${tracked.sessionId} — E2E enabled but no SCK`);
792
+ setSessionEncryptionStatus(tracked.sessionId, "failed");
793
+ }
794
+ else {
795
+ // Plaintext is intentionally allowed (e2eEncryption: false in config)
796
+ const base64 = Buffer.from(rawData, "utf-8").toString("base64");
797
+ this.send({
798
+ type: "cli_pty_data",
799
+ sessionId: tracked.sessionId,
800
+ data: base64,
801
+ });
802
+ }
803
+ }
804
+ async sendEncryptedReplay(tracked) {
805
+ if (!tracked.sck)
806
+ return;
807
+ const chunks = this.replayBuffer.get(tracked.sessionId);
808
+ for (const chunk of chunks) {
809
+ try {
810
+ const plaintext = Buffer.from(chunk, "utf-8");
811
+ const encrypted = await encryptData(plaintext, tracked.sck);
812
+ const seq = tracked.encryptSeq++;
813
+ this.send({
814
+ type: "cli_encrypted_pty_data",
815
+ sessionId: tracked.sessionId,
816
+ data: encrypted,
817
+ seq,
818
+ });
819
+ }
820
+ catch {
821
+ debugLog("relay: failed to encrypt replay chunk");
822
+ }
823
+ }
824
+ }
825
+ // -------------------------------------------------------------------------
826
+ // Session tracking — watch appStore for session add/remove
827
+ // -------------------------------------------------------------------------
828
+ async watchSessions() {
829
+ let prevIds = new Set(appStore.getState().sessions.map((s) => s.id));
830
+ // Track existing sessions
831
+ for (const s of appStore.getState().sessions) {
832
+ await this.trackSession(s.id);
833
+ }
834
+ this.storeUnsub = appStore.subscribe((state) => {
835
+ const currentIds = new Set(state.sessions.map((s) => s.id));
836
+ // New sessions
837
+ for (const id of currentIds) {
838
+ if (!prevIds.has(id)) {
839
+ // Register immediately so the session appears in the browser right away
840
+ this.registerSession(id);
841
+ // Then track async for E2E crypto and PTY data streaming
842
+ this.trackSession(id).then(() => {
843
+ // Re-register with public key once E2E keys are generated
844
+ const tracked = this.trackedSessions.get(id);
845
+ if (tracked?.keyPair) {
846
+ this.registerSession(id);
847
+ }
848
+ });
849
+ }
850
+ }
851
+ // Removed sessions
852
+ for (const id of prevIds) {
853
+ if (!currentIds.has(id)) {
854
+ const tracked = this.trackedSessions.get(id);
855
+ if (tracked) {
856
+ this.untrackSession(tracked);
857
+ this.trackedSessions.delete(id);
858
+ }
859
+ this.replayBuffer.remove(id);
860
+ setSessionEncryptionStatus(id, "none");
861
+ this.send({ type: "cli_unregister_session", sessionId: id });
862
+ }
863
+ }
864
+ prevIds = currentIds;
865
+ });
866
+ }
867
+ async trackSession(sessionId) {
868
+ if (this.trackedSessions.has(sessionId))
869
+ return;
870
+ const session = getSessionInstance(sessionId);
871
+ if (!session)
872
+ return;
873
+ // Generate crypto state if E2E enabled
874
+ let keyPair = null;
875
+ let sck = null;
876
+ if (this.e2eEnabled) {
877
+ try {
878
+ keyPair = await generateSessionKeyPair();
879
+ sck = await generateSessionContentKey();
880
+ debugLog(`relay: generated E2E keys for session ${sessionId}`);
881
+ }
882
+ catch (err) {
883
+ const message = err instanceof Error ? err.message : String(err);
884
+ debugLog(`relay: CRITICAL: E2E key generation failed: ${message}`);
885
+ setSessionEncryptionStatus(sessionId, "failed");
886
+ return; // Abort — do not stream data without E2E
887
+ }
888
+ }
889
+ const tracked = {
890
+ sessionId,
891
+ rawDataUnsub: null,
892
+ statusUnsub: null,
893
+ keyPair,
894
+ sck,
895
+ viewerKeys: new Map(),
896
+ encryptSeq: 0,
897
+ };
898
+ // Stream raw PTY data to relay
899
+ tracked.rawDataUnsub = session.onRawData((data) => {
900
+ this.replayBuffer.push(sessionId, data);
901
+ this.sendPtyData(tracked, data);
902
+ });
903
+ // Forward status changes (with detailed fields when available)
904
+ const onStatus = (status) => {
905
+ const detailed = session.detailedStatus;
906
+ const msg = {
907
+ type: "cli_session_status",
908
+ sessionId,
909
+ status,
910
+ };
911
+ if (detailed && "detail" in detailed) {
912
+ msg.detailedState = detailed.state;
913
+ msg.detailedDetail = detailed.detail;
914
+ }
915
+ else if (detailed) {
916
+ msg.detailedState = detailed.state;
917
+ }
918
+ if (session.currentTool) {
919
+ msg.currentTool = session.currentTool;
920
+ }
921
+ this.send(msg);
922
+ };
923
+ session.on("status", onStatus);
924
+ // Forward metadata changes (throttled — every 10s max)
925
+ let lastMetadataSend = 0;
926
+ let metadataTimer = null;
927
+ const sendMetadata = () => {
928
+ const data = sessionMetadataStore.get(sessionId);
929
+ if (!data)
930
+ return;
931
+ this.send({
932
+ type: "cli_session_metadata",
933
+ sessionId,
934
+ modelId: data.modelId,
935
+ modelDisplayName: data.modelDisplayName,
936
+ totalCostUsd: data.totalCostUsd,
937
+ totalDurationMs: data.totalDurationMs,
938
+ totalLinesAdded: data.totalLinesAdded,
939
+ totalLinesRemoved: data.totalLinesRemoved,
940
+ usedPercentage: data.contextWindow?.usedPercentage,
941
+ contextTotalTokens: data.contextWindow?.totalTokens,
942
+ contextUsedTokens: data.contextWindow?.usedTokens,
943
+ showCost: settingsStore.getState().config.ui.showCost,
944
+ });
945
+ lastMetadataSend = Date.now();
946
+ };
947
+ const onMetadata = () => {
948
+ const now = Date.now();
949
+ if (now - lastMetadataSend >= 10_000) {
950
+ sendMetadata();
951
+ }
952
+ else if (!metadataTimer) {
953
+ metadataTimer = setTimeout(() => {
954
+ metadataTimer = null;
955
+ sendMetadata();
956
+ }, 10_000 - (now - lastMetadataSend));
957
+ }
958
+ };
959
+ session.on("metadata", onMetadata);
960
+ tracked.statusUnsub = () => {
961
+ session.removeListener("status", onStatus);
962
+ session.removeListener("metadata", onMetadata);
963
+ if (metadataTimer) {
964
+ clearTimeout(metadataTimer);
965
+ metadataTimer = null;
966
+ }
967
+ };
968
+ this.trackedSessions.set(sessionId, tracked);
969
+ }
970
+ untrackSession(tracked) {
971
+ if (tracked.rawDataUnsub) {
972
+ tracked.rawDataUnsub();
973
+ tracked.rawDataUnsub = null;
974
+ }
975
+ if (tracked.statusUnsub) {
976
+ tracked.statusUnsub();
977
+ tracked.statusUnsub = null;
978
+ }
979
+ // Zero out raw key material before clearing references
980
+ if (tracked.sck?.rawBytes) {
981
+ tracked.sck.rawBytes.fill(0);
982
+ }
983
+ tracked.keyPair = null;
984
+ tracked.sck = null;
985
+ tracked.viewerKeys.clear();
986
+ setSessionEncryptionStatus(tracked.sessionId, "none");
987
+ }
988
+ // -------------------------------------------------------------------------
989
+ // Session registration — send current sessions to relay
990
+ // -------------------------------------------------------------------------
991
+ registerAllSessions() {
992
+ for (const s of appStore.getState().sessions) {
993
+ this.registerSession(s.id);
994
+ }
995
+ }
996
+ registerSession(sessionId) {
997
+ const sessionState = appStore.getState().sessions.find((s) => s.id === sessionId);
998
+ const instance = getSessionInstance(sessionId);
999
+ if (!sessionState || !instance)
1000
+ return;
1001
+ const tracked = this.trackedSessions.get(sessionId);
1002
+ this.send({
1003
+ type: "cli_register_session",
1004
+ sessionId,
1005
+ driverName: sessionState.driverName,
1006
+ displayName: sessionState.displayName,
1007
+ cwd: sessionState.cwd,
1008
+ status: sessionState.status,
1009
+ cols: instance.cols,
1010
+ rows: instance.rows,
1011
+ publicKey: tracked?.keyPair?.publicKeyBase64,
1012
+ interactive: sessionState.interactive,
1013
+ command: sessionState.command,
1014
+ });
1015
+ // Send metadata if available (e.g. on reconnect)
1016
+ const meta = sessionMetadataStore.get(sessionId);
1017
+ if (meta) {
1018
+ this.send({
1019
+ type: "cli_session_metadata",
1020
+ sessionId,
1021
+ modelId: meta.modelId,
1022
+ modelDisplayName: meta.modelDisplayName,
1023
+ totalCostUsd: meta.totalCostUsd,
1024
+ totalDurationMs: meta.totalDurationMs,
1025
+ totalLinesAdded: meta.totalLinesAdded,
1026
+ totalLinesRemoved: meta.totalLinesRemoved,
1027
+ usedPercentage: meta.contextWindow?.usedPercentage,
1028
+ contextTotalTokens: meta.contextWindow?.totalTokens,
1029
+ contextUsedTokens: meta.contextWindow?.usedTokens,
1030
+ });
1031
+ }
1032
+ }
1033
+ // -------------------------------------------------------------------------
1034
+ // Heartbeat
1035
+ // -------------------------------------------------------------------------
1036
+ startHeartbeat() {
1037
+ this.clearHeartbeat();
1038
+ // We respond to server pings. Reset the pong timer on each ping.
1039
+ this.resetPongTimer();
1040
+ }
1041
+ resetPongTimer() {
1042
+ if (this.pongTimer)
1043
+ clearTimeout(this.pongTimer);
1044
+ this.pongTimer = setTimeout(() => {
1045
+ debugLog("relay: no ping from relay — connection dead");
1046
+ if (this.ws) {
1047
+ this.ws.close(4000, "Ping timeout");
1048
+ }
1049
+ }, PING_INTERVAL_MS + PONG_TIMEOUT_MS);
1050
+ }
1051
+ clearHeartbeat() {
1052
+ if (this.pingTimer) {
1053
+ clearInterval(this.pingTimer);
1054
+ this.pingTimer = null;
1055
+ }
1056
+ if (this.pongTimer) {
1057
+ clearTimeout(this.pongTimer);
1058
+ this.pongTimer = null;
1059
+ }
1060
+ }
1061
+ // -------------------------------------------------------------------------
1062
+ // Reconnection with exponential backoff
1063
+ // -------------------------------------------------------------------------
1064
+ scheduleReconnect() {
1065
+ this.clearReconnectTimer();
1066
+ const delays = [1000, 2000, 4000, 8000, 16000, 30000];
1067
+ const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)];
1068
+ this.reconnectAttempt++;
1069
+ debugLog(`relay: reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
1070
+ this.reconnectTimer = setTimeout(() => {
1071
+ this.reconnectTimer = null;
1072
+ if (this.shouldReconnect) {
1073
+ this.connect();
1074
+ }
1075
+ }, delay);
1076
+ }
1077
+ clearReconnectTimer() {
1078
+ if (this.reconnectTimer) {
1079
+ clearTimeout(this.reconnectTimer);
1080
+ this.reconnectTimer = null;
1081
+ }
1082
+ }
1083
+ // -------------------------------------------------------------------------
1084
+ // Send helper
1085
+ // -------------------------------------------------------------------------
1086
+ send(msg) {
1087
+ if (!this.ws || this.ws.readyState !== 1 /* OPEN */)
1088
+ return;
1089
+ // Backpressure: drop PTY data if buffer is backing up
1090
+ if ((msg.type === "cli_pty_data" || msg.type === "cli_encrypted_pty_data") &&
1091
+ this.ws.bufferedAmount > 256 * 1024) {
1092
+ return;
1093
+ }
1094
+ try {
1095
+ this.ws.send(JSON.stringify(msg));
1096
+ }
1097
+ catch {
1098
+ debugLog("relay: send error");
1099
+ }
1100
+ }
1101
+ }
1102
+ //# sourceMappingURL=relay-client.js.map