@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.
- package/bin/pty-remote-relay.js +24 -0
- package/package.json +54 -0
- package/public/build/assets/_basePickBy-BSLVzrBb.js +1 -0
- package/public/build/assets/_baseUniq-CjVqOhCX.js +1 -0
- package/public/build/assets/arc-DNTPwzgE.js +1 -0
- package/public/build/assets/architectureDiagram-2XIMDMQ5-cs5bhtYI.js +36 -0
- package/public/build/assets/blockDiagram-WCTKOSBZ-Cq0ABSsS.js +132 -0
- package/public/build/assets/c4Diagram-IC4MRINW-BZWmi635.js +10 -0
- package/public/build/assets/channel-DaknKRU-.js +1 -0
- package/public/build/assets/chunk-4BX2VUAB-BKpTCKpU.js +1 -0
- package/public/build/assets/chunk-55IACEB6-CKic7IJy.js +1 -0
- package/public/build/assets/chunk-FMBD7UC4-DkDwvv08.js +15 -0
- package/public/build/assets/chunk-JSJVCQXG-DyHske-D.js +1 -0
- package/public/build/assets/chunk-KX2RTZJC-BHhZdn7R.js +1 -0
- package/public/build/assets/chunk-NQ4KR5QH-CMkarP7M.js +220 -0
- package/public/build/assets/chunk-QZHKN3VN-2tloC7Ya.js +1 -0
- package/public/build/assets/chunk-WL4C6EOR-CXbcMZje.js +189 -0
- package/public/build/assets/classDiagram-VBA2DB6C-CAPxr6Mj.js +1 -0
- package/public/build/assets/classDiagram-v2-RAHNMMFH-CAPxr6Mj.js +1 -0
- package/public/build/assets/clone-DGE_el-r.js +1 -0
- package/public/build/assets/cose-bilkent-S5V4N54A-C_lbjWei.js +1 -0
- package/public/build/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/public/build/assets/dagre-KLK3FWXG-nRhxcUNz.js +4 -0
- package/public/build/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/public/build/assets/diagram-E7M64L7V-wMDgG-Tz.js +24 -0
- package/public/build/assets/diagram-IFDJBPK2-BQ0Ju-S3.js +43 -0
- package/public/build/assets/diagram-P4PSJMXO-C-WVz2po.js +24 -0
- package/public/build/assets/erDiagram-INFDFZHY-BDkf9Or-.js +70 -0
- package/public/build/assets/flowDiagram-PKNHOUZH-y9mpFLOt.js +162 -0
- package/public/build/assets/ganttDiagram-A5KZAMGK-FXibpUAU.js +292 -0
- package/public/build/assets/gitGraphDiagram-K3NZZRJ6-D1c4ie6p.js +65 -0
- package/public/build/assets/graph-CTV1hGZn.js +1 -0
- package/public/build/assets/index-B002QPfr.js +42 -0
- package/public/build/assets/index-CQO8fMLA.css +1 -0
- package/public/build/assets/index-Cr5L7diS.js +2 -0
- package/public/build/assets/infoDiagram-LFFYTUFH-DwCujEd2.js +2 -0
- package/public/build/assets/init-Gi6I4Gst.js +1 -0
- package/public/build/assets/ishikawaDiagram-PHBUUO56-Bwx1BVdH.js +70 -0
- package/public/build/assets/journeyDiagram-4ABVD52K-IMq7PvWc.js +139 -0
- package/public/build/assets/kanban-definition-K7BYSVSG-DELVXLkO.js +89 -0
- package/public/build/assets/katex-C-M49wc6.js +261 -0
- package/public/build/assets/layout-CJ0CPRFk.js +1 -0
- package/public/build/assets/linear-CDCvsSqR.js +1 -0
- package/public/build/assets/mermaid.core-BR-iWEq5.js +249 -0
- package/public/build/assets/mindmap-definition-YRQLILUH-eBbDXLEj.js +68 -0
- package/public/build/assets/ordinal-Cboi1Yqb.js +1 -0
- package/public/build/assets/pieDiagram-SKSYHLDU-DI6G2WkI.js +30 -0
- package/public/build/assets/quadrantDiagram-337W2JSQ-D-jE0cRm.js +7 -0
- package/public/build/assets/requirementDiagram-Z7DCOOCP-moUfEpIX.js +73 -0
- package/public/build/assets/sankeyDiagram-WA2Y5GQK-UdfaFey7.js +10 -0
- package/public/build/assets/sequenceDiagram-2WXFIKYE-Bt4P4tZ8.js +145 -0
- package/public/build/assets/stateDiagram-RAJIS63D-Br4zliPQ.js +1 -0
- package/public/build/assets/stateDiagram-v2-FVOUBMTO-DtPzp4lF.js +1 -0
- package/public/build/assets/timeline-definition-YZTLITO2-Bbyk_oDc.js +61 -0
- package/public/build/assets/treemap-KZPCXAKY-GvvLCih7.js +162 -0
- package/public/build/assets/vennDiagram-LZ73GAT5-hKaQHCSS.js +34 -0
- package/public/build/assets/xychartDiagram-JWTSCODW-KCSeWg4r.js +7 -0
- package/public/build/index.html +13 -0
- package/relay.conf +19 -0
- package/src/socket/relay-config.ts +102 -0
- package/src/socket/server.ts +1170 -0
- 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
|
+
}
|