@nowcrew/daemon 0.6.13 → 0.6.15
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/dist/prompt.js +15 -8
- package/package.json +1 -1
- package/dist/remote/claude-bridge.js +0 -558
- package/dist/remote/claude-channel.js +0 -164
- package/dist/remote/codex-client.js +0 -451
- package/dist/remote/codex-runtime.js +0 -77
- package/dist/remote/config.js +0 -135
- package/dist/remote/gateway.js +0 -879
- package/dist/remote/identity.js +0 -39
- package/dist/remote/owner.js +0 -77
- package/dist/remote/protocol.js +0 -211
- package/dist/remote/remote-cli.js +0 -254
- package/dist/remote/runtime-probe.js +0 -182
- package/dist/remote/session-discovery.js +0 -249
- package/dist/remote/wrapper.js +0 -40
- package/dist/remote-web/assets/index-B_6VM_tw.js +0 -94
- package/dist/remote-web/assets/index-L6EiQbJn.css +0 -1
- package/dist/remote-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/remote-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/remote-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/remote-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/remote-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/remote-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/remote-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/remote-web/icons/nowwork-192.png +0 -0
- package/dist/remote-web/icons/nowwork-512.png +0 -0
- package/dist/remote-web/icons/nowwork.svg +0 -7
- package/dist/remote-web/index.html +0 -20
- package/dist/remote-web/manifest.webmanifest +0 -13
- package/dist/remote-web/sw.js +0 -12
package/dist/remote/gateway.js
DELETED
|
@@ -1,879 +0,0 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import { access, readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { extname, join, normalize } from "node:path";
|
|
6
|
-
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
8
|
-
import { signGatewayIdentity } from "./identity.js";
|
|
9
|
-
import { ChannelToGatewaySchema, GatewayToChannelSchema, MAX_REMOTE_WEBSOCKET_BUFFER_BYTES, REMOTE_PROTOCOL_VERSION, ResolveRemoteApprovalSchema, ResolveRemoteQuestionSchema, SubmitRemoteInputSchema, } from "./protocol.js";
|
|
10
|
-
import { RemoteSessionDiscovery } from "./session-discovery.js";
|
|
11
|
-
const MAX_BODY_BYTES = 96 * 1024;
|
|
12
|
-
const MAX_DEDUP_ENTRIES = 1_000;
|
|
13
|
-
const MAX_PENDING_CHANNEL_DELIVERIES = 32;
|
|
14
|
-
const MAX_RECENT_TIMELINE_ITEMS = 1_000;
|
|
15
|
-
const MAX_RECENT_TIMELINE_BYTES = 4 * 1024 * 1024;
|
|
16
|
-
const MAX_TIMELINE_SNAPSHOT_ITEMS = 400;
|
|
17
|
-
const MAX_CLAUDE_MESSAGE_MATCH_DISTANCE_MS = 24 * 60 * 60 * 1_000;
|
|
18
|
-
const DEFAULT_CHANNEL_DELIVERY_TIMEOUT_MS = 2_000;
|
|
19
|
-
const WS_PROTOCOL = "nowcrew.remote.v2";
|
|
20
|
-
const SESSION_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)(?:\/(timeline|input))?$/i;
|
|
21
|
-
const APPROVAL_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)\/approvals\/([A-Za-z0-9_-]+)$/;
|
|
22
|
-
const QUESTION_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)\/questions\/([0-9a-f-]+)$/i;
|
|
23
|
-
function pendingInteractionKey(sessionId, requestId) {
|
|
24
|
-
return `${sessionId}:${requestId}`;
|
|
25
|
-
}
|
|
26
|
-
function inputDedupKey(runtime, sessionId, idempotencyKey) {
|
|
27
|
-
return `${runtime}:${sessionId}:${idempotencyKey}`;
|
|
28
|
-
}
|
|
29
|
-
function timelineCacheKey(runtime, sessionId, itemId) {
|
|
30
|
-
return `${runtime}:${sessionId}:${itemId}`;
|
|
31
|
-
}
|
|
32
|
-
function parseDeliverableChannelFrame(value) {
|
|
33
|
-
const frame = GatewayToChannelSchema.parse(value);
|
|
34
|
-
if (frame.type === "channel.registered")
|
|
35
|
-
throw new Error("Registration acknowledgements are not deliveries");
|
|
36
|
-
return frame;
|
|
37
|
-
}
|
|
38
|
-
function json(response, status, value) {
|
|
39
|
-
const body = JSON.stringify(value);
|
|
40
|
-
response.writeHead(status, {
|
|
41
|
-
"content-type": "application/json; charset=utf-8",
|
|
42
|
-
"content-length": Buffer.byteLength(body),
|
|
43
|
-
"cache-control": "no-store",
|
|
44
|
-
"x-content-type-options": "nosniff",
|
|
45
|
-
});
|
|
46
|
-
response.end(body);
|
|
47
|
-
}
|
|
48
|
-
function bearer(request) {
|
|
49
|
-
const value = request.headers.authorization;
|
|
50
|
-
return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : null;
|
|
51
|
-
}
|
|
52
|
-
function tokenMatches(expected, actual) {
|
|
53
|
-
if (actual === null)
|
|
54
|
-
return false;
|
|
55
|
-
const left = Buffer.from(expected);
|
|
56
|
-
const right = Buffer.from(actual);
|
|
57
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
58
|
-
}
|
|
59
|
-
function websocketToken(request) {
|
|
60
|
-
const auth = bearer(request);
|
|
61
|
-
if (auth)
|
|
62
|
-
return auth;
|
|
63
|
-
const values = String(request.headers["sec-websocket-protocol"] ?? "")
|
|
64
|
-
.split(",")
|
|
65
|
-
.map((value) => value.trim());
|
|
66
|
-
const encoded = values.find((value) => value.startsWith("auth."))?.slice("auth.".length);
|
|
67
|
-
if (!encoded)
|
|
68
|
-
return null;
|
|
69
|
-
try {
|
|
70
|
-
return Buffer.from(encoded, "base64url").toString("utf8");
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function allowedOrigin(request, config) {
|
|
77
|
-
const origin = request.headers.origin;
|
|
78
|
-
if (!origin)
|
|
79
|
-
return true;
|
|
80
|
-
if (config.allowedOrigins.includes(origin))
|
|
81
|
-
return true;
|
|
82
|
-
const host = request.headers.host;
|
|
83
|
-
if (!host)
|
|
84
|
-
return false;
|
|
85
|
-
return origin === `http://${host}` || origin === `https://${host}`;
|
|
86
|
-
}
|
|
87
|
-
async function readJsonBody(request) {
|
|
88
|
-
const chunks = [];
|
|
89
|
-
let bytes = 0;
|
|
90
|
-
for await (const chunk of request) {
|
|
91
|
-
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
92
|
-
bytes += value.length;
|
|
93
|
-
if (bytes > MAX_BODY_BYTES)
|
|
94
|
-
throw new RangeError("request body too large");
|
|
95
|
-
chunks.push(value);
|
|
96
|
-
}
|
|
97
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
98
|
-
}
|
|
99
|
-
function mime(path) {
|
|
100
|
-
switch (extname(path)) {
|
|
101
|
-
case ".html": return "text/html; charset=utf-8";
|
|
102
|
-
case ".js": return "text/javascript; charset=utf-8";
|
|
103
|
-
case ".css": return "text/css; charset=utf-8";
|
|
104
|
-
case ".svg": return "image/svg+xml";
|
|
105
|
-
case ".png": return "image/png";
|
|
106
|
-
case ".woff2": return "font/woff2";
|
|
107
|
-
case ".json": return "application/json; charset=utf-8";
|
|
108
|
-
case ".webmanifest": return "application/manifest+json";
|
|
109
|
-
default: return "application/octet-stream";
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
async function serveStatic(response, uiDir, pathname) {
|
|
113
|
-
const relative = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
114
|
-
const normalized = normalize(relative).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
115
|
-
let path = join(uiDir, normalized);
|
|
116
|
-
try {
|
|
117
|
-
const info = await stat(path);
|
|
118
|
-
if (!info.isFile())
|
|
119
|
-
throw new Error("not a file");
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
path = join(uiDir, "index.html");
|
|
123
|
-
try {
|
|
124
|
-
await access(path);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
const info = await stat(path);
|
|
131
|
-
const revalidate = path.endsWith("index.html")
|
|
132
|
-
|| path.endsWith("sw.js")
|
|
133
|
-
|| path.endsWith("manifest.webmanifest");
|
|
134
|
-
response.writeHead(200, {
|
|
135
|
-
"content-type": mime(path),
|
|
136
|
-
"content-length": info.size,
|
|
137
|
-
"cache-control": revalidate ? "no-cache" : "public, max-age=31536000, immutable",
|
|
138
|
-
"x-content-type-options": "nosniff",
|
|
139
|
-
"content-security-policy": "default-src 'self'; connect-src 'self' ws: wss:; font-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
140
|
-
});
|
|
141
|
-
createReadStream(path).pipe(response);
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
function websocketSend(socket, value) {
|
|
145
|
-
if (socket.readyState !== WebSocket.OPEN)
|
|
146
|
-
return Promise.resolve(false);
|
|
147
|
-
const serialized = JSON.stringify(value);
|
|
148
|
-
if (socket.bufferedAmount + Buffer.byteLength(serialized, "utf8") > MAX_REMOTE_WEBSOCKET_BUFFER_BYTES) {
|
|
149
|
-
socket.close(1013, "outbound buffer limit");
|
|
150
|
-
return Promise.resolve(false);
|
|
151
|
-
}
|
|
152
|
-
return new Promise((resolve) => {
|
|
153
|
-
try {
|
|
154
|
-
socket.send(serialized, (error) => resolve(error === undefined || error === null));
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
resolve(false);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
export async function startRemoteGateway(options) {
|
|
162
|
-
const { config } = options;
|
|
163
|
-
const gatewayId = options.gatewayId ?? randomUUID();
|
|
164
|
-
const activeClaudeConnections = new Map();
|
|
165
|
-
const activeClaudeView = new Map();
|
|
166
|
-
const pendingApprovals = new Map();
|
|
167
|
-
const pendingQuestions = new Map();
|
|
168
|
-
const acceptedInputs = new Map();
|
|
169
|
-
const inputsInFlight = new Set();
|
|
170
|
-
const interactionsInFlight = new Set();
|
|
171
|
-
const pendingChannelDeliveries = new Map();
|
|
172
|
-
const recentTimelineItems = new Map();
|
|
173
|
-
let recentTimelineBytes = 0;
|
|
174
|
-
const clientSockets = new Set();
|
|
175
|
-
const discovery = options.discovery ?? new RemoteSessionDiscovery({
|
|
176
|
-
claudeProjectsRoot: options.claudeProjectsRoot ?? join(homedir(), ".claude", "projects"),
|
|
177
|
-
codexSessionsRoot: options.codexSessionsRoot ?? join(homedir(), ".codex", "sessions"),
|
|
178
|
-
activeClaude: activeClaudeView,
|
|
179
|
-
...(options.runtimeStates === undefined ? {} : { runtimeStates: options.runtimeStates }),
|
|
180
|
-
});
|
|
181
|
-
const removeCachedTimelineItem = (key) => {
|
|
182
|
-
const cached = recentTimelineItems.get(key);
|
|
183
|
-
if (!cached)
|
|
184
|
-
return;
|
|
185
|
-
recentTimelineItems.delete(key);
|
|
186
|
-
recentTimelineBytes -= cached.bytes;
|
|
187
|
-
};
|
|
188
|
-
const cacheTimelineItem = (runtime, sessionId, item) => {
|
|
189
|
-
const key = timelineCacheKey(runtime, sessionId, item.id);
|
|
190
|
-
removeCachedTimelineItem(key);
|
|
191
|
-
const bytes = Buffer.byteLength(JSON.stringify(item), "utf8");
|
|
192
|
-
recentTimelineItems.set(key, { runtime, sessionId, item, bytes });
|
|
193
|
-
recentTimelineBytes += bytes;
|
|
194
|
-
while (recentTimelineItems.size > MAX_RECENT_TIMELINE_ITEMS
|
|
195
|
-
|| recentTimelineBytes > MAX_RECENT_TIMELINE_BYTES) {
|
|
196
|
-
const oldest = recentTimelineItems.keys().next().value;
|
|
197
|
-
if (!oldest)
|
|
198
|
-
break;
|
|
199
|
-
removeCachedTimelineItem(oldest);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
const removeCachedInteraction = (runtime, sessionId, requestId) => {
|
|
203
|
-
for (const [key, cached] of recentTimelineItems) {
|
|
204
|
-
if (cached.runtime !== runtime || cached.sessionId !== sessionId)
|
|
205
|
-
continue;
|
|
206
|
-
if ((cached.item.kind === "approval" || cached.item.kind === "question")
|
|
207
|
-
&& cached.item.requestId === requestId)
|
|
208
|
-
removeCachedTimelineItem(key);
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
const timelineSnapshot = (runtime, sessionId, durable) => {
|
|
212
|
-
const merged = new Map(durable.map((item) => [item.id, item]));
|
|
213
|
-
const matchedDurableMessages = new Set();
|
|
214
|
-
for (const cached of recentTimelineItems.values()) {
|
|
215
|
-
if (cached.runtime !== runtime || cached.sessionId !== sessionId)
|
|
216
|
-
continue;
|
|
217
|
-
if (runtime === "claude" && cached.item.kind === "message" && cached.item.role === "assistant") {
|
|
218
|
-
const cachedAt = Date.parse(cached.item.createdAt);
|
|
219
|
-
let nearest = null;
|
|
220
|
-
for (const item of durable) {
|
|
221
|
-
if (item.kind !== "message" || matchedDurableMessages.has(item.id))
|
|
222
|
-
continue;
|
|
223
|
-
if (item.role !== cached.item.role || item.text !== cached.item.text)
|
|
224
|
-
continue;
|
|
225
|
-
const distance = Math.abs(Date.parse(item.createdAt) - cachedAt);
|
|
226
|
-
if (!Number.isFinite(distance) || distance > MAX_CLAUDE_MESSAGE_MATCH_DISTANCE_MS)
|
|
227
|
-
continue;
|
|
228
|
-
if (!nearest || distance < nearest.distance)
|
|
229
|
-
nearest = { id: item.id, distance };
|
|
230
|
-
}
|
|
231
|
-
if (nearest) {
|
|
232
|
-
matchedDurableMessages.add(nearest.id);
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
merged.delete(cached.item.id);
|
|
237
|
-
merged.set(cached.item.id, cached.item);
|
|
238
|
-
}
|
|
239
|
-
return [...merged.values()]
|
|
240
|
-
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
241
|
-
.slice(-MAX_TIMELINE_SNAPSHOT_ITEMS);
|
|
242
|
-
};
|
|
243
|
-
const broadcast = (event) => {
|
|
244
|
-
if (event.type === "timeline.appended")
|
|
245
|
-
cacheTimelineItem(event.runtime, event.sessionId, event.item);
|
|
246
|
-
for (const socket of clientSockets)
|
|
247
|
-
void websocketSend(socket, event);
|
|
248
|
-
};
|
|
249
|
-
const sessionsChanged = () => broadcast({
|
|
250
|
-
type: "sessions.changed",
|
|
251
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
252
|
-
});
|
|
253
|
-
const interactionResolved = (runtime, sessionId, requestId) => {
|
|
254
|
-
removeCachedInteraction(runtime, sessionId, requestId);
|
|
255
|
-
broadcast({
|
|
256
|
-
type: "interaction.resolved",
|
|
257
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
258
|
-
runtime,
|
|
259
|
-
sessionId,
|
|
260
|
-
requestId,
|
|
261
|
-
});
|
|
262
|
-
};
|
|
263
|
-
const clearClaudeInteractions = (sessionId) => {
|
|
264
|
-
for (const [key, pending] of pendingApprovals) {
|
|
265
|
-
if (pending.sessionId !== sessionId)
|
|
266
|
-
continue;
|
|
267
|
-
pendingApprovals.delete(key);
|
|
268
|
-
interactionResolved("claude", sessionId, pending.item.requestId);
|
|
269
|
-
}
|
|
270
|
-
for (const [key, pending] of pendingQuestions) {
|
|
271
|
-
if (pending.sessionId !== sessionId)
|
|
272
|
-
continue;
|
|
273
|
-
pendingQuestions.delete(key);
|
|
274
|
-
interactionResolved("claude", sessionId, pending.item.requestId);
|
|
275
|
-
}
|
|
276
|
-
};
|
|
277
|
-
const settleChannelDelivery = (deliveryId, status) => {
|
|
278
|
-
const pending = pendingChannelDeliveries.get(deliveryId);
|
|
279
|
-
if (!pending)
|
|
280
|
-
return;
|
|
281
|
-
pendingChannelDeliveries.delete(deliveryId);
|
|
282
|
-
clearTimeout(pending.timer);
|
|
283
|
-
pending.resolve(status);
|
|
284
|
-
};
|
|
285
|
-
const failChannelDeliveries = (sessionId, generation) => {
|
|
286
|
-
for (const [deliveryId, pending] of pendingChannelDeliveries) {
|
|
287
|
-
if (pending.sessionId === sessionId && pending.generation === generation) {
|
|
288
|
-
settleChannelDelivery(deliveryId, "disconnected");
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
const deliverToClaude = (connection, frame) => {
|
|
293
|
-
const current = activeClaudeConnections.get(connection.sessionId);
|
|
294
|
-
if (!current || current.socket !== connection.socket || current.generation !== connection.generation) {
|
|
295
|
-
return Promise.resolve("disconnected");
|
|
296
|
-
}
|
|
297
|
-
const pendingCount = [...pendingChannelDeliveries.values()]
|
|
298
|
-
.filter((pending) => pending.sessionId === connection.sessionId
|
|
299
|
-
&& pending.generation === connection.generation).length;
|
|
300
|
-
if (pendingCount >= MAX_PENDING_CHANNEL_DELIVERIES)
|
|
301
|
-
return Promise.resolve("queue_full");
|
|
302
|
-
return new Promise((resolve) => {
|
|
303
|
-
const timer = setTimeout(() => settleChannelDelivery(frame.deliveryId, "unconfirmed"), options.channelDeliveryTimeoutMs ?? DEFAULT_CHANNEL_DELIVERY_TIMEOUT_MS);
|
|
304
|
-
pendingChannelDeliveries.set(frame.deliveryId, {
|
|
305
|
-
sessionId: connection.sessionId,
|
|
306
|
-
generation: connection.generation,
|
|
307
|
-
timer,
|
|
308
|
-
resolve,
|
|
309
|
-
});
|
|
310
|
-
void websocketSend(connection.socket, frame).then((sent) => {
|
|
311
|
-
if (!sent)
|
|
312
|
-
settleChannelDelivery(frame.deliveryId, "disconnected");
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
};
|
|
316
|
-
const respondForDeliveryFailure = (response, status) => {
|
|
317
|
-
if (status === "accepted")
|
|
318
|
-
return false;
|
|
319
|
-
if (status === "queue_full")
|
|
320
|
-
json(response, 429, { error: "queue_full" });
|
|
321
|
-
else if (status === "unconfirmed")
|
|
322
|
-
json(response, 504, { error: "delivery_unconfirmed" });
|
|
323
|
-
else if (status === "not_pending")
|
|
324
|
-
json(response, 409, { error: "interaction_not_pending" });
|
|
325
|
-
else
|
|
326
|
-
json(response, 409, { error: "session_not_live" });
|
|
327
|
-
return true;
|
|
328
|
-
};
|
|
329
|
-
const listSessions = async () => {
|
|
330
|
-
const history = await discovery.list();
|
|
331
|
-
if (!options.codex || options.runtimeStates?.codex)
|
|
332
|
-
return history;
|
|
333
|
-
let live;
|
|
334
|
-
try {
|
|
335
|
-
live = await options.codex.listSessions();
|
|
336
|
-
}
|
|
337
|
-
catch {
|
|
338
|
-
return history;
|
|
339
|
-
}
|
|
340
|
-
const merged = new Map(history.map((session) => [`${session.runtime}:${session.id}`, session]));
|
|
341
|
-
for (const session of live)
|
|
342
|
-
merged.set(`codex:${session.id}`, session);
|
|
343
|
-
return [...merged.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 200);
|
|
344
|
-
};
|
|
345
|
-
const unsubscribeCodex = options.codex?.onEvent((event) => {
|
|
346
|
-
if (event.type === "sessions.changed")
|
|
347
|
-
sessionsChanged();
|
|
348
|
-
else
|
|
349
|
-
broadcast({ ...event, runtime: "codex", protocolVersion: REMOTE_PROTOCOL_VERSION });
|
|
350
|
-
});
|
|
351
|
-
const server = createServer((request, response) => {
|
|
352
|
-
void (async () => {
|
|
353
|
-
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
354
|
-
if (request.method === "GET" && url.pathname === "/api/v1/health") {
|
|
355
|
-
json(response, 200, { ok: true, protocolVersion: REMOTE_PROTOCOL_VERSION });
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
if (request.method === "GET" && url.pathname === "/api/v1/identity") {
|
|
359
|
-
const nonce = url.searchParams.get("nonce");
|
|
360
|
-
if (!nonce) {
|
|
361
|
-
json(response, 400, { error: "identity_nonce_required" });
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
if (!options.identityKey) {
|
|
365
|
-
json(response, 503, { error: "identity_unavailable" });
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
const identity = { gatewayId, pid: process.pid, protocolVersion: REMOTE_PROTOCOL_VERSION };
|
|
369
|
-
try {
|
|
370
|
-
json(response, 200, {
|
|
371
|
-
...identity,
|
|
372
|
-
proof: signGatewayIdentity(options.identityKey, nonce, identity),
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
catch {
|
|
376
|
-
json(response, 400, { error: "invalid_identity_nonce" });
|
|
377
|
-
}
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (url.pathname.startsWith("/api/")) {
|
|
381
|
-
if (!tokenMatches(config.token, bearer(request))) {
|
|
382
|
-
json(response, 401, { error: "unauthorized" });
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (!["GET", "HEAD"].includes(request.method ?? "") && !allowedOrigin(request, config)) {
|
|
386
|
-
json(response, 403, { error: "origin_not_allowed" });
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
if (request.method === "GET" && url.pathname === "/api/v1/sessions") {
|
|
391
|
-
json(response, 200, { sessions: await listSessions() });
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const approvalMatch = APPROVAL_PATH_RE.exec(url.pathname);
|
|
395
|
-
if (request.method === "POST" && approvalMatch) {
|
|
396
|
-
const runtime = approvalMatch[1].toLowerCase();
|
|
397
|
-
const sessionId = approvalMatch[2];
|
|
398
|
-
const requestId = approvalMatch[3];
|
|
399
|
-
const body = ResolveRemoteApprovalSchema.parse(await readJsonBody(request));
|
|
400
|
-
if (runtime === "codex") {
|
|
401
|
-
if (!options.codex?.resolveApproval(sessionId, requestId, body.decision)) {
|
|
402
|
-
json(response, 409, { error: "approval_not_pending" });
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
interactionResolved("codex", sessionId, requestId);
|
|
406
|
-
json(response, 202, { accepted: true });
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
const pendingKey = pendingInteractionKey(sessionId, requestId);
|
|
410
|
-
const pending = pendingApprovals.get(pendingKey);
|
|
411
|
-
if (!pending || pending.sessionId !== sessionId) {
|
|
412
|
-
json(response, 409, { error: "approval_not_pending" });
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const interactionKey = `approval:${sessionId}:${requestId}`;
|
|
416
|
-
if (interactionsInFlight.has(interactionKey)) {
|
|
417
|
-
json(response, 409, { error: "approval_in_progress" });
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
const connection = activeClaudeConnections.get(sessionId);
|
|
421
|
-
if (!connection) {
|
|
422
|
-
json(response, 409, { error: "session_not_live" });
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
interactionsInFlight.add(interactionKey);
|
|
426
|
-
try {
|
|
427
|
-
const deliveryId = randomUUID();
|
|
428
|
-
const delivery = await deliverToClaude(connection, parseDeliverableChannelFrame(pending.bridge ? {
|
|
429
|
-
type: "bridge.approval_response",
|
|
430
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
431
|
-
deliveryId,
|
|
432
|
-
requestId,
|
|
433
|
-
decision: body.decision,
|
|
434
|
-
} : {
|
|
435
|
-
type: "channel.permission_response",
|
|
436
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
437
|
-
deliveryId,
|
|
438
|
-
requestId,
|
|
439
|
-
decision: body.decision,
|
|
440
|
-
}));
|
|
441
|
-
if (delivery === "not_pending") {
|
|
442
|
-
pendingApprovals.delete(pendingKey);
|
|
443
|
-
interactionResolved("claude", sessionId, requestId);
|
|
444
|
-
}
|
|
445
|
-
if (respondForDeliveryFailure(response, delivery))
|
|
446
|
-
return;
|
|
447
|
-
pendingApprovals.delete(pendingKey);
|
|
448
|
-
interactionResolved("claude", sessionId, requestId);
|
|
449
|
-
json(response, 202, { accepted: true });
|
|
450
|
-
}
|
|
451
|
-
finally {
|
|
452
|
-
interactionsInFlight.delete(interactionKey);
|
|
453
|
-
}
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
const questionMatch = QUESTION_PATH_RE.exec(url.pathname);
|
|
457
|
-
if (request.method === "POST" && questionMatch) {
|
|
458
|
-
const runtime = questionMatch[1].toLowerCase();
|
|
459
|
-
const sessionId = questionMatch[2];
|
|
460
|
-
const requestId = questionMatch[3];
|
|
461
|
-
const body = ResolveRemoteQuestionSchema.parse(await readJsonBody(request));
|
|
462
|
-
if (runtime === "codex") {
|
|
463
|
-
if (!options.codex?.resolveQuestion(sessionId, requestId, body)) {
|
|
464
|
-
json(response, 409, { error: "question_not_pending" });
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
interactionResolved("codex", sessionId, requestId);
|
|
468
|
-
json(response, 202, { accepted: true });
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
const pendingKey = pendingInteractionKey(sessionId, requestId);
|
|
472
|
-
const pending = pendingQuestions.get(pendingKey);
|
|
473
|
-
const connection = activeClaudeConnections.get(sessionId);
|
|
474
|
-
if (!pending || pending.sessionId !== sessionId || !connection) {
|
|
475
|
-
json(response, 409, { error: "question_not_pending" });
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const interactionKey = `question:${sessionId}:${requestId}`;
|
|
479
|
-
if (interactionsInFlight.has(interactionKey)) {
|
|
480
|
-
json(response, 409, { error: "question_in_progress" });
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
interactionsInFlight.add(interactionKey);
|
|
484
|
-
try {
|
|
485
|
-
const delivery = await deliverToClaude(connection, parseDeliverableChannelFrame({
|
|
486
|
-
type: "bridge.question_response",
|
|
487
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
488
|
-
deliveryId: randomUUID(),
|
|
489
|
-
requestId,
|
|
490
|
-
answers: body.answers,
|
|
491
|
-
}));
|
|
492
|
-
if (delivery === "not_pending") {
|
|
493
|
-
pendingQuestions.delete(pendingKey);
|
|
494
|
-
interactionResolved("claude", sessionId, requestId);
|
|
495
|
-
}
|
|
496
|
-
if (respondForDeliveryFailure(response, delivery))
|
|
497
|
-
return;
|
|
498
|
-
pendingQuestions.delete(pendingKey);
|
|
499
|
-
interactionResolved("claude", sessionId, requestId);
|
|
500
|
-
json(response, 202, { accepted: true });
|
|
501
|
-
}
|
|
502
|
-
finally {
|
|
503
|
-
interactionsInFlight.delete(interactionKey);
|
|
504
|
-
}
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
const match = SESSION_PATH_RE.exec(url.pathname);
|
|
508
|
-
if (request.method === "GET" && match?.[3] === "timeline") {
|
|
509
|
-
const runtime = match[1].toLowerCase();
|
|
510
|
-
const discovered = runtime === "codex" && options.codex
|
|
511
|
-
? await options.codex.timeline(match[2])
|
|
512
|
-
: await discovery.timeline(runtime, match[2]);
|
|
513
|
-
if (discovered === null) {
|
|
514
|
-
json(response, 404, { error: "session_not_found" });
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
const items = runtime === "claude"
|
|
518
|
-
? [
|
|
519
|
-
...discovered,
|
|
520
|
-
...[...pendingApprovals.values()].filter((pending) => pending.sessionId === match[2]).map((pending) => pending.item),
|
|
521
|
-
...[...pendingQuestions.values()].filter((pending) => pending.sessionId === match[2]).map((pending) => pending.item),
|
|
522
|
-
]
|
|
523
|
-
: discovered;
|
|
524
|
-
json(response, 200, { items: timelineSnapshot(runtime, match[2], items) });
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
if (request.method === "POST" && match?.[3] === "input") {
|
|
528
|
-
const runtime = match[1].toLowerCase();
|
|
529
|
-
const sessionId = match[2];
|
|
530
|
-
const degradedState = options.runtimeStates?.[runtime];
|
|
531
|
-
if (degradedState) {
|
|
532
|
-
json(response, 501, { error: `runtime_${degradedState}` });
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (runtime !== "claude" && !options.codex) {
|
|
536
|
-
json(response, 501, { error: "runtime_not_connected" });
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
const input = SubmitRemoteInputSchema.parse(await readJsonBody(request));
|
|
540
|
-
const dedupKey = inputDedupKey(runtime, sessionId, input.idempotencyKey);
|
|
541
|
-
const prior = acceptedInputs.get(dedupKey);
|
|
542
|
-
if (prior) {
|
|
543
|
-
json(response, 202, { accepted: true, requestId: prior.requestId, duplicate: true });
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
if (inputsInFlight.has(dedupKey)) {
|
|
547
|
-
json(response, 409, { error: "input_in_progress" });
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
const connection = runtime === "claude" ? activeClaudeConnections.get(sessionId) : undefined;
|
|
551
|
-
if (runtime === "claude" && !connection) {
|
|
552
|
-
json(response, 409, { error: "session_not_live" });
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
const accepted = {
|
|
556
|
-
requestId: input.idempotencyKey,
|
|
557
|
-
sessionId,
|
|
558
|
-
acceptedAt: new Date().toISOString(),
|
|
559
|
-
};
|
|
560
|
-
inputsInFlight.add(dedupKey);
|
|
561
|
-
try {
|
|
562
|
-
if (runtime === "claude") {
|
|
563
|
-
const delivery = await deliverToClaude(connection, parseDeliverableChannelFrame({
|
|
564
|
-
type: "channel.prompt",
|
|
565
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
566
|
-
deliveryId: randomUUID(),
|
|
567
|
-
requestId: accepted.requestId,
|
|
568
|
-
text: input.text,
|
|
569
|
-
}));
|
|
570
|
-
if (respondForDeliveryFailure(response, delivery))
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
await options.codex.submit(sessionId, input);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
finally {
|
|
578
|
-
inputsInFlight.delete(dedupKey);
|
|
579
|
-
}
|
|
580
|
-
acceptedInputs.set(dedupKey, accepted);
|
|
581
|
-
while (acceptedInputs.size > MAX_DEDUP_ENTRIES) {
|
|
582
|
-
const oldest = acceptedInputs.keys().next().value;
|
|
583
|
-
if (!oldest)
|
|
584
|
-
break;
|
|
585
|
-
acceptedInputs.delete(oldest);
|
|
586
|
-
}
|
|
587
|
-
if (runtime === "claude") {
|
|
588
|
-
broadcast({
|
|
589
|
-
type: "timeline.appended",
|
|
590
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
591
|
-
runtime,
|
|
592
|
-
sessionId,
|
|
593
|
-
item: {
|
|
594
|
-
id: accepted.requestId,
|
|
595
|
-
kind: "message",
|
|
596
|
-
role: "user",
|
|
597
|
-
text: input.text,
|
|
598
|
-
createdAt: accepted.acceptedAt,
|
|
599
|
-
},
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
json(response, 202, { accepted: true, requestId: accepted.requestId, duplicate: false });
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
if (url.pathname.startsWith("/api/")) {
|
|
606
|
-
json(response, 404, { error: "not_found" });
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
if (request.method === "GET" && options.uiDir && await serveStatic(response, options.uiDir, url.pathname))
|
|
610
|
-
return;
|
|
611
|
-
json(response, 404, { error: "not_found" });
|
|
612
|
-
})().catch((error) => {
|
|
613
|
-
if (response.headersSent) {
|
|
614
|
-
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
const message = error instanceof SyntaxError ? "invalid_json"
|
|
618
|
-
: error instanceof RangeError ? "request_too_large"
|
|
619
|
-
: "invalid_request";
|
|
620
|
-
json(response, message === "request_too_large" ? 413 : 400, { error: message });
|
|
621
|
-
});
|
|
622
|
-
});
|
|
623
|
-
const selectProtocol = (protocols) => protocols.has(WS_PROTOCOL) ? WS_PROTOCOL : false;
|
|
624
|
-
const clientWss = new WebSocketServer({
|
|
625
|
-
noServer: true,
|
|
626
|
-
maxPayload: MAX_BODY_BYTES,
|
|
627
|
-
handleProtocols: selectProtocol,
|
|
628
|
-
});
|
|
629
|
-
const channelWss = new WebSocketServer({
|
|
630
|
-
noServer: true,
|
|
631
|
-
maxPayload: MAX_BODY_BYTES,
|
|
632
|
-
handleProtocols: selectProtocol,
|
|
633
|
-
});
|
|
634
|
-
server.on("upgrade", (request, socket, head) => {
|
|
635
|
-
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
636
|
-
if (!tokenMatches(config.token, websocketToken(request)) || !allowedOrigin(request, config)) {
|
|
637
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
638
|
-
socket.destroy();
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
const wss = url.pathname === "/ws/client" ? clientWss
|
|
642
|
-
: url.pathname === "/ws/channel" ? channelWss
|
|
643
|
-
: null;
|
|
644
|
-
if (!wss) {
|
|
645
|
-
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
646
|
-
socket.destroy();
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
650
|
-
wss.emit("connection", ws, request);
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
clientWss.on("connection", (socket) => {
|
|
654
|
-
clientSockets.add(socket);
|
|
655
|
-
socket.on("close", () => clientSockets.delete(socket));
|
|
656
|
-
});
|
|
657
|
-
channelWss.on("connection", (socket) => {
|
|
658
|
-
let registration = null;
|
|
659
|
-
socket.on("message", (raw) => {
|
|
660
|
-
let decoded;
|
|
661
|
-
try {
|
|
662
|
-
decoded = JSON.parse(raw.toString());
|
|
663
|
-
}
|
|
664
|
-
catch {
|
|
665
|
-
socket.close(1008, "invalid JSON");
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const parsed = ChannelToGatewaySchema.safeParse(decoded);
|
|
669
|
-
if (!parsed.success) {
|
|
670
|
-
socket.close(1008, "invalid channel frame");
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
const frame = parsed.data;
|
|
674
|
-
if (frame.type === "channel.register") {
|
|
675
|
-
if (options.runtimeStates?.claude) {
|
|
676
|
-
socket.close(1008, `runtime ${options.runtimeStates.claude}`);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
if (registration) {
|
|
680
|
-
socket.close(1008, "already registered");
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
const previous = activeClaudeConnections.get(frame.sessionId);
|
|
684
|
-
if (previous && previous.socket !== socket) {
|
|
685
|
-
failChannelDeliveries(frame.sessionId, previous.generation);
|
|
686
|
-
previous.socket.close(4001, "superseded");
|
|
687
|
-
}
|
|
688
|
-
clearClaudeInteractions(frame.sessionId);
|
|
689
|
-
registration = { sessionId: frame.sessionId, generation: frame.generation };
|
|
690
|
-
const active = {
|
|
691
|
-
sessionId: frame.sessionId,
|
|
692
|
-
socket,
|
|
693
|
-
generation: frame.generation,
|
|
694
|
-
pid: frame.pid,
|
|
695
|
-
cwd: frame.cwd,
|
|
696
|
-
...(frame.title === undefined ? {} : { title: frame.title }),
|
|
697
|
-
busy: false,
|
|
698
|
-
updatedAt: new Date().toISOString(),
|
|
699
|
-
};
|
|
700
|
-
activeClaudeConnections.set(frame.sessionId, active);
|
|
701
|
-
activeClaudeView.set(frame.sessionId, {
|
|
702
|
-
cwd: active.cwd,
|
|
703
|
-
...(active.title === undefined ? {} : { title: active.title }),
|
|
704
|
-
busy: active.busy,
|
|
705
|
-
...(active.updatedAt === undefined ? {} : { updatedAt: active.updatedAt }),
|
|
706
|
-
});
|
|
707
|
-
sessionsChanged();
|
|
708
|
-
void websocketSend(socket, GatewayToChannelSchema.parse({
|
|
709
|
-
type: "channel.registered",
|
|
710
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
711
|
-
sessionId: frame.sessionId,
|
|
712
|
-
generation: frame.generation,
|
|
713
|
-
})).then((sent) => {
|
|
714
|
-
if (!sent)
|
|
715
|
-
socket.close(1011, "registration acknowledgement failed");
|
|
716
|
-
});
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
if (!registration || registration.sessionId !== frame.sessionId) {
|
|
720
|
-
socket.close(1008, "register first");
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
const current = activeClaudeConnections.get(frame.sessionId);
|
|
724
|
-
if (!current || current.socket !== socket || current.generation !== registration.generation) {
|
|
725
|
-
socket.close(4001, "stale generation");
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
if (frame.type === "channel.delivery_ack") {
|
|
729
|
-
if (frame.generation !== registration.generation) {
|
|
730
|
-
socket.close(4001, "stale generation");
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const pending = pendingChannelDeliveries.get(frame.deliveryId);
|
|
734
|
-
if (!pending || pending.sessionId !== frame.sessionId || pending.generation !== frame.generation)
|
|
735
|
-
return;
|
|
736
|
-
settleChannelDelivery(frame.deliveryId, frame.status);
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
if (frame.type === "channel.reply") {
|
|
740
|
-
if (!frame.text)
|
|
741
|
-
return;
|
|
742
|
-
const item = {
|
|
743
|
-
id: `channel-reply:${frame.requestId}`,
|
|
744
|
-
kind: "message",
|
|
745
|
-
role: "assistant",
|
|
746
|
-
text: frame.text,
|
|
747
|
-
createdAt: new Date().toISOString(),
|
|
748
|
-
};
|
|
749
|
-
broadcast({
|
|
750
|
-
type: "timeline.appended",
|
|
751
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
752
|
-
runtime: "claude",
|
|
753
|
-
sessionId: frame.sessionId,
|
|
754
|
-
item,
|
|
755
|
-
});
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
if (frame.type === "bridge.user_message") {
|
|
759
|
-
broadcast({
|
|
760
|
-
type: "timeline.appended",
|
|
761
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
762
|
-
runtime: "claude",
|
|
763
|
-
sessionId: frame.sessionId,
|
|
764
|
-
item: {
|
|
765
|
-
id: frame.messageId,
|
|
766
|
-
kind: "message",
|
|
767
|
-
role: "user",
|
|
768
|
-
text: frame.text,
|
|
769
|
-
createdAt: frame.createdAt,
|
|
770
|
-
},
|
|
771
|
-
});
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (frame.type === "bridge.status") {
|
|
775
|
-
const updated = { ...current, busy: frame.busy };
|
|
776
|
-
activeClaudeConnections.set(frame.sessionId, updated);
|
|
777
|
-
activeClaudeView.set(frame.sessionId, {
|
|
778
|
-
cwd: updated.cwd,
|
|
779
|
-
...(updated.title === undefined ? {} : { title: updated.title }),
|
|
780
|
-
busy: updated.busy,
|
|
781
|
-
...(updated.updatedAt === undefined ? {} : { updatedAt: updated.updatedAt }),
|
|
782
|
-
});
|
|
783
|
-
sessionsChanged();
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
if (frame.type === "bridge.resolved") {
|
|
787
|
-
const key = pendingInteractionKey(frame.sessionId, frame.requestId);
|
|
788
|
-
pendingApprovals.delete(key);
|
|
789
|
-
pendingQuestions.delete(key);
|
|
790
|
-
interactionResolved("claude", frame.sessionId, frame.requestId);
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
793
|
-
if (frame.type === "bridge.question") {
|
|
794
|
-
const item = {
|
|
795
|
-
id: `claude-question:${frame.requestId}`,
|
|
796
|
-
kind: "question",
|
|
797
|
-
requestId: frame.requestId,
|
|
798
|
-
questions: frame.questions,
|
|
799
|
-
createdAt: new Date().toISOString(),
|
|
800
|
-
};
|
|
801
|
-
pendingQuestions.set(pendingInteractionKey(frame.sessionId, frame.requestId), { sessionId: frame.sessionId, item });
|
|
802
|
-
broadcast({
|
|
803
|
-
type: "timeline.appended",
|
|
804
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
805
|
-
runtime: "claude",
|
|
806
|
-
sessionId: frame.sessionId,
|
|
807
|
-
item,
|
|
808
|
-
});
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
const item = {
|
|
812
|
-
id: `claude-approval:${frame.requestId}`,
|
|
813
|
-
kind: "approval",
|
|
814
|
-
requestId: frame.requestId,
|
|
815
|
-
tool: frame.tool,
|
|
816
|
-
preview: frame.preview || ("description" in frame ? frame.description : frame.tool),
|
|
817
|
-
createdAt: new Date().toISOString(),
|
|
818
|
-
};
|
|
819
|
-
pendingApprovals.set(pendingInteractionKey(frame.sessionId, frame.requestId), {
|
|
820
|
-
sessionId: frame.sessionId,
|
|
821
|
-
item,
|
|
822
|
-
bridge: frame.type === "bridge.approval",
|
|
823
|
-
});
|
|
824
|
-
broadcast({
|
|
825
|
-
type: "timeline.appended",
|
|
826
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
827
|
-
runtime: "claude",
|
|
828
|
-
sessionId: frame.sessionId,
|
|
829
|
-
item,
|
|
830
|
-
});
|
|
831
|
-
});
|
|
832
|
-
socket.on("close", () => {
|
|
833
|
-
if (!registration)
|
|
834
|
-
return;
|
|
835
|
-
const current = activeClaudeConnections.get(registration.sessionId);
|
|
836
|
-
if (current?.generation !== registration.generation || current.socket !== socket)
|
|
837
|
-
return;
|
|
838
|
-
failChannelDeliveries(registration.sessionId, registration.generation);
|
|
839
|
-
activeClaudeConnections.delete(registration.sessionId);
|
|
840
|
-
activeClaudeView.delete(registration.sessionId);
|
|
841
|
-
clearClaudeInteractions(registration.sessionId);
|
|
842
|
-
sessionsChanged();
|
|
843
|
-
});
|
|
844
|
-
});
|
|
845
|
-
await new Promise((resolve, reject) => {
|
|
846
|
-
server.once("error", reject);
|
|
847
|
-
server.listen(options.listenPort ?? config.port, config.host, () => {
|
|
848
|
-
server.off("error", reject);
|
|
849
|
-
resolve();
|
|
850
|
-
});
|
|
851
|
-
});
|
|
852
|
-
const address = server.address();
|
|
853
|
-
if (!address || typeof address === "string")
|
|
854
|
-
throw new Error("remote gateway did not bind a TCP address");
|
|
855
|
-
return {
|
|
856
|
-
host: config.host,
|
|
857
|
-
port: address.port,
|
|
858
|
-
gatewayId,
|
|
859
|
-
activeClaude: activeClaudeView,
|
|
860
|
-
async close() {
|
|
861
|
-
unsubscribeCodex?.();
|
|
862
|
-
for (const deliveryId of [...pendingChannelDeliveries.keys()]) {
|
|
863
|
-
settleChannelDelivery(deliveryId, "disconnected");
|
|
864
|
-
}
|
|
865
|
-
for (const socket of [...clientSockets, ...activeClaudeConnections.values()].map((value) => value instanceof WebSocket ? value : value.socket)) {
|
|
866
|
-
socket.close(1001, "gateway stopping");
|
|
867
|
-
}
|
|
868
|
-
await Promise.all([
|
|
869
|
-
new Promise((resolve) => clientWss.close(() => resolve())),
|
|
870
|
-
new Promise((resolve) => channelWss.close(() => resolve())),
|
|
871
|
-
new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
872
|
-
]);
|
|
873
|
-
},
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
export function encodeWebSocketToken(token) {
|
|
877
|
-
return `auth.${Buffer.from(token, "utf8").toString("base64url")}`;
|
|
878
|
-
}
|
|
879
|
-
export const REMOTE_WEBSOCKET_PROTOCOL = WS_PROTOCOL;
|