@nowcrew/daemon 0.5.31 → 0.5.32
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/README.md +53 -0
- package/dist/agent-memory/bridge.js +37 -0
- package/dist/agent-memory/client.js +94 -0
- package/dist/agent-memory/config.js +64 -0
- package/dist/agent-memory/policy.js +98 -0
- package/dist/config.js +2 -0
- package/dist/execution-journal-lock.js +21 -4
- package/dist/execution-protocol.js +1 -0
- package/dist/execution-runner.js +32 -2
- package/dist/local-executor.js +6 -1
- package/dist/machine-info.js +1 -0
- package/dist/runtimes/codex-app-server-runner.js +60 -16
- package/dist/serve.js +4 -0
- package/package.json +1 -1
- package/dist/remote/claude-bridge.js +0 -402
- package/dist/remote/claude-channel.js +0 -164
- package/dist/remote/codex-client.js +0 -408
- package/dist/remote/codex-runtime.js +0 -77
- package/dist/remote/config.js +0 -83
- package/dist/remote/gateway.js +0 -572
- package/dist/remote/protocol.js +0 -178
- package/dist/remote/remote-cli.js +0 -233
- package/dist/remote/session-discovery.js +0 -249
- package/dist/remote/wrapper.js +0 -40
package/dist/remote/gateway.js
DELETED
|
@@ -1,572 +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 { ChannelToGatewaySchema, GatewayToChannelSchema, REMOTE_PROTOCOL_VERSION, ResolveRemoteApprovalSchema, ResolveRemoteQuestionSchema, SubmitRemoteInputSchema, } from "./protocol.js";
|
|
9
|
-
import { RemoteSessionDiscovery } from "./session-discovery.js";
|
|
10
|
-
const MAX_BODY_BYTES = 96 * 1024;
|
|
11
|
-
const MAX_DEDUP_ENTRIES = 1_000;
|
|
12
|
-
const WS_PROTOCOL = "nowcrew.remote.v1";
|
|
13
|
-
const SESSION_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)(?:\/(timeline|input))?$/i;
|
|
14
|
-
const APPROVAL_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)\/approvals\/([A-Za-z0-9_-]+)$/;
|
|
15
|
-
const QUESTION_PATH_RE = /^\/api\/v1\/sessions\/(claude|codex)\/([0-9a-f-]+)\/questions\/([0-9a-f-]+)$/i;
|
|
16
|
-
function json(response, status, value) {
|
|
17
|
-
const body = JSON.stringify(value);
|
|
18
|
-
response.writeHead(status, {
|
|
19
|
-
"content-type": "application/json; charset=utf-8",
|
|
20
|
-
"content-length": Buffer.byteLength(body),
|
|
21
|
-
"cache-control": "no-store",
|
|
22
|
-
"x-content-type-options": "nosniff",
|
|
23
|
-
});
|
|
24
|
-
response.end(body);
|
|
25
|
-
}
|
|
26
|
-
function bearer(request) {
|
|
27
|
-
const value = request.headers.authorization;
|
|
28
|
-
return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : null;
|
|
29
|
-
}
|
|
30
|
-
function tokenMatches(expected, actual) {
|
|
31
|
-
if (actual === null)
|
|
32
|
-
return false;
|
|
33
|
-
const left = Buffer.from(expected);
|
|
34
|
-
const right = Buffer.from(actual);
|
|
35
|
-
return left.length === right.length && timingSafeEqual(left, right);
|
|
36
|
-
}
|
|
37
|
-
function websocketToken(request) {
|
|
38
|
-
const auth = bearer(request);
|
|
39
|
-
if (auth)
|
|
40
|
-
return auth;
|
|
41
|
-
const values = String(request.headers["sec-websocket-protocol"] ?? "")
|
|
42
|
-
.split(",")
|
|
43
|
-
.map((value) => value.trim());
|
|
44
|
-
const encoded = values.find((value) => value.startsWith("auth."))?.slice("auth.".length);
|
|
45
|
-
if (!encoded)
|
|
46
|
-
return null;
|
|
47
|
-
try {
|
|
48
|
-
return Buffer.from(encoded, "base64url").toString("utf8");
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function allowedOrigin(request, config) {
|
|
55
|
-
const origin = request.headers.origin;
|
|
56
|
-
if (!origin)
|
|
57
|
-
return true;
|
|
58
|
-
if (config.allowedOrigins.includes(origin))
|
|
59
|
-
return true;
|
|
60
|
-
const host = request.headers.host;
|
|
61
|
-
if (!host)
|
|
62
|
-
return false;
|
|
63
|
-
return origin === `http://${host}` || origin === `https://${host}`;
|
|
64
|
-
}
|
|
65
|
-
async function readJsonBody(request) {
|
|
66
|
-
const chunks = [];
|
|
67
|
-
let bytes = 0;
|
|
68
|
-
for await (const chunk of request) {
|
|
69
|
-
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
70
|
-
bytes += value.length;
|
|
71
|
-
if (bytes > MAX_BODY_BYTES)
|
|
72
|
-
throw new RangeError("request body too large");
|
|
73
|
-
chunks.push(value);
|
|
74
|
-
}
|
|
75
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
76
|
-
}
|
|
77
|
-
function mime(path) {
|
|
78
|
-
switch (extname(path)) {
|
|
79
|
-
case ".html": return "text/html; charset=utf-8";
|
|
80
|
-
case ".js": return "text/javascript; charset=utf-8";
|
|
81
|
-
case ".css": return "text/css; charset=utf-8";
|
|
82
|
-
case ".svg": return "image/svg+xml";
|
|
83
|
-
case ".png": return "image/png";
|
|
84
|
-
case ".woff2": return "font/woff2";
|
|
85
|
-
case ".json": return "application/json; charset=utf-8";
|
|
86
|
-
case ".webmanifest": return "application/manifest+json";
|
|
87
|
-
default: return "application/octet-stream";
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
async function serveStatic(response, uiDir, pathname) {
|
|
91
|
-
const relative = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
92
|
-
const normalized = normalize(relative).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
93
|
-
let path = join(uiDir, normalized);
|
|
94
|
-
try {
|
|
95
|
-
const info = await stat(path);
|
|
96
|
-
if (!info.isFile())
|
|
97
|
-
throw new Error("not a file");
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
path = join(uiDir, "index.html");
|
|
101
|
-
try {
|
|
102
|
-
await access(path);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
const info = await stat(path);
|
|
109
|
-
const revalidate = path.endsWith("index.html")
|
|
110
|
-
|| path.endsWith("sw.js")
|
|
111
|
-
|| path.endsWith("manifest.webmanifest");
|
|
112
|
-
response.writeHead(200, {
|
|
113
|
-
"content-type": mime(path),
|
|
114
|
-
"content-length": info.size,
|
|
115
|
-
"cache-control": revalidate ? "no-cache" : "public, max-age=31536000, immutable",
|
|
116
|
-
"x-content-type-options": "nosniff",
|
|
117
|
-
"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'",
|
|
118
|
-
});
|
|
119
|
-
createReadStream(path).pipe(response);
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
function websocketSend(socket, value) {
|
|
123
|
-
if (socket.readyState === WebSocket.OPEN)
|
|
124
|
-
socket.send(JSON.stringify(value));
|
|
125
|
-
}
|
|
126
|
-
export async function startRemoteGateway(options) {
|
|
127
|
-
const { config } = options;
|
|
128
|
-
const gatewayId = options.gatewayId ?? randomUUID();
|
|
129
|
-
const activeClaudeConnections = new Map();
|
|
130
|
-
const activeClaudeView = new Map();
|
|
131
|
-
const pendingApprovals = new Map();
|
|
132
|
-
const pendingQuestions = new Map();
|
|
133
|
-
const acceptedInputs = new Map();
|
|
134
|
-
const clientSockets = new Set();
|
|
135
|
-
const discovery = options.discovery ?? new RemoteSessionDiscovery({
|
|
136
|
-
claudeProjectsRoot: options.claudeProjectsRoot ?? join(homedir(), ".claude", "projects"),
|
|
137
|
-
codexSessionsRoot: options.codexSessionsRoot ?? join(homedir(), ".codex", "sessions"),
|
|
138
|
-
activeClaude: activeClaudeView,
|
|
139
|
-
});
|
|
140
|
-
const broadcast = (event) => {
|
|
141
|
-
for (const socket of clientSockets)
|
|
142
|
-
websocketSend(socket, event);
|
|
143
|
-
};
|
|
144
|
-
const sessionsChanged = () => broadcast({
|
|
145
|
-
type: "sessions.changed",
|
|
146
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
147
|
-
});
|
|
148
|
-
const interactionResolved = (sessionId, requestId) => broadcast({
|
|
149
|
-
type: "interaction.resolved",
|
|
150
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
151
|
-
sessionId,
|
|
152
|
-
requestId,
|
|
153
|
-
});
|
|
154
|
-
const listSessions = async () => {
|
|
155
|
-
const history = await discovery.list();
|
|
156
|
-
if (!options.codex)
|
|
157
|
-
return history;
|
|
158
|
-
let live;
|
|
159
|
-
try {
|
|
160
|
-
live = await options.codex.listSessions();
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return history;
|
|
164
|
-
}
|
|
165
|
-
const merged = new Map(history.map((session) => [`${session.runtime}:${session.id}`, session]));
|
|
166
|
-
for (const session of live)
|
|
167
|
-
merged.set(`codex:${session.id}`, session);
|
|
168
|
-
return [...merged.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 200);
|
|
169
|
-
};
|
|
170
|
-
const unsubscribeCodex = options.codex?.onEvent((event) => {
|
|
171
|
-
if (event.type === "sessions.changed")
|
|
172
|
-
sessionsChanged();
|
|
173
|
-
else
|
|
174
|
-
broadcast({ ...event, protocolVersion: REMOTE_PROTOCOL_VERSION });
|
|
175
|
-
});
|
|
176
|
-
const server = createServer((request, response) => {
|
|
177
|
-
void (async () => {
|
|
178
|
-
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
179
|
-
if (request.method === "GET" && url.pathname === "/api/v1/health") {
|
|
180
|
-
json(response, 200, { ok: true, protocolVersion: REMOTE_PROTOCOL_VERSION });
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (url.pathname.startsWith("/api/")) {
|
|
184
|
-
if (!tokenMatches(config.token, bearer(request))) {
|
|
185
|
-
json(response, 401, { error: "unauthorized" });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (!["GET", "HEAD"].includes(request.method ?? "") && !allowedOrigin(request, config)) {
|
|
189
|
-
json(response, 403, { error: "origin_not_allowed" });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (request.method === "GET" && url.pathname === "/api/v1/sessions") {
|
|
194
|
-
json(response, 200, { sessions: await listSessions() });
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (request.method === "GET" && url.pathname === "/api/v1/identity") {
|
|
198
|
-
json(response, 200, { gatewayId, pid: process.pid, protocolVersion: REMOTE_PROTOCOL_VERSION });
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const approvalMatch = APPROVAL_PATH_RE.exec(url.pathname);
|
|
202
|
-
if (request.method === "POST" && approvalMatch) {
|
|
203
|
-
const runtime = approvalMatch[1].toLowerCase();
|
|
204
|
-
const sessionId = approvalMatch[2];
|
|
205
|
-
const requestId = approvalMatch[3];
|
|
206
|
-
const body = ResolveRemoteApprovalSchema.parse(await readJsonBody(request));
|
|
207
|
-
if (runtime === "codex") {
|
|
208
|
-
if (!options.codex?.resolveApproval(sessionId, requestId, body.decision)) {
|
|
209
|
-
json(response, 409, { error: "approval_not_pending" });
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
interactionResolved(sessionId, requestId);
|
|
213
|
-
json(response, 202, { accepted: true });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
const pending = pendingApprovals.get(requestId);
|
|
217
|
-
if (!pending || pending.sessionId !== sessionId) {
|
|
218
|
-
json(response, 409, { error: "approval_not_pending" });
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
const connection = activeClaudeConnections.get(sessionId);
|
|
222
|
-
if (!connection) {
|
|
223
|
-
json(response, 409, { error: "session_not_live" });
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
websocketSend(connection.socket, GatewayToChannelSchema.parse(pending.bridge ? {
|
|
227
|
-
type: "bridge.approval_response",
|
|
228
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
229
|
-
requestId,
|
|
230
|
-
decision: body.decision,
|
|
231
|
-
} : {
|
|
232
|
-
type: "channel.permission_response",
|
|
233
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
234
|
-
requestId,
|
|
235
|
-
decision: body.decision,
|
|
236
|
-
}));
|
|
237
|
-
pendingApprovals.delete(requestId);
|
|
238
|
-
interactionResolved(sessionId, requestId);
|
|
239
|
-
json(response, 202, { accepted: true });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const questionMatch = QUESTION_PATH_RE.exec(url.pathname);
|
|
243
|
-
if (request.method === "POST" && questionMatch) {
|
|
244
|
-
const runtime = questionMatch[1].toLowerCase();
|
|
245
|
-
const sessionId = questionMatch[2];
|
|
246
|
-
const requestId = questionMatch[3];
|
|
247
|
-
const body = ResolveRemoteQuestionSchema.parse(await readJsonBody(request));
|
|
248
|
-
if (runtime === "codex") {
|
|
249
|
-
if (!options.codex?.resolveQuestion(sessionId, requestId, body)) {
|
|
250
|
-
json(response, 409, { error: "question_not_pending" });
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
interactionResolved(sessionId, requestId);
|
|
254
|
-
json(response, 202, { accepted: true });
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const pending = pendingQuestions.get(requestId);
|
|
258
|
-
const connection = activeClaudeConnections.get(sessionId);
|
|
259
|
-
if (!pending || pending.sessionId !== sessionId || !connection) {
|
|
260
|
-
json(response, 409, { error: "question_not_pending" });
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
websocketSend(connection.socket, GatewayToChannelSchema.parse({
|
|
264
|
-
type: "bridge.question_response",
|
|
265
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
266
|
-
requestId,
|
|
267
|
-
answers: body.answers,
|
|
268
|
-
}));
|
|
269
|
-
pendingQuestions.delete(requestId);
|
|
270
|
-
interactionResolved(sessionId, requestId);
|
|
271
|
-
json(response, 202, { accepted: true });
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
const match = SESSION_PATH_RE.exec(url.pathname);
|
|
275
|
-
if (request.method === "GET" && match?.[3] === "timeline") {
|
|
276
|
-
const runtime = match[1].toLowerCase();
|
|
277
|
-
const discovered = runtime === "codex" && options.codex
|
|
278
|
-
? await options.codex.timeline(match[2])
|
|
279
|
-
: await discovery.timeline(runtime, match[2]);
|
|
280
|
-
if (discovered === null) {
|
|
281
|
-
json(response, 404, { error: "session_not_found" });
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
const items = runtime === "claude"
|
|
285
|
-
? [
|
|
286
|
-
...discovered,
|
|
287
|
-
...[...pendingApprovals.values()].filter((pending) => pending.sessionId === match[2]).map((pending) => pending.item),
|
|
288
|
-
...[...pendingQuestions.values()].filter((pending) => pending.sessionId === match[2]).map((pending) => pending.item),
|
|
289
|
-
]
|
|
290
|
-
: discovered;
|
|
291
|
-
json(response, 200, { items });
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
if (request.method === "POST" && match?.[3] === "input") {
|
|
295
|
-
const runtime = match[1].toLowerCase();
|
|
296
|
-
const sessionId = match[2];
|
|
297
|
-
if (runtime !== "claude" && !options.codex) {
|
|
298
|
-
json(response, 501, { error: "runtime_not_connected" });
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
const input = SubmitRemoteInputSchema.parse(await readJsonBody(request));
|
|
302
|
-
const prior = acceptedInputs.get(input.idempotencyKey);
|
|
303
|
-
if (prior) {
|
|
304
|
-
json(response, 202, { accepted: true, requestId: prior.requestId, duplicate: true });
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const connection = runtime === "claude" ? activeClaudeConnections.get(sessionId) : undefined;
|
|
308
|
-
if (runtime === "claude" && !connection) {
|
|
309
|
-
json(response, 409, { error: "session_not_live" });
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
const accepted = {
|
|
313
|
-
requestId: input.idempotencyKey,
|
|
314
|
-
sessionId,
|
|
315
|
-
acceptedAt: new Date().toISOString(),
|
|
316
|
-
};
|
|
317
|
-
if (runtime === "claude") {
|
|
318
|
-
websocketSend(connection.socket, GatewayToChannelSchema.parse({
|
|
319
|
-
type: "channel.prompt",
|
|
320
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
321
|
-
requestId: accepted.requestId,
|
|
322
|
-
text: input.text,
|
|
323
|
-
}));
|
|
324
|
-
}
|
|
325
|
-
else {
|
|
326
|
-
await options.codex.submit(sessionId, input);
|
|
327
|
-
}
|
|
328
|
-
acceptedInputs.set(input.idempotencyKey, accepted);
|
|
329
|
-
while (acceptedInputs.size > MAX_DEDUP_ENTRIES) {
|
|
330
|
-
const oldest = acceptedInputs.keys().next().value;
|
|
331
|
-
if (!oldest)
|
|
332
|
-
break;
|
|
333
|
-
acceptedInputs.delete(oldest);
|
|
334
|
-
}
|
|
335
|
-
const item = {
|
|
336
|
-
id: `remote-user:${accepted.requestId}`,
|
|
337
|
-
kind: "message",
|
|
338
|
-
role: "user",
|
|
339
|
-
text: input.text,
|
|
340
|
-
createdAt: accepted.acceptedAt,
|
|
341
|
-
};
|
|
342
|
-
broadcast({
|
|
343
|
-
type: "timeline.appended",
|
|
344
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
345
|
-
sessionId,
|
|
346
|
-
item,
|
|
347
|
-
});
|
|
348
|
-
json(response, 202, { accepted: true, requestId: accepted.requestId, duplicate: false });
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (url.pathname.startsWith("/api/")) {
|
|
352
|
-
json(response, 404, { error: "not_found" });
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
if (request.method === "GET" && options.uiDir && await serveStatic(response, options.uiDir, url.pathname))
|
|
356
|
-
return;
|
|
357
|
-
json(response, 404, { error: "not_found" });
|
|
358
|
-
})().catch((error) => {
|
|
359
|
-
if (response.headersSent) {
|
|
360
|
-
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
const message = error instanceof SyntaxError ? "invalid_json"
|
|
364
|
-
: error instanceof RangeError ? "request_too_large"
|
|
365
|
-
: "invalid_request";
|
|
366
|
-
json(response, message === "request_too_large" ? 413 : 400, { error: message });
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
const selectProtocol = (protocols) => protocols.has(WS_PROTOCOL) ? WS_PROTOCOL : false;
|
|
370
|
-
const clientWss = new WebSocketServer({
|
|
371
|
-
noServer: true,
|
|
372
|
-
maxPayload: MAX_BODY_BYTES,
|
|
373
|
-
handleProtocols: selectProtocol,
|
|
374
|
-
});
|
|
375
|
-
const channelWss = new WebSocketServer({
|
|
376
|
-
noServer: true,
|
|
377
|
-
maxPayload: MAX_BODY_BYTES,
|
|
378
|
-
handleProtocols: selectProtocol,
|
|
379
|
-
});
|
|
380
|
-
server.on("upgrade", (request, socket, head) => {
|
|
381
|
-
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
382
|
-
if (!tokenMatches(config.token, websocketToken(request)) || !allowedOrigin(request, config)) {
|
|
383
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
384
|
-
socket.destroy();
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
const wss = url.pathname === "/ws/client" ? clientWss
|
|
388
|
-
: url.pathname === "/ws/channel" ? channelWss
|
|
389
|
-
: null;
|
|
390
|
-
if (!wss) {
|
|
391
|
-
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
392
|
-
socket.destroy();
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
396
|
-
wss.emit("connection", ws, request);
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
clientWss.on("connection", (socket) => {
|
|
400
|
-
clientSockets.add(socket);
|
|
401
|
-
socket.on("close", () => clientSockets.delete(socket));
|
|
402
|
-
});
|
|
403
|
-
channelWss.on("connection", (socket) => {
|
|
404
|
-
let registration = null;
|
|
405
|
-
socket.on("message", (raw) => {
|
|
406
|
-
let decoded;
|
|
407
|
-
try {
|
|
408
|
-
decoded = JSON.parse(raw.toString());
|
|
409
|
-
}
|
|
410
|
-
catch {
|
|
411
|
-
socket.close(1008, "invalid JSON");
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const parsed = ChannelToGatewaySchema.safeParse(decoded);
|
|
415
|
-
if (!parsed.success) {
|
|
416
|
-
socket.close(1008, "invalid channel frame");
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
const frame = parsed.data;
|
|
420
|
-
if (frame.type === "channel.register") {
|
|
421
|
-
const previous = activeClaudeConnections.get(frame.sessionId);
|
|
422
|
-
if (previous && previous.socket !== socket)
|
|
423
|
-
previous.socket.close(4001, "superseded");
|
|
424
|
-
registration = { sessionId: frame.sessionId, generation: frame.generation };
|
|
425
|
-
const active = {
|
|
426
|
-
socket,
|
|
427
|
-
generation: frame.generation,
|
|
428
|
-
pid: frame.pid,
|
|
429
|
-
cwd: frame.cwd,
|
|
430
|
-
...(frame.title === undefined ? {} : { title: frame.title }),
|
|
431
|
-
busy: false,
|
|
432
|
-
updatedAt: new Date().toISOString(),
|
|
433
|
-
};
|
|
434
|
-
activeClaudeConnections.set(frame.sessionId, active);
|
|
435
|
-
activeClaudeView.set(frame.sessionId, {
|
|
436
|
-
cwd: active.cwd,
|
|
437
|
-
...(active.title === undefined ? {} : { title: active.title }),
|
|
438
|
-
busy: active.busy,
|
|
439
|
-
...(active.updatedAt === undefined ? {} : { updatedAt: active.updatedAt }),
|
|
440
|
-
});
|
|
441
|
-
sessionsChanged();
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
if (!registration || registration.sessionId !== frame.sessionId) {
|
|
445
|
-
socket.close(1008, "register first");
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (frame.type === "channel.reply") {
|
|
449
|
-
const item = {
|
|
450
|
-
id: `channel-reply:${frame.requestId}`,
|
|
451
|
-
kind: "message",
|
|
452
|
-
role: "assistant",
|
|
453
|
-
text: frame.text,
|
|
454
|
-
createdAt: new Date().toISOString(),
|
|
455
|
-
};
|
|
456
|
-
broadcast({
|
|
457
|
-
type: "timeline.appended",
|
|
458
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
459
|
-
sessionId: frame.sessionId,
|
|
460
|
-
item,
|
|
461
|
-
});
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
if (frame.type === "bridge.status") {
|
|
465
|
-
const current = activeClaudeConnections.get(frame.sessionId);
|
|
466
|
-
if (!current)
|
|
467
|
-
return;
|
|
468
|
-
const updated = { ...current, busy: frame.busy };
|
|
469
|
-
activeClaudeConnections.set(frame.sessionId, updated);
|
|
470
|
-
activeClaudeView.set(frame.sessionId, {
|
|
471
|
-
cwd: updated.cwd,
|
|
472
|
-
...(updated.title === undefined ? {} : { title: updated.title }),
|
|
473
|
-
busy: updated.busy,
|
|
474
|
-
...(updated.updatedAt === undefined ? {} : { updatedAt: updated.updatedAt }),
|
|
475
|
-
});
|
|
476
|
-
sessionsChanged();
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
if (frame.type === "bridge.resolved") {
|
|
480
|
-
pendingApprovals.delete(frame.requestId);
|
|
481
|
-
pendingQuestions.delete(frame.requestId);
|
|
482
|
-
interactionResolved(frame.sessionId, frame.requestId);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
if (frame.type === "bridge.question") {
|
|
486
|
-
const item = {
|
|
487
|
-
id: `claude-question:${frame.requestId}`,
|
|
488
|
-
kind: "question",
|
|
489
|
-
requestId: frame.requestId,
|
|
490
|
-
questions: frame.questions,
|
|
491
|
-
createdAt: new Date().toISOString(),
|
|
492
|
-
};
|
|
493
|
-
pendingQuestions.set(frame.requestId, { sessionId: frame.sessionId, item });
|
|
494
|
-
broadcast({
|
|
495
|
-
type: "timeline.appended",
|
|
496
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
497
|
-
sessionId: frame.sessionId,
|
|
498
|
-
item,
|
|
499
|
-
});
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const item = {
|
|
503
|
-
id: `claude-approval:${frame.requestId}`,
|
|
504
|
-
kind: "approval",
|
|
505
|
-
requestId: frame.requestId,
|
|
506
|
-
tool: frame.tool,
|
|
507
|
-
preview: frame.preview || ("description" in frame ? frame.description : frame.tool),
|
|
508
|
-
createdAt: new Date().toISOString(),
|
|
509
|
-
};
|
|
510
|
-
pendingApprovals.set(frame.requestId, {
|
|
511
|
-
sessionId: frame.sessionId,
|
|
512
|
-
item,
|
|
513
|
-
bridge: frame.type === "bridge.approval",
|
|
514
|
-
});
|
|
515
|
-
broadcast({
|
|
516
|
-
type: "timeline.appended",
|
|
517
|
-
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
518
|
-
sessionId: frame.sessionId,
|
|
519
|
-
item,
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
socket.on("close", () => {
|
|
523
|
-
if (!registration)
|
|
524
|
-
return;
|
|
525
|
-
const current = activeClaudeConnections.get(registration.sessionId);
|
|
526
|
-
if (current?.generation !== registration.generation || current.socket !== socket)
|
|
527
|
-
return;
|
|
528
|
-
activeClaudeConnections.delete(registration.sessionId);
|
|
529
|
-
activeClaudeView.delete(registration.sessionId);
|
|
530
|
-
for (const [requestId, pending] of pendingApprovals) {
|
|
531
|
-
if (pending.sessionId === registration.sessionId)
|
|
532
|
-
pendingApprovals.delete(requestId);
|
|
533
|
-
}
|
|
534
|
-
for (const [requestId, pending] of pendingQuestions) {
|
|
535
|
-
if (pending.sessionId === registration.sessionId)
|
|
536
|
-
pendingQuestions.delete(requestId);
|
|
537
|
-
}
|
|
538
|
-
sessionsChanged();
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
await new Promise((resolve, reject) => {
|
|
542
|
-
server.once("error", reject);
|
|
543
|
-
server.listen(options.listenPort ?? config.port, config.host, () => {
|
|
544
|
-
server.off("error", reject);
|
|
545
|
-
resolve();
|
|
546
|
-
});
|
|
547
|
-
});
|
|
548
|
-
const address = server.address();
|
|
549
|
-
if (!address || typeof address === "string")
|
|
550
|
-
throw new Error("remote gateway did not bind a TCP address");
|
|
551
|
-
return {
|
|
552
|
-
host: config.host,
|
|
553
|
-
port: address.port,
|
|
554
|
-
gatewayId,
|
|
555
|
-
activeClaude: activeClaudeView,
|
|
556
|
-
async close() {
|
|
557
|
-
unsubscribeCodex?.();
|
|
558
|
-
for (const socket of [...clientSockets, ...activeClaudeConnections.values()].map((value) => value instanceof WebSocket ? value : value.socket)) {
|
|
559
|
-
socket.close(1001, "gateway stopping");
|
|
560
|
-
}
|
|
561
|
-
await Promise.all([
|
|
562
|
-
new Promise((resolve) => clientWss.close(() => resolve())),
|
|
563
|
-
new Promise((resolve) => channelWss.close(() => resolve())),
|
|
564
|
-
new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
565
|
-
]);
|
|
566
|
-
},
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
export function encodeWebSocketToken(token) {
|
|
570
|
-
return `auth.${Buffer.from(token, "utf8").toString("base64url")}`;
|
|
571
|
-
}
|
|
572
|
-
export const REMOTE_WEBSOCKET_PROTOCOL = WS_PROTOCOL;
|