@lzdi/pty-remote-relay 0.1.3

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 (62) hide show
  1. package/bin/pty-remote-relay.js +24 -0
  2. package/package.json +54 -0
  3. package/public/build/assets/_basePickBy-BSLVzrBb.js +1 -0
  4. package/public/build/assets/_baseUniq-CjVqOhCX.js +1 -0
  5. package/public/build/assets/arc-DNTPwzgE.js +1 -0
  6. package/public/build/assets/architectureDiagram-2XIMDMQ5-cs5bhtYI.js +36 -0
  7. package/public/build/assets/blockDiagram-WCTKOSBZ-Cq0ABSsS.js +132 -0
  8. package/public/build/assets/c4Diagram-IC4MRINW-BZWmi635.js +10 -0
  9. package/public/build/assets/channel-DaknKRU-.js +1 -0
  10. package/public/build/assets/chunk-4BX2VUAB-BKpTCKpU.js +1 -0
  11. package/public/build/assets/chunk-55IACEB6-CKic7IJy.js +1 -0
  12. package/public/build/assets/chunk-FMBD7UC4-DkDwvv08.js +15 -0
  13. package/public/build/assets/chunk-JSJVCQXG-DyHske-D.js +1 -0
  14. package/public/build/assets/chunk-KX2RTZJC-BHhZdn7R.js +1 -0
  15. package/public/build/assets/chunk-NQ4KR5QH-CMkarP7M.js +220 -0
  16. package/public/build/assets/chunk-QZHKN3VN-2tloC7Ya.js +1 -0
  17. package/public/build/assets/chunk-WL4C6EOR-CXbcMZje.js +189 -0
  18. package/public/build/assets/classDiagram-VBA2DB6C-CAPxr6Mj.js +1 -0
  19. package/public/build/assets/classDiagram-v2-RAHNMMFH-CAPxr6Mj.js +1 -0
  20. package/public/build/assets/clone-DGE_el-r.js +1 -0
  21. package/public/build/assets/cose-bilkent-S5V4N54A-C_lbjWei.js +1 -0
  22. package/public/build/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  23. package/public/build/assets/dagre-KLK3FWXG-nRhxcUNz.js +4 -0
  24. package/public/build/assets/defaultLocale-DX6XiGOO.js +1 -0
  25. package/public/build/assets/diagram-E7M64L7V-wMDgG-Tz.js +24 -0
  26. package/public/build/assets/diagram-IFDJBPK2-BQ0Ju-S3.js +43 -0
  27. package/public/build/assets/diagram-P4PSJMXO-C-WVz2po.js +24 -0
  28. package/public/build/assets/erDiagram-INFDFZHY-BDkf9Or-.js +70 -0
  29. package/public/build/assets/flowDiagram-PKNHOUZH-y9mpFLOt.js +162 -0
  30. package/public/build/assets/ganttDiagram-A5KZAMGK-FXibpUAU.js +292 -0
  31. package/public/build/assets/gitGraphDiagram-K3NZZRJ6-D1c4ie6p.js +65 -0
  32. package/public/build/assets/graph-CTV1hGZn.js +1 -0
  33. package/public/build/assets/index-B002QPfr.js +42 -0
  34. package/public/build/assets/index-CQO8fMLA.css +1 -0
  35. package/public/build/assets/index-Cr5L7diS.js +2 -0
  36. package/public/build/assets/infoDiagram-LFFYTUFH-DwCujEd2.js +2 -0
  37. package/public/build/assets/init-Gi6I4Gst.js +1 -0
  38. package/public/build/assets/ishikawaDiagram-PHBUUO56-Bwx1BVdH.js +70 -0
  39. package/public/build/assets/journeyDiagram-4ABVD52K-IMq7PvWc.js +139 -0
  40. package/public/build/assets/kanban-definition-K7BYSVSG-DELVXLkO.js +89 -0
  41. package/public/build/assets/katex-C-M49wc6.js +261 -0
  42. package/public/build/assets/layout-CJ0CPRFk.js +1 -0
  43. package/public/build/assets/linear-CDCvsSqR.js +1 -0
  44. package/public/build/assets/mermaid.core-BR-iWEq5.js +249 -0
  45. package/public/build/assets/mindmap-definition-YRQLILUH-eBbDXLEj.js +68 -0
  46. package/public/build/assets/ordinal-Cboi1Yqb.js +1 -0
  47. package/public/build/assets/pieDiagram-SKSYHLDU-DI6G2WkI.js +30 -0
  48. package/public/build/assets/quadrantDiagram-337W2JSQ-D-jE0cRm.js +7 -0
  49. package/public/build/assets/requirementDiagram-Z7DCOOCP-moUfEpIX.js +73 -0
  50. package/public/build/assets/sankeyDiagram-WA2Y5GQK-UdfaFey7.js +10 -0
  51. package/public/build/assets/sequenceDiagram-2WXFIKYE-Bt4P4tZ8.js +145 -0
  52. package/public/build/assets/stateDiagram-RAJIS63D-Br4zliPQ.js +1 -0
  53. package/public/build/assets/stateDiagram-v2-FVOUBMTO-DtPzp4lF.js +1 -0
  54. package/public/build/assets/timeline-definition-YZTLITO2-Bbyk_oDc.js +61 -0
  55. package/public/build/assets/treemap-KZPCXAKY-GvvLCih7.js +162 -0
  56. package/public/build/assets/vennDiagram-LZ73GAT5-hKaQHCSS.js +34 -0
  57. package/public/build/assets/xychartDiagram-JWTSCODW-KCSeWg4r.js +7 -0
  58. package/public/build/index.html +13 -0
  59. package/relay.conf +19 -0
  60. package/src/socket/relay-config.ts +102 -0
  61. package/src/socket/server.ts +1170 -0
  62. package/src/socket-main.ts +6 -0
@@ -0,0 +1,1170 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import http, { type IncomingMessage, type ServerResponse } from 'node:http';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { Server as SocketIOServer, type Socket } from 'socket.io';
8
+ import type {
9
+ CliCommandEnvelope,
10
+ CliCommandResult,
11
+ CliRegisterPayload,
12
+ CliRegisterResult,
13
+ CliStatusPayload,
14
+ MessagesUpsertPayload,
15
+ RuntimeSubscriptionPayload,
16
+ RuntimeSnapshotPayload,
17
+ TerminalFramePatchPayload,
18
+ TerminalFrameSyncRequestPayload,
19
+ TerminalFrameSyncResultPayload,
20
+ TerminalSessionEvictedPayload,
21
+ TerminalResizePayload,
22
+ WebCommandEnvelope,
23
+ WebInitPayload
24
+ } from '@lzdi/pty-remote-protocol/protocol.ts';
25
+ import type { CliDescriptor, CliProviderRuntimeDescriptor, ProviderId, RuntimeSnapshot, RuntimeStatus } from '@lzdi/pty-remote-protocol/runtime-types.ts';
26
+ import { applyTerminalFramePatch, cloneTerminalFrameSnapshot, type TerminalFramePatch, type TerminalFrameSnapshot } from '@lzdi/pty-remote-protocol/terminal-frame.ts';
27
+
28
+ import { loadRelayConfig } from './relay-config.ts';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+ const ROOT_DIR = path.resolve(__dirname, '../..');
33
+ const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
34
+ const WEB_BUILD_DIR = path.join(PUBLIC_DIR, 'build');
35
+ const WEB_BUILD_INDEX_FILE = path.join(WEB_BUILD_DIR, 'index.html');
36
+
37
+ const relayConfig = loadRelayConfig(ROOT_DIR);
38
+ const PORT = Number.parseInt(process.env.PORT ?? String(relayConfig.port), 10);
39
+ const HOST = process.env.HOST ?? relayConfig.host;
40
+ const SOCKET_MAX_HTTP_BUFFER_SIZE = relayConfig.socketMaxHttpBufferSize;
41
+ const CLI_COMMAND_TIMEOUT_MS = relayConfig.cliCommandTimeoutMs;
42
+ const TERMINAL_FRAME_PATCH_HISTORY_LIMIT = 256;
43
+ const TERMINAL_SESSION_CACHE_MAX = 8;
44
+
45
+ const MIME_TYPES: Record<string, string> = {
46
+ '.css': 'text/css; charset=utf-8',
47
+ '.html': 'text/html; charset=utf-8',
48
+ '.js': 'text/javascript; charset=utf-8',
49
+ '.mjs': 'text/javascript; charset=utf-8',
50
+ '.json': 'application/json; charset=utf-8'
51
+ };
52
+
53
+ interface CliProviderRuntimeRecord {
54
+ snapshot: RuntimeSnapshot | null;
55
+ terminalSessions: Map<string, TerminalSessionCacheEntry>;
56
+ }
57
+
58
+ interface TerminalSessionCacheEntry {
59
+ conversationKey: string | null;
60
+ patches: TerminalFramePatch[];
61
+ snapshot: TerminalFrameSnapshot;
62
+ updatedAt: number;
63
+ }
64
+
65
+ interface CliRuntimeRecord {
66
+ socket: Socket;
67
+ descriptor: CliDescriptor;
68
+ runtimes: Partial<Record<ProviderId, CliProviderRuntimeRecord>>;
69
+ }
70
+
71
+ const cliRecords = new Map<string, CliRuntimeRecord>();
72
+ const webRuntimeSubscriptions = new Map<string, RuntimeSubscriptionPayload>();
73
+
74
+ interface RelayReplayEntry {
75
+ seq: number;
76
+ payload: MessagesUpsertPayload;
77
+ }
78
+
79
+ interface RelayReplayBuffer {
80
+ nextSeq: number;
81
+ entries: RelayReplayEntry[];
82
+ lastAccessedAt: number;
83
+ }
84
+
85
+ interface RelaySnapshotCacheEntry {
86
+ payload: RuntimeSnapshotPayload;
87
+ size: number;
88
+ lastAccessedAt: number;
89
+ }
90
+
91
+ const relayReplayBuffers = new Map<string, RelayReplayBuffer>();
92
+ const relaySnapshotCache = new Map<string, RelaySnapshotCacheEntry>();
93
+
94
+ let httpServer: http.Server | null = null;
95
+
96
+ function cloneValue<T>(value: T): T {
97
+ return structuredClone(value);
98
+ }
99
+
100
+ function relayErrorMessage(error: unknown, fallback: string): string {
101
+ return error instanceof Error ? error.message : fallback;
102
+ }
103
+
104
+ function logRelay(level: 'info' | 'warn' | 'error', message: string, details?: Record<string, unknown>): void {
105
+ const logger = level === 'info' ? console.log : level === 'warn' ? console.warn : console.error;
106
+ if (details) {
107
+ logger(`[pty-remote][relay] ${message}`, details);
108
+ return;
109
+ }
110
+ logger(`[pty-remote][relay] ${message}`);
111
+ }
112
+
113
+ function json(res: ServerResponse<IncomingMessage>, statusCode: number, payload: unknown): void {
114
+ res.writeHead(statusCode, {
115
+ 'Content-Type': 'application/json; charset=utf-8',
116
+ 'Cache-Control': 'no-store'
117
+ });
118
+ res.end(JSON.stringify(payload));
119
+ }
120
+
121
+ async function serveStaticFile(res: ServerResponse<IncomingMessage>, filePath: string): Promise<void> {
122
+ const extension = path.extname(filePath);
123
+ const contentType = MIME_TYPES[extension] ?? 'application/octet-stream';
124
+ const content = await fs.readFile(filePath);
125
+ res.writeHead(200, {
126
+ 'Content-Type': contentType,
127
+ 'Cache-Control': 'no-store'
128
+ });
129
+ res.end(content);
130
+ }
131
+
132
+ async function serveWebApp(res: ServerResponse<IncomingMessage>): Promise<void> {
133
+ try {
134
+ await serveStaticFile(res, WEB_BUILD_INDEX_FILE);
135
+ } catch (error) {
136
+ const code = (error as NodeJS.ErrnoException).code;
137
+ if (code !== 'ENOENT') {
138
+ throw error;
139
+ }
140
+ json(res, 503, {
141
+ error: 'Web UI build is not ready. Run `npm run build:web` or wait for `npm run dev:web` to finish.'
142
+ });
143
+ }
144
+ }
145
+
146
+ function resolvePublicAssetPath(urlPathname: string): string | null {
147
+ const normalizedPath = path.posix.normalize(urlPathname);
148
+ if (!normalizedPath.startsWith('/') || normalizedPath.includes('\0')) {
149
+ return null;
150
+ }
151
+
152
+ const relativePath = normalizedPath.replace(/^\/+/, '');
153
+ if (!relativePath) {
154
+ return null;
155
+ }
156
+
157
+ const resolvedPath = path.resolve(PUBLIC_DIR, relativePath);
158
+ if (resolvedPath !== PUBLIC_DIR && !resolvedPath.startsWith(`${PUBLIC_DIR}${path.sep}`)) {
159
+ return null;
160
+ }
161
+
162
+ return resolvedPath;
163
+ }
164
+
165
+ function normalizeSupportedProviders(payload: CliRegisterPayload): ProviderId[] {
166
+ return [...new Set(payload.supportedProviders)].sort();
167
+ }
168
+
169
+ function resolveConversationCacheKey(
170
+ cliId: string | null,
171
+ providerId: ProviderId | null,
172
+ conversationKey: string | null,
173
+ sessionId: string | null
174
+ ): string | null {
175
+ if (!cliId || !providerId) {
176
+ return null;
177
+ }
178
+ if (conversationKey) {
179
+ return `${cliId}:${providerId}:conversation:${conversationKey}`;
180
+ }
181
+ if (sessionId) {
182
+ return `${cliId}:${providerId}:session:${sessionId}`;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function getReplayBuffer(cacheKey: string): RelayReplayBuffer {
188
+ const existing = relayReplayBuffers.get(cacheKey);
189
+ if (existing) {
190
+ existing.lastAccessedAt = Date.now();
191
+ return existing;
192
+ }
193
+ const created: RelayReplayBuffer = {
194
+ nextSeq: 0,
195
+ entries: [],
196
+ lastAccessedAt: Date.now()
197
+ };
198
+ relayReplayBuffers.set(cacheKey, created);
199
+ return created;
200
+ }
201
+
202
+ function recordReplayEntry(cacheKey: string, payload: MessagesUpsertPayload): MessagesUpsertPayload {
203
+ const buffer = getReplayBuffer(cacheKey);
204
+ const seq = buffer.nextSeq + 1;
205
+ buffer.nextSeq = seq;
206
+ const enriched = { ...payload, seq };
207
+ buffer.entries.push({ seq, payload: enriched });
208
+ if (buffer.entries.length > relayConfig.replayBufferSize) {
209
+ buffer.entries.splice(0, buffer.entries.length - relayConfig.replayBufferSize);
210
+ }
211
+ buffer.lastAccessedAt = Date.now();
212
+ return enriched;
213
+ }
214
+
215
+ function cacheSnapshot(payload: RuntimeSnapshotPayload): void {
216
+ const cacheKey = resolveConversationCacheKey(
217
+ payload.cliId,
218
+ payload.providerId,
219
+ payload.snapshot.conversationKey,
220
+ payload.snapshot.sessionId
221
+ );
222
+ if (!cacheKey) {
223
+ return;
224
+ }
225
+ const size = Buffer.byteLength(JSON.stringify(payload), 'utf8');
226
+ if (size > relayConfig.snapshotMaxBytes) {
227
+ relaySnapshotCache.delete(cacheKey);
228
+ return;
229
+ }
230
+ relaySnapshotCache.set(cacheKey, {
231
+ payload: cloneValue(payload),
232
+ size,
233
+ lastAccessedAt: Date.now()
234
+ });
235
+ if (relaySnapshotCache.size <= relayConfig.snapshotCacheMax) {
236
+ return;
237
+ }
238
+ const entries = [...relaySnapshotCache.entries()].sort((left, right) => left[1].lastAccessedAt - right[1].lastAccessedAt);
239
+ const excess = relaySnapshotCache.size - relayConfig.snapshotCacheMax;
240
+ for (let i = 0; i < excess; i += 1) {
241
+ relaySnapshotCache.delete(entries[i]?.[0] ?? '');
242
+ }
243
+ }
244
+
245
+ function emitCachedSnapshotToSocket(socket: Socket, subscription: RuntimeSubscriptionPayload): boolean {
246
+ if (!hasRuntimeSubscriptionTarget(subscription) || !hasRuntimeSubscriptionConversation(subscription)) {
247
+ return false;
248
+ }
249
+ const cacheKey = resolveConversationCacheKey(
250
+ subscription.targetCliId,
251
+ subscription.targetProviderId,
252
+ subscription.conversationKey,
253
+ subscription.sessionId
254
+ );
255
+ if (!cacheKey) {
256
+ return false;
257
+ }
258
+ const cached = relaySnapshotCache.get(cacheKey);
259
+ if (!cached) {
260
+ return false;
261
+ }
262
+ cached.lastAccessedAt = Date.now();
263
+ socket.emit('runtime:snapshot', cloneValue(cached.payload));
264
+ return true;
265
+ }
266
+
267
+ function replayMessagesToSocket(socket: Socket, subscription: RuntimeSubscriptionPayload): boolean {
268
+ if (!hasRuntimeSubscriptionTarget(subscription) || !hasRuntimeSubscriptionConversation(subscription)) {
269
+ return false;
270
+ }
271
+ if (subscription.lastSeq == null) {
272
+ return false;
273
+ }
274
+ const cacheKey = resolveConversationCacheKey(
275
+ subscription.targetCliId,
276
+ subscription.targetProviderId,
277
+ subscription.conversationKey,
278
+ subscription.sessionId
279
+ );
280
+ if (!cacheKey) {
281
+ return false;
282
+ }
283
+ const buffer = relayReplayBuffers.get(cacheKey);
284
+ if (!buffer || buffer.entries.length === 0) {
285
+ return false;
286
+ }
287
+ const oldestSeq = buffer.entries[0]?.seq ?? null;
288
+ if (oldestSeq === null || subscription.lastSeq < oldestSeq) {
289
+ return false;
290
+ }
291
+ const entries = buffer.entries.filter((entry) => entry.seq > (subscription.lastSeq ?? 0));
292
+ if (entries.length === 0) {
293
+ return true;
294
+ }
295
+ for (const entry of entries) {
296
+ socket.emit('runtime:messages-upsert', cloneValue(entry.payload));
297
+ }
298
+ buffer.lastAccessedAt = Date.now();
299
+ return true;
300
+ }
301
+
302
+ function createProviderRuntimeDescriptor(
303
+ payload: CliRegisterPayload,
304
+ providerId: ProviderId
305
+ ): CliProviderRuntimeDescriptor {
306
+ const registration = payload.runtimes[providerId];
307
+ return {
308
+ cwd: registration?.cwd ?? payload.cwd,
309
+ conversationKey: registration?.conversationKey ?? null,
310
+ status: 'idle',
311
+ sessionId: registration?.sessionId ?? null
312
+ };
313
+ }
314
+
315
+ function createProviderRuntimeRecord(
316
+ payload: CliRegisterPayload,
317
+ providerId: ProviderId,
318
+ previous?: CliProviderRuntimeRecord | null
319
+ ): CliProviderRuntimeRecord {
320
+ return {
321
+ snapshot: previous?.snapshot ?? null,
322
+ terminalSessions: cloneTerminalSessions(previous?.terminalSessions)
323
+ };
324
+ }
325
+
326
+ function cloneTerminalSessions(
327
+ source: Map<string, TerminalSessionCacheEntry> | undefined
328
+ ): Map<string, TerminalSessionCacheEntry> {
329
+ const cloned = new Map<string, TerminalSessionCacheEntry>();
330
+ if (!source) {
331
+ return cloned;
332
+ }
333
+ for (const [sessionId, entry] of source.entries()) {
334
+ cloned.set(sessionId, {
335
+ conversationKey: entry.conversationKey,
336
+ patches: entry.patches.map((patch) => cloneValue(patch)),
337
+ snapshot: cloneTerminalFrameSnapshot(entry.snapshot),
338
+ updatedAt: entry.updatedAt
339
+ });
340
+ }
341
+ return cloned;
342
+ }
343
+
344
+ function createCliDescriptor(cliId: string, payload: CliRegisterPayload): CliDescriptor {
345
+ const now = new Date().toISOString();
346
+ const supportedProviders = normalizeSupportedProviders(payload);
347
+ return {
348
+ cliId,
349
+ label: payload.label?.trim() || path.basename(payload.cwd) || cliId,
350
+ cwd: payload.cwd,
351
+ supportedProviders,
352
+ runtimes: Object.fromEntries(
353
+ supportedProviders.map((providerId) => [providerId, createProviderRuntimeDescriptor(payload, providerId)])
354
+ ),
355
+ runtimeBackend: payload.runtimeBackend,
356
+ connected: true,
357
+ connectedAt: now,
358
+ lastSeenAt: now
359
+ };
360
+ }
361
+
362
+ function listCliDescriptors(): CliDescriptor[] {
363
+ return [...cliRecords.values()]
364
+ .map((record) => cloneValue(record.descriptor))
365
+ .sort((left, right) => left.label.localeCompare(right.label) || left.cliId.localeCompare(right.cliId));
366
+ }
367
+
368
+ function getProviderRecord(record: CliRuntimeRecord, providerId: ProviderId): CliProviderRuntimeRecord | null {
369
+ return record.runtimes[providerId] ?? null;
370
+ }
371
+
372
+ function updateDescriptorFromSnapshot(record: CliRuntimeRecord, providerId: ProviderId): void {
373
+ const providerRecord = getProviderRecord(record, providerId);
374
+ if (!providerRecord?.snapshot) {
375
+ return;
376
+ }
377
+
378
+ record.descriptor = {
379
+ ...record.descriptor,
380
+ runtimes: {
381
+ ...record.descriptor.runtimes,
382
+ [providerId]: {
383
+ cwd: record.descriptor.runtimes[providerId]?.cwd ?? record.descriptor.cwd,
384
+ conversationKey: providerRecord.snapshot.conversationKey,
385
+ status: providerRecord.snapshot.status,
386
+ sessionId: providerRecord.snapshot.sessionId
387
+ }
388
+ },
389
+ lastSeenAt: new Date().toISOString()
390
+ };
391
+ }
392
+
393
+ function pruneTerminalSessions(record: CliProviderRuntimeRecord): void {
394
+ if (record.terminalSessions.size <= TERMINAL_SESSION_CACHE_MAX) {
395
+ return;
396
+ }
397
+ const evictions = [...record.terminalSessions.entries()]
398
+ .sort((left, right) => left[1].updatedAt - right[1].updatedAt)
399
+ .slice(0, Math.max(0, record.terminalSessions.size - TERMINAL_SESSION_CACHE_MAX));
400
+ for (const [sessionId] of evictions) {
401
+ record.terminalSessions.delete(sessionId);
402
+ }
403
+ }
404
+
405
+ function getTerminalSessionCache(
406
+ record: CliProviderRuntimeRecord | null,
407
+ sessionId: string | null | undefined
408
+ ): TerminalSessionCacheEntry | null {
409
+ const normalizedSessionId = sessionId?.trim() || null;
410
+ if (!record || !normalizedSessionId) {
411
+ return null;
412
+ }
413
+ return record.terminalSessions.get(normalizedSessionId) ?? null;
414
+ }
415
+
416
+ function deleteTerminalSessionCache(record: CliProviderRuntimeRecord, sessionId: string | null | undefined): void {
417
+ const normalizedSessionId = sessionId?.trim() || null;
418
+ if (!normalizedSessionId) {
419
+ return;
420
+ }
421
+ record.terminalSessions.delete(normalizedSessionId);
422
+ }
423
+
424
+ function appendTerminalFramePatch(
425
+ record: CliProviderRuntimeRecord,
426
+ patch: TerminalFramePatch,
427
+ conversationKey: string | null
428
+ ): boolean {
429
+ const sessionId = patch.sessionId?.trim() || null;
430
+ if (!sessionId) {
431
+ return false;
432
+ }
433
+ const isResetPatch = patch.ops.some((op) => op.type === 'reset');
434
+ if (isResetPatch) {
435
+ record.terminalSessions.set(sessionId, {
436
+ conversationKey,
437
+ patches: [cloneValue(patch)],
438
+ snapshot: applyTerminalFramePatch(null, patch),
439
+ updatedAt: Date.now()
440
+ });
441
+ pruneTerminalSessions(record);
442
+ return true;
443
+ }
444
+
445
+ const currentSession = getTerminalSessionCache(record, sessionId);
446
+ if (!currentSession || currentSession.snapshot.revision !== patch.baseRevision) {
447
+ deleteTerminalSessionCache(record, sessionId);
448
+ return false;
449
+ }
450
+
451
+ currentSession.snapshot = applyTerminalFramePatch(currentSession.snapshot, patch);
452
+ currentSession.conversationKey = conversationKey;
453
+ currentSession.patches.push(cloneValue(patch));
454
+ if (currentSession.patches.length > TERMINAL_FRAME_PATCH_HISTORY_LIMIT) {
455
+ currentSession.patches.splice(0, currentSession.patches.length - TERMINAL_FRAME_PATCH_HISTORY_LIMIT);
456
+ }
457
+ currentSession.updatedAt = Date.now();
458
+ return true;
459
+ }
460
+
461
+ function getTerminalFramePatchesSince(
462
+ record: CliProviderRuntimeRecord,
463
+ sessionId: string,
464
+ lastRevision: number
465
+ ): TerminalFramePatch[] | null {
466
+ const sessionCache = getTerminalSessionCache(record, sessionId);
467
+ if (!sessionCache) {
468
+ return null;
469
+ }
470
+ const currentSnapshot = sessionCache.snapshot;
471
+ if (lastRevision === currentSnapshot.revision) {
472
+ return [];
473
+ }
474
+ if (lastRevision > currentSnapshot.revision) {
475
+ return null;
476
+ }
477
+
478
+ const selected: TerminalFramePatch[] = [];
479
+ let expectedBaseRevision = lastRevision;
480
+
481
+ for (const patch of sessionCache.patches) {
482
+ if (patch.revision <= lastRevision) {
483
+ continue;
484
+ }
485
+ if (patch.baseRevision !== expectedBaseRevision) {
486
+ return null;
487
+ }
488
+ selected.push(cloneValue(patch));
489
+ expectedBaseRevision = patch.revision;
490
+ if (expectedBaseRevision === currentSnapshot.revision) {
491
+ return selected;
492
+ }
493
+ }
494
+
495
+ return null;
496
+ }
497
+
498
+ function getTerminalSessionId(record: CliProviderRuntimeRecord | null): string | null {
499
+ return record?.snapshot?.sessionId ?? null;
500
+ }
501
+
502
+ function getSocketCliId(socket: Socket): string | null {
503
+ const cliId = (socket.data as { cliId?: string }).cliId;
504
+ return typeof cliId === 'string' && cliId.trim() ? cliId : null;
505
+ }
506
+
507
+ function getConnectedCliRecord(cliId: string | null | undefined): CliRuntimeRecord | null {
508
+ if (!cliId) {
509
+ return null;
510
+ }
511
+
512
+ const record = cliRecords.get(cliId) ?? null;
513
+ if (!record?.descriptor.connected) {
514
+ return null;
515
+ }
516
+
517
+ return record;
518
+ }
519
+
520
+ function isProviderId(value: unknown): value is ProviderId {
521
+ return value === 'claude' || value === 'codex';
522
+ }
523
+
524
+ function normalizeRuntimeSubscription(payload?: Partial<RuntimeSubscriptionPayload> | null): RuntimeSubscriptionPayload {
525
+ const targetCliId =
526
+ typeof payload?.targetCliId === 'string' && payload.targetCliId.trim().length > 0 ? payload.targetCliId.trim() : null;
527
+ const targetProviderId = isProviderId(payload?.targetProviderId) ? payload.targetProviderId : null;
528
+ const conversationKey =
529
+ typeof payload?.conversationKey === 'string' && payload.conversationKey.trim().length > 0 ? payload.conversationKey.trim() : null;
530
+ const sessionId =
531
+ typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : null;
532
+ const lastSeq = typeof payload?.lastSeq === 'number' && Number.isFinite(payload.lastSeq) ? payload.lastSeq : null;
533
+ const terminalEnabled = payload?.terminalEnabled === true;
534
+ return {
535
+ targetCliId,
536
+ targetProviderId,
537
+ conversationKey,
538
+ sessionId,
539
+ lastSeq,
540
+ terminalEnabled
541
+ };
542
+ }
543
+
544
+ function hasRuntimeSubscriptionTarget(subscription: RuntimeSubscriptionPayload): boolean {
545
+ return subscription.targetCliId !== null && subscription.targetProviderId !== null;
546
+ }
547
+
548
+ function hasRuntimeSubscriptionConversation(subscription: RuntimeSubscriptionPayload): boolean {
549
+ return subscription.conversationKey !== null || subscription.sessionId !== null;
550
+ }
551
+
552
+ function matchesRuntimeSnapshotSubscription(subscription: RuntimeSubscriptionPayload, payload: RuntimeSnapshotPayload): boolean {
553
+ if (!hasRuntimeSubscriptionTarget(subscription)) {
554
+ return false;
555
+ }
556
+ if (!hasRuntimeSubscriptionConversation(subscription)) {
557
+ return false;
558
+ }
559
+ if (payload.cliId !== subscription.targetCliId || payload.providerId !== subscription.targetProviderId) {
560
+ return false;
561
+ }
562
+ if (subscription.conversationKey !== null && payload.snapshot.conversationKey !== subscription.conversationKey) {
563
+ return false;
564
+ }
565
+ if (subscription.sessionId !== null && payload.snapshot.sessionId !== subscription.sessionId) {
566
+ return false;
567
+ }
568
+ return true;
569
+ }
570
+
571
+ function matchesMessagesUpsertSubscription(subscription: RuntimeSubscriptionPayload, payload: MessagesUpsertPayload): boolean {
572
+ if (!hasRuntimeSubscriptionTarget(subscription)) {
573
+ return false;
574
+ }
575
+ if (!hasRuntimeSubscriptionConversation(subscription)) {
576
+ return false;
577
+ }
578
+ if (payload.cliId !== subscription.targetCliId || payload.providerId !== subscription.targetProviderId) {
579
+ return false;
580
+ }
581
+ if (subscription.conversationKey !== null && payload.conversationKey !== subscription.conversationKey) {
582
+ return false;
583
+ }
584
+ if (subscription.sessionId !== null && payload.sessionId !== subscription.sessionId) {
585
+ return false;
586
+ }
587
+ return true;
588
+ }
589
+
590
+ function matchesTerminalFramePatchSubscription(subscription: RuntimeSubscriptionPayload, payload: TerminalFramePatchPayload): boolean {
591
+ if (subscription.terminalEnabled !== true) {
592
+ return false;
593
+ }
594
+ if (!hasRuntimeSubscriptionTarget(subscription)) {
595
+ return false;
596
+ }
597
+ if (!hasRuntimeSubscriptionConversation(subscription)) {
598
+ return false;
599
+ }
600
+ if (payload.cliId !== subscription.targetCliId || payload.providerId !== subscription.targetProviderId) {
601
+ return false;
602
+ }
603
+ if (subscription.conversationKey !== null && payload.conversationKey !== subscription.conversationKey) {
604
+ return false;
605
+ }
606
+ if (subscription.sessionId !== null && payload.patch.sessionId !== subscription.sessionId) {
607
+ return false;
608
+ }
609
+ return true;
610
+ }
611
+
612
+ function matchesProviderRuntimeSubscription(
613
+ subscription: RuntimeSubscriptionPayload,
614
+ cliId: string,
615
+ providerId: ProviderId,
616
+ providerRecord: CliProviderRuntimeRecord
617
+ ): boolean {
618
+ if (!hasRuntimeSubscriptionTarget(subscription)) {
619
+ return false;
620
+ }
621
+ if (!hasRuntimeSubscriptionConversation(subscription)) {
622
+ return false;
623
+ }
624
+ if (subscription.targetCliId !== cliId || subscription.targetProviderId !== providerId) {
625
+ return false;
626
+ }
627
+
628
+ const terminalSessionId = subscription.sessionId ?? getTerminalSessionId(providerRecord);
629
+ const terminalSessionCache = getTerminalSessionCache(providerRecord, terminalSessionId);
630
+ const conversationKeyForMatch =
631
+ subscription.sessionId !== null
632
+ ? terminalSessionCache?.conversationKey ?? null
633
+ : providerRecord.snapshot?.conversationKey ?? null;
634
+
635
+ if (subscription.conversationKey !== null && conversationKeyForMatch !== subscription.conversationKey) {
636
+ return false;
637
+ }
638
+ if (subscription.sessionId !== null && !terminalSessionCache) {
639
+ return false;
640
+ }
641
+ return true;
642
+ }
643
+
644
+ function emitCurrentRuntimeSnapshotToSocket(socket: Socket, subscription: RuntimeSubscriptionPayload): void {
645
+ if (!hasRuntimeSubscriptionTarget(subscription)) {
646
+ return;
647
+ }
648
+
649
+ const record = getConnectedCliRecord(subscription.targetCliId);
650
+ const providerId = subscription.targetProviderId;
651
+ if (!record || !providerId) {
652
+ return;
653
+ }
654
+
655
+ const providerRecord = getProviderRecord(record, providerId);
656
+ if (!providerRecord?.snapshot || !matchesProviderRuntimeSubscription(subscription, record.descriptor.cliId, providerId, providerRecord)) {
657
+ return;
658
+ }
659
+
660
+ socket.emit('runtime:snapshot', {
661
+ cliId: record.descriptor.cliId,
662
+ providerId,
663
+ snapshot: cloneValue(providerRecord.snapshot)
664
+ } satisfies RuntimeSnapshotPayload);
665
+ }
666
+
667
+ function emitRuntimeSnapshotToSubscribers(io: SocketIOServer, payload: RuntimeSnapshotPayload): void {
668
+ for (const socket of io.of('/web').sockets.values()) {
669
+ const subscription = webRuntimeSubscriptions.get(socket.id);
670
+ if (!subscription || !matchesRuntimeSnapshotSubscription(subscription, payload)) {
671
+ continue;
672
+ }
673
+ socket.emit('runtime:snapshot', payload);
674
+ }
675
+ }
676
+
677
+ function emitMessagesUpsertToSubscribers(io: SocketIOServer, payload: MessagesUpsertPayload): void {
678
+ for (const socket of io.of('/web').sockets.values()) {
679
+ const subscription = webRuntimeSubscriptions.get(socket.id);
680
+ if (!subscription) {
681
+ continue;
682
+ }
683
+ if (!matchesMessagesUpsertSubscription(subscription, payload)) {
684
+ continue;
685
+ }
686
+ socket.emit('runtime:messages-upsert', payload);
687
+ }
688
+ }
689
+
690
+ function emitTerminalFramePatchToSubscribers(io: SocketIOServer, payload: TerminalFramePatchPayload): void {
691
+ for (const socket of io.of('/web').sockets.values()) {
692
+ const subscription = webRuntimeSubscriptions.get(socket.id);
693
+ if (!subscription || !matchesTerminalFramePatchSubscription(subscription, payload)) {
694
+ continue;
695
+ }
696
+ socket.emit('terminal:frame-patch', payload);
697
+ }
698
+ }
699
+
700
+ async function createTerminalFrameSyncResult(socket: Socket, payload: TerminalFrameSyncRequestPayload): Promise<TerminalFrameSyncResultPayload> {
701
+ const subscription = webRuntimeSubscriptions.get(socket.id);
702
+ const record = getConnectedCliRecord(payload.targetCliId);
703
+ const providerId = payload.targetProviderId ?? null;
704
+ const providerRecord = record && providerId ? getProviderRecord(record, providerId) : null;
705
+ const requestedSessionId = payload.sessionId ?? providerRecord?.snapshot?.sessionId ?? null;
706
+ let terminalSessionCache = getTerminalSessionCache(providerRecord, requestedSessionId);
707
+ const isActiveSessionRequest = requestedSessionId !== null && requestedSessionId === providerRecord?.snapshot?.sessionId;
708
+ const terminalFrameSnapshot = terminalSessionCache?.snapshot ?? null;
709
+
710
+ if (
711
+ !record ||
712
+ !providerId ||
713
+ !providerRecord ||
714
+ !subscription ||
715
+ !matchesProviderRuntimeSubscription(subscription, record.descriptor.cliId, providerId, providerRecord) ||
716
+ !terminalSessionCache ||
717
+ !terminalFrameSnapshot
718
+ ) {
719
+ if (record && providerId && providerRecord && subscription && isActiveSessionRequest) {
720
+ const result = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
721
+ record.socket.emit('cli:terminal-frame-prime', { targetProviderId: providerId }, (ack?: { ok: boolean; error?: string }) => {
722
+ resolve(ack ?? { ok: false, error: 'No response from CLI terminal frame priming' });
723
+ });
724
+ });
725
+ if (!result.ok) {
726
+ return {
727
+ ok: false,
728
+ error: result.error || 'Failed to prepare terminal frame',
729
+ providerId,
730
+ sessionId: requestedSessionId
731
+ };
732
+ }
733
+
734
+ terminalSessionCache = getTerminalSessionCache(providerRecord, requestedSessionId);
735
+ if (terminalSessionCache?.snapshot) {
736
+ return {
737
+ ok: true,
738
+ providerId,
739
+ sessionId: terminalSessionCache.snapshot.sessionId,
740
+ mode: 'snapshot',
741
+ snapshot: cloneTerminalFrameSnapshot(terminalSessionCache.snapshot)
742
+ };
743
+ }
744
+ }
745
+
746
+ return {
747
+ ok: false,
748
+ error: 'Terminal frame is unavailable for the requested runtime',
749
+ providerId,
750
+ sessionId: null
751
+ };
752
+ }
753
+
754
+ const resolvedTerminalSessionCache = terminalSessionCache;
755
+
756
+ if (
757
+ payload.sessionId === terminalFrameSnapshot.sessionId &&
758
+ payload.lastRevision != null
759
+ ) {
760
+ const patches = getTerminalFramePatchesSince(providerRecord, terminalFrameSnapshot.sessionId ?? '', payload.lastRevision);
761
+ if (patches) {
762
+ resolvedTerminalSessionCache.updatedAt = Date.now();
763
+ return {
764
+ ok: true,
765
+ providerId,
766
+ sessionId: terminalFrameSnapshot.sessionId,
767
+ mode: 'patches',
768
+ patches
769
+ };
770
+ }
771
+ }
772
+
773
+ resolvedTerminalSessionCache.updatedAt = Date.now();
774
+ return {
775
+ ok: true,
776
+ providerId,
777
+ sessionId: terminalFrameSnapshot.sessionId,
778
+ mode: 'snapshot',
779
+ snapshot: cloneTerminalFrameSnapshot(terminalFrameSnapshot)
780
+ };
781
+ }
782
+
783
+ function emitCliStatus(io: SocketIOServer): void {
784
+ io.of('/web').emit('cli:update', {
785
+ clis: listCliDescriptors()
786
+ } satisfies CliStatusPayload);
787
+ }
788
+
789
+ async function handleHttpRequest(req: IncomingMessage, res: ServerResponse<IncomingMessage>): Promise<void> {
790
+ const method = req.method ?? 'GET';
791
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
792
+
793
+ if (method === 'GET' && url.pathname === '/healthz') {
794
+ json(res, 200, { ok: true, cliConnected: [...cliRecords.values()].some((record) => record.descriptor.connected) });
795
+ return;
796
+ }
797
+
798
+ if (method === 'GET' && url.pathname === '/') {
799
+ await serveWebApp(res);
800
+ return;
801
+ }
802
+
803
+ if (method === 'GET' && url.pathname === '/favicon.ico') {
804
+ res.writeHead(204);
805
+ res.end();
806
+ return;
807
+ }
808
+
809
+ if (method === 'GET') {
810
+ const publicAssetPath = resolvePublicAssetPath(url.pathname);
811
+ if (publicAssetPath) {
812
+ try {
813
+ const stat = await fs.stat(publicAssetPath);
814
+ if (stat.isFile()) {
815
+ await serveStaticFile(res, publicAssetPath);
816
+ return;
817
+ }
818
+ } catch (error) {
819
+ const code = (error as NodeJS.ErrnoException).code;
820
+ if (code !== 'ENOENT') {
821
+ throw error;
822
+ }
823
+ }
824
+ }
825
+ }
826
+
827
+ json(res, 404, { error: 'Not found' });
828
+ }
829
+
830
+ function forwardCliCommand(record: CliRuntimeRecord, envelope: CliCommandEnvelope): Promise<CliCommandResult> {
831
+ return new Promise((resolve) => {
832
+ const timer = setTimeout(() => {
833
+ logRelay('error', 'cli command timed out', {
834
+ cliId: record.descriptor.cliId,
835
+ command: envelope.name,
836
+ requestId: envelope.requestId,
837
+ targetProviderId: envelope.targetProviderId ?? null,
838
+ timeoutMs: CLI_COMMAND_TIMEOUT_MS
839
+ });
840
+ resolve({ ok: false, error: 'CLI command timeout' });
841
+ }, CLI_COMMAND_TIMEOUT_MS);
842
+
843
+ record.socket.emit('cli:command', envelope, (result?: CliCommandResult) => {
844
+ clearTimeout(timer);
845
+ if (!result?.ok) {
846
+ logRelay('error', 'cli command returned failure', {
847
+ cliId: record.descriptor.cliId,
848
+ command: envelope.name,
849
+ error: result?.error || 'CLI command failed',
850
+ requestId: envelope.requestId,
851
+ targetProviderId: envelope.targetProviderId ?? null
852
+ });
853
+ }
854
+ resolve(result?.ok ? result : { ok: false, error: result?.error || 'CLI command failed' });
855
+ });
856
+ });
857
+ }
858
+
859
+ async function routeWebCommand(command: WebCommandEnvelope, io: SocketIOServer): Promise<CliCommandResult> {
860
+ const targetCliId = command.targetCliId?.trim() || null;
861
+ if (!targetCliId) {
862
+ return { ok: false, error: 'CLI is not selected' };
863
+ }
864
+
865
+ const record = getConnectedCliRecord(targetCliId);
866
+ if (!record) {
867
+ return { ok: false, error: 'CLI is offline' };
868
+ }
869
+
870
+ const result = await forwardCliCommand(record, {
871
+ requestId: randomUUID(),
872
+ targetProviderId: command.targetProviderId ?? null,
873
+ name: command.name,
874
+ payload: command.payload
875
+ } satisfies CliCommandEnvelope);
876
+
877
+ if (result.ok && command.name === 'select-conversation') {
878
+ const providerId = command.targetProviderId ?? null;
879
+ const payload = result.payload as { conversationKey?: string | null; sessionId?: string | null } | undefined;
880
+ const cwd = path.resolve((command.payload as { cwd: string }).cwd);
881
+ const currentRuntime = providerId ? record.descriptor.runtimes[providerId] : null;
882
+ record.descriptor = {
883
+ ...record.descriptor,
884
+ cwd: providerId && providerId === record.descriptor.supportedProviders[0] ? cwd : record.descriptor.cwd,
885
+ runtimes:
886
+ providerId === null
887
+ ? record.descriptor.runtimes
888
+ : {
889
+ ...record.descriptor.runtimes,
890
+ [providerId]: {
891
+ cwd,
892
+ conversationKey:
893
+ payload?.conversationKey ?? (command.payload as { conversationKey?: string }).conversationKey ?? null,
894
+ sessionId: payload?.sessionId ?? null,
895
+ status: currentRuntime?.status ?? 'idle'
896
+ }
897
+ },
898
+ lastSeenAt: new Date().toISOString()
899
+ };
900
+ emitCliStatus(io);
901
+ }
902
+
903
+ return result;
904
+ }
905
+
906
+ function buildWebInitPayload(): WebInitPayload {
907
+ return {
908
+ clis: listCliDescriptors()
909
+ };
910
+ }
911
+
912
+ export async function startSocketServer(): Promise<void> {
913
+ if (httpServer) {
914
+ return;
915
+ }
916
+
917
+ httpServer = http.createServer((req, res) => {
918
+ void handleHttpRequest(req, res).catch((error) => {
919
+ const message = error instanceof Error ? error.message : 'Internal server error';
920
+ if (!res.headersSent) {
921
+ json(res, 500, { error: message });
922
+ return;
923
+ }
924
+ res.end();
925
+ });
926
+ });
927
+
928
+ const io = new SocketIOServer(httpServer, {
929
+ path: '/socket.io/',
930
+ maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE,
931
+ cors: {
932
+ origin: true,
933
+ credentials: true
934
+ }
935
+ });
936
+
937
+ io.of('/cli').on('connection', (socket) => {
938
+ socket.on('cli:register', (payload: CliRegisterPayload, callback?: (result: CliRegisterResult) => void) => {
939
+ const cliId = payload.cliId?.trim() || randomUUID();
940
+ const previous = cliRecords.get(cliId);
941
+
942
+ if (previous?.descriptor.connected && previous.socket.id !== socket.id) {
943
+ callback?.({
944
+ ok: false,
945
+ cliId,
946
+ errorCode: 'conflict',
947
+ error: `CLI ${cliId} is already connected`
948
+ });
949
+ return;
950
+ }
951
+
952
+ (socket.data as { cliId?: string }).cliId = cliId;
953
+
954
+ const record: CliRuntimeRecord = {
955
+ socket,
956
+ descriptor: createCliDescriptor(cliId, payload),
957
+ runtimes: Object.fromEntries(
958
+ normalizeSupportedProviders(payload).map((providerId) => [
959
+ providerId,
960
+ createProviderRuntimeRecord(payload, providerId, previous ? getProviderRecord(previous, providerId) : null)
961
+ ])
962
+ )
963
+ };
964
+
965
+ cliRecords.set(cliId, record);
966
+ for (const providerId of record.descriptor.supportedProviders) {
967
+ updateDescriptorFromSnapshot(record, providerId);
968
+ }
969
+ emitCliStatus(io);
970
+ callback?.({
971
+ ok: true,
972
+ cliId
973
+ });
974
+ });
975
+
976
+ socket.on('cli:snapshot', (payload: RuntimeSnapshotPayload) => {
977
+ const cliId = getSocketCliId(socket);
978
+ if (!cliId) {
979
+ return;
980
+ }
981
+ const record = cliId ? cliRecords.get(cliId) : null;
982
+ if (!record || record.socket.id !== socket.id) {
983
+ return;
984
+ }
985
+
986
+ const providerRecord = getProviderRecord(record, payload.providerId);
987
+ if (!providerRecord) {
988
+ return;
989
+ }
990
+
991
+ record.descriptor.connected = true;
992
+ providerRecord.snapshot = cloneValue(payload.snapshot);
993
+ updateDescriptorFromSnapshot(record, payload.providerId);
994
+ const snapshotPayload = {
995
+ cliId,
996
+ providerId: payload.providerId,
997
+ snapshot: cloneValue(payload.snapshot)
998
+ } satisfies RuntimeSnapshotPayload;
999
+ cacheSnapshot(snapshotPayload);
1000
+ emitRuntimeSnapshotToSubscribers(io, snapshotPayload);
1001
+ emitCliStatus(io);
1002
+ });
1003
+
1004
+ socket.on('cli:messages-upsert', (payload: MessagesUpsertPayload) => {
1005
+ const cliId = getSocketCliId(socket);
1006
+ if (!cliId) {
1007
+ return;
1008
+ }
1009
+ const record = cliId ? cliRecords.get(cliId) : null;
1010
+ if (!record || record.socket.id !== socket.id) {
1011
+ return;
1012
+ }
1013
+
1014
+ record.descriptor.connected = true;
1015
+ record.descriptor.lastSeenAt = new Date().toISOString();
1016
+ const basePayload = {
1017
+ ...cloneValue(payload),
1018
+ cliId
1019
+ } satisfies MessagesUpsertPayload;
1020
+ const cacheKey = resolveConversationCacheKey(
1021
+ cliId,
1022
+ basePayload.providerId ?? null,
1023
+ basePayload.conversationKey ?? null,
1024
+ basePayload.sessionId ?? null
1025
+ );
1026
+ const messagesPayload = cacheKey ? recordReplayEntry(cacheKey, basePayload) : basePayload;
1027
+ emitMessagesUpsertToSubscribers(io, messagesPayload);
1028
+ });
1029
+
1030
+ socket.on('cli:terminal-frame-patch', (payload: TerminalFramePatchPayload) => {
1031
+ const cliId = getSocketCliId(socket);
1032
+ if (!cliId) {
1033
+ return;
1034
+ }
1035
+ const record = cliId ? cliRecords.get(cliId) : null;
1036
+ if (!record || record.socket.id !== socket.id) {
1037
+ return;
1038
+ }
1039
+
1040
+ const providerRecord = getProviderRecord(record, payload.providerId);
1041
+ if (!providerRecord) {
1042
+ return;
1043
+ }
1044
+
1045
+ record.descriptor.connected = true;
1046
+ record.descriptor.lastSeenAt = new Date().toISOString();
1047
+ if (!appendTerminalFramePatch(providerRecord, payload.patch, payload.conversationKey ?? null)) {
1048
+ return;
1049
+ }
1050
+ emitTerminalFramePatchToSubscribers(io, {
1051
+ ...cloneValue(payload),
1052
+ cliId
1053
+ } satisfies TerminalFramePatchPayload);
1054
+ });
1055
+
1056
+ socket.on('cli:terminal-session-evicted', (payload: TerminalSessionEvictedPayload) => {
1057
+ const cliId = getSocketCliId(socket);
1058
+ if (!cliId) {
1059
+ return;
1060
+ }
1061
+ const record = cliId ? cliRecords.get(cliId) : null;
1062
+ if (!record || record.socket.id !== socket.id) {
1063
+ return;
1064
+ }
1065
+
1066
+ const providerRecord = getProviderRecord(record, payload.providerId);
1067
+ if (!providerRecord) {
1068
+ return;
1069
+ }
1070
+
1071
+ deleteTerminalSessionCache(providerRecord, payload.sessionId);
1072
+ record.descriptor.lastSeenAt = new Date().toISOString();
1073
+ });
1074
+
1075
+ socket.on('disconnect', () => {
1076
+ const cliId = getSocketCliId(socket);
1077
+ const record = cliId ? cliRecords.get(cliId) : null;
1078
+ if (!record || record.socket.id !== socket.id) {
1079
+ return;
1080
+ }
1081
+
1082
+ record.descriptor = {
1083
+ ...record.descriptor,
1084
+ connected: false,
1085
+ runtimes: Object.fromEntries(
1086
+ record.descriptor.supportedProviders.map((providerId) => [
1087
+ providerId,
1088
+ {
1089
+ ...(record.descriptor.runtimes[providerId] ?? {
1090
+ cwd: record.descriptor.cwd,
1091
+ conversationKey: null,
1092
+ sessionId: null,
1093
+ status: 'idle' as RuntimeStatus
1094
+ }),
1095
+ status: 'idle' as RuntimeStatus
1096
+ }
1097
+ ])
1098
+ ),
1099
+ lastSeenAt: new Date().toISOString()
1100
+ };
1101
+ emitCliStatus(io);
1102
+ });
1103
+ });
1104
+
1105
+ io.of('/web').on('connection', (socket) => {
1106
+ webRuntimeSubscriptions.set(socket.id, normalizeRuntimeSubscription());
1107
+ socket.emit('web:init', buildWebInitPayload());
1108
+
1109
+ socket.on('web:runtime-subscribe', (payload: RuntimeSubscriptionPayload) => {
1110
+ const subscription = normalizeRuntimeSubscription(payload);
1111
+ webRuntimeSubscriptions.set(socket.id, subscription);
1112
+ if (replayMessagesToSocket(socket, subscription)) {
1113
+ return;
1114
+ }
1115
+ if (emitCachedSnapshotToSocket(socket, subscription)) {
1116
+ return;
1117
+ }
1118
+ emitCurrentRuntimeSnapshotToSocket(socket, subscription);
1119
+ });
1120
+
1121
+ socket.on('web:command', async (command: WebCommandEnvelope, callback?: (result: CliCommandResult) => void) => {
1122
+ try {
1123
+ callback?.(await routeWebCommand(command, io));
1124
+ } catch (error) {
1125
+ logRelay('error', 'web command routing failed', {
1126
+ command: command.name,
1127
+ error: relayErrorMessage(error, 'Command failed'),
1128
+ socketId: socket.id,
1129
+ targetCliId: command.targetCliId,
1130
+ targetProviderId: command.targetProviderId ?? null
1131
+ });
1132
+ callback?.({
1133
+ ok: false,
1134
+ error: error instanceof Error ? error.message : 'Command failed'
1135
+ });
1136
+ }
1137
+ });
1138
+
1139
+ socket.on(
1140
+ 'web:terminal-frame-sync',
1141
+ async (payload: TerminalFrameSyncRequestPayload, callback?: (result: TerminalFrameSyncResultPayload) => void) => {
1142
+ callback?.(await createTerminalFrameSyncResult(socket, payload));
1143
+ }
1144
+ );
1145
+
1146
+ socket.on('web:terminal-resize', (payload: TerminalResizePayload) => {
1147
+ const record = getConnectedCliRecord(payload.targetCliId);
1148
+ if (!record) {
1149
+ return;
1150
+ }
1151
+
1152
+ record.socket.emit('cli:terminal-resize', {
1153
+ targetProviderId: payload.targetProviderId,
1154
+ cols: payload.cols,
1155
+ rows: payload.rows
1156
+ } satisfies Omit<TerminalResizePayload, 'targetCliId'>);
1157
+ });
1158
+
1159
+ socket.on('disconnect', () => {
1160
+ webRuntimeSubscriptions.delete(socket.id);
1161
+ });
1162
+ });
1163
+
1164
+ await new Promise<void>((resolve) => {
1165
+ httpServer!.listen(PORT, HOST, () => {
1166
+ console.log(`socket relay listening on http://${HOST}:${PORT}`);
1167
+ resolve();
1168
+ });
1169
+ });
1170
+ }