@cjhyy/code-shell-server 0.8.0
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 +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- package/package.json +63 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { networkInterfaces } from "node:os";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { serveMobile } from "./mobile-static.js";
|
|
8
|
+
import { PairingTokenManager } from "./pairing.js";
|
|
9
|
+
/**
|
|
10
|
+
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
11
|
+
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
12
|
+
* §6.5 network-safety rule. Excludes loopback (127.x), link-local (169.254.x),
|
|
13
|
+
* and common VPN/tunnel ranges (198.18.x carrier-grade test net used by some
|
|
14
|
+
* VPN clients) so we don't advertise an address the phone can't route to.
|
|
15
|
+
* Returns undefined if no suitable interface is found.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveLanHost() {
|
|
18
|
+
const ifaces = networkInterfaces();
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const addrs of Object.values(ifaces)) {
|
|
21
|
+
for (const addr of addrs ?? []) {
|
|
22
|
+
if (addr.family !== "IPv4" || addr.internal)
|
|
23
|
+
continue;
|
|
24
|
+
const ip = addr.address;
|
|
25
|
+
if (ip.startsWith("127.") || ip.startsWith("169.254.") || ip.startsWith("198.18.")) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
candidates.push(ip);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Prefer private LAN ranges (192.168.x, 10.x, 172.16-31.x) over anything else.
|
|
32
|
+
const isPrivate = (ip) => ip.startsWith("192.168.") || ip.startsWith("10.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
|
|
33
|
+
return candidates.find(isPrivate) ?? candidates[0];
|
|
34
|
+
}
|
|
35
|
+
export class RemoteHostManager extends EventEmitter {
|
|
36
|
+
opts;
|
|
37
|
+
server;
|
|
38
|
+
wss;
|
|
39
|
+
started;
|
|
40
|
+
pairing = new PairingTokenManager();
|
|
41
|
+
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
42
|
+
authed = new WeakMap();
|
|
43
|
+
/** Stable identity for each individual tab/socket, independent of device id. */
|
|
44
|
+
viewers = new WeakMap();
|
|
45
|
+
nextViewerId = 1;
|
|
46
|
+
/** deviceId → number of live authenticated sockets (a phone may open more
|
|
47
|
+
* than one). A device is "online" while its count is > 0. */
|
|
48
|
+
onlineCounts = new Map();
|
|
49
|
+
/** Public base URL (tunnel domain) used by createPairingUrl when set. */
|
|
50
|
+
publicBaseUrl;
|
|
51
|
+
/** Passcode gate, present only in tunnel mode. */
|
|
52
|
+
passcode;
|
|
53
|
+
/** Where the built mobile app lives (out/mobile). */
|
|
54
|
+
mobileRootDir;
|
|
55
|
+
/** Dev proxy target for /mobile (HMR); undefined in prod. */
|
|
56
|
+
mobileDevUrl;
|
|
57
|
+
constructor(opts) {
|
|
58
|
+
super();
|
|
59
|
+
this.opts = opts;
|
|
60
|
+
// Bundled main is out/main/index.mjs → mobile app is the sibling out/mobile.
|
|
61
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
this.mobileRootDir = opts.mobileRootDir ?? resolve(here, "../mobile");
|
|
63
|
+
this.mobileDevUrl = opts.mobileDevUrl ?? process.env.MOBILE_DEV_URL;
|
|
64
|
+
}
|
|
65
|
+
/** Device ids with at least one live socket right now. */
|
|
66
|
+
onlineDeviceIds() {
|
|
67
|
+
return [...this.onlineCounts.keys()];
|
|
68
|
+
}
|
|
69
|
+
/** Register a live socket for a device; emits `online-change` only when the
|
|
70
|
+
* device transitions offline→online (first socket). */
|
|
71
|
+
markOnline(deviceId) {
|
|
72
|
+
const prev = this.onlineCounts.get(deviceId) ?? 0;
|
|
73
|
+
this.onlineCounts.set(deviceId, prev + 1);
|
|
74
|
+
if (prev === 0)
|
|
75
|
+
this.emit("online-change", this.onlineDeviceIds());
|
|
76
|
+
}
|
|
77
|
+
/** Drop a socket for a device; emits `online-change` only when its last
|
|
78
|
+
* socket goes away (online→offline). */
|
|
79
|
+
markOffline(deviceId) {
|
|
80
|
+
const prev = this.onlineCounts.get(deviceId) ?? 0;
|
|
81
|
+
if (prev <= 1) {
|
|
82
|
+
if (this.onlineCounts.delete(deviceId)) {
|
|
83
|
+
this.emit("online-change", this.onlineDeviceIds());
|
|
84
|
+
this.emit("device-offline", deviceId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.onlineCounts.set(deviceId, prev - 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async start(options) {
|
|
92
|
+
if (this.started)
|
|
93
|
+
return this.started;
|
|
94
|
+
const tunnel = options.mode === "tunnel";
|
|
95
|
+
this.passcode = tunnel ? options.passcode : undefined;
|
|
96
|
+
const gate = this.passcode;
|
|
97
|
+
const server = createServer((req, res) => {
|
|
98
|
+
// Tunnel mode: a passcode gate sits in front of EVERY route. The gate
|
|
99
|
+
// either allows (returns true) or writes its own 401/403 challenge.
|
|
100
|
+
if (gate && !gate.gate(req, res))
|
|
101
|
+
return;
|
|
102
|
+
if (req.url === "/health") {
|
|
103
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ ok: true }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const uploadPath = req.url
|
|
108
|
+
? new URL(req.url, "http://localhost").pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/)
|
|
109
|
+
: null;
|
|
110
|
+
if (uploadPath && this.opts.uploads) {
|
|
111
|
+
void this.opts.uploads.acceptPut(uploadPath[1], req, res);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// The mobile app is built/served with vite base "/mobile/", so ALL of its
|
|
115
|
+
// assets — and in dev, vite's HMR/module URLs — live under the /mobile
|
|
116
|
+
// prefix. A single prefix route serves prod (static out/mobile) and dev
|
|
117
|
+
// (proxy to the mobile vite server) symmetrically. Tunnel mode never
|
|
118
|
+
// proxies dev: Vite's HMR socket and @fs module URLs don't survive a
|
|
119
|
+
// public trycloudflare hop, so a public tunnel always serves the build.
|
|
120
|
+
// Match the route family exactly ("/mobile", "/mobile/…", "/mobile?…",
|
|
121
|
+
// "/mobile#…") — NOT a sibling like "/mobilexyz", which falls to 404.
|
|
122
|
+
if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
|
|
123
|
+
serveMobile(req, res, {
|
|
124
|
+
rootDir: this.mobileRootDir,
|
|
125
|
+
devUrl: tunnel ? undefined : this.mobileDevUrl,
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(404);
|
|
130
|
+
res.end("not found");
|
|
131
|
+
});
|
|
132
|
+
// In tunnel mode we must gate the WS handshake too. `noServer` lets us run
|
|
133
|
+
// the passcode check during the HTTP `upgrade` event before completing the
|
|
134
|
+
// handshake; in lan mode behaviour is unchanged (the same path/server bind).
|
|
135
|
+
this.wss = gate
|
|
136
|
+
? new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 })
|
|
137
|
+
: new WebSocketServer({ server, path: "/ws", maxPayload: 1024 * 1024 });
|
|
138
|
+
if (gate) {
|
|
139
|
+
server.on("upgrade", (req, socket, head) => {
|
|
140
|
+
if (!req.url?.startsWith("/ws")) {
|
|
141
|
+
socket.destroy();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!gate.allows(req)) {
|
|
145
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
146
|
+
socket.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
150
|
+
this.wss.emit("connection", ws, req);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
this.wss.on("connection", (ws) => {
|
|
155
|
+
const viewerId = `viewer-${this.nextViewerId++}`;
|
|
156
|
+
this.viewers.set(ws, viewerId);
|
|
157
|
+
ws.on("message", (raw) => {
|
|
158
|
+
let event;
|
|
159
|
+
try {
|
|
160
|
+
event = JSON.parse(String(raw));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Pairing/auth are handled inline and bind device identity to this
|
|
167
|
+
// socket. Everything else is gated: an unauthenticated socket cannot
|
|
168
|
+
// send chat/approval/run/job events (design §6.1).
|
|
169
|
+
const reply = this.handleClientEvent(event);
|
|
170
|
+
if (event.type === "auth.device" && reply?.type === "auth.ok") {
|
|
171
|
+
if (!this.authed.has(ws))
|
|
172
|
+
this.markOnline(reply.device.id);
|
|
173
|
+
this.authed.set(ws, reply.device.id);
|
|
174
|
+
}
|
|
175
|
+
// Pairing also yields a live, identified socket → count it online.
|
|
176
|
+
if (event.type === "pair.complete" && reply?.type === "pair.ok") {
|
|
177
|
+
if (!this.authed.has(ws))
|
|
178
|
+
this.markOnline(reply.device.id);
|
|
179
|
+
this.authed.set(ws, reply.device.id);
|
|
180
|
+
}
|
|
181
|
+
if (reply) {
|
|
182
|
+
ws.send(JSON.stringify(reply));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!this.authed.has(ws)) {
|
|
186
|
+
ws.send(JSON.stringify({ type: "auth.failed", message: "Not authenticated" }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Authenticated, non-auth event → hand to the main dispatcher, which
|
|
190
|
+
// routes chat/approval into the existing run/permission path.
|
|
191
|
+
this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
|
|
192
|
+
});
|
|
193
|
+
ws.on("close", () => {
|
|
194
|
+
this.releaseSocket(ws);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
this.server = server;
|
|
198
|
+
// host: "lan" → resolve the Mac's real LAN IPv4 so a phone on the same
|
|
199
|
+
// Wi-Fi can reach us. We bind that concrete address (never 0.0.0.0). If no
|
|
200
|
+
// LAN interface is found, fall back to the requested host (e.g. localhost).
|
|
201
|
+
const bindHost = tunnel
|
|
202
|
+
? "127.0.0.1"
|
|
203
|
+
: options.host === "lan"
|
|
204
|
+
? (resolveLanHost() ?? "127.0.0.1")
|
|
205
|
+
: options.host;
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
server.once("error", reject);
|
|
208
|
+
server.listen(options.port, bindHost, () => resolve());
|
|
209
|
+
});
|
|
210
|
+
const addr = server.address();
|
|
211
|
+
const port = typeof addr === "object" && addr ? addr.port : options.port;
|
|
212
|
+
this.started = {
|
|
213
|
+
host: bindHost,
|
|
214
|
+
port,
|
|
215
|
+
url: `http://${bindHost}:${port}`,
|
|
216
|
+
mode: tunnel ? "tunnel" : "lan",
|
|
217
|
+
};
|
|
218
|
+
return this.started;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Override the base URL used to build pairing URLs (tunnel mode points this
|
|
222
|
+
* at the `https://*.trycloudflare.com` domain so the QR encodes the public
|
|
223
|
+
* address, not the loopback bind). Pass undefined to clear it.
|
|
224
|
+
*/
|
|
225
|
+
setPublicBaseUrl(url) {
|
|
226
|
+
this.publicBaseUrl = url;
|
|
227
|
+
}
|
|
228
|
+
createPairingUrl() {
|
|
229
|
+
if (!this.started)
|
|
230
|
+
throw new Error("Remote host is not running");
|
|
231
|
+
const token = this.pairing.createToken();
|
|
232
|
+
const base = this.publicBaseUrl ?? this.started.url;
|
|
233
|
+
return {
|
|
234
|
+
token: token.value,
|
|
235
|
+
expiresAt: token.expiresAt,
|
|
236
|
+
url: `${base}/mobile?pairing=${token.value}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Handle pairing/auth client events. Returns a server event for those two
|
|
241
|
+
* cases (consumed inline by the WS layer) or `undefined` for events the
|
|
242
|
+
* caller should route elsewhere. Pairing tokens are one-use; auth checks the
|
|
243
|
+
* trusted-device store (which rejects revoked devices), so the remote host
|
|
244
|
+
* never executes tools itself — it only gates transport.
|
|
245
|
+
*/
|
|
246
|
+
handleClientEvent(event) {
|
|
247
|
+
if (event.type === "pair.complete") {
|
|
248
|
+
if (!this.pairing.consume(event.token)) {
|
|
249
|
+
return { type: "pair.failed", message: "Pairing token expired or invalid" };
|
|
250
|
+
}
|
|
251
|
+
const device = this.opts.devices.addDevice({
|
|
252
|
+
name: event.name,
|
|
253
|
+
secretHash: event.secretHash,
|
|
254
|
+
});
|
|
255
|
+
return { type: "pair.ok", device };
|
|
256
|
+
}
|
|
257
|
+
if (event.type === "auth.device") {
|
|
258
|
+
const device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
|
|
259
|
+
if (!device)
|
|
260
|
+
return { type: "auth.failed", message: "Device is not trusted" };
|
|
261
|
+
return { type: "auth.ok", device };
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
/** Broadcast a server event to every connected, authenticated mobile socket. */
|
|
266
|
+
broadcast(event) {
|
|
267
|
+
this.broadcastRaw(JSON.stringify(event));
|
|
268
|
+
}
|
|
269
|
+
/** Send a server event to ONLY the sockets belonging to one device. Used for
|
|
270
|
+
* per-device replies (chat.accepted, session.list.ok, permission.mode, …) so
|
|
271
|
+
* one phone's session/permission state never leaks onto another's screen.
|
|
272
|
+
* A device with no live socket (raced a disconnect) is a silent no-op. */
|
|
273
|
+
sendToDevice(deviceId, event) {
|
|
274
|
+
const payload = JSON.stringify(event);
|
|
275
|
+
for (const client of this.wss?.clients ?? []) {
|
|
276
|
+
if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
|
|
277
|
+
client.send(payload);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
|
|
282
|
+
sendRawToDevice(deviceId, payload) {
|
|
283
|
+
for (const client of this.wss?.clients ?? []) {
|
|
284
|
+
if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
|
|
285
|
+
client.send(payload);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Broadcast a raw line (e.g. a mirrored worker→renderer JSON-RPC line) to
|
|
290
|
+
* every authenticated mobile socket. Unauthenticated sockets are skipped so
|
|
291
|
+
* a half-paired client never sees session output. */
|
|
292
|
+
broadcastRaw(payload) {
|
|
293
|
+
for (const client of this.wss?.clients ?? []) {
|
|
294
|
+
if (client.readyState === client.OPEN && this.authed.has(client)) {
|
|
295
|
+
client.send(payload);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
releaseSocket(ws) {
|
|
300
|
+
const deviceId = this.authed.get(ws);
|
|
301
|
+
const viewerId = this.viewers.get(ws);
|
|
302
|
+
this.authed.delete(ws);
|
|
303
|
+
this.viewers.delete(ws);
|
|
304
|
+
if (!deviceId)
|
|
305
|
+
return;
|
|
306
|
+
if (viewerId) {
|
|
307
|
+
this.emit("viewer-offline", { deviceId, viewerId });
|
|
308
|
+
}
|
|
309
|
+
this.markOffline(deviceId);
|
|
310
|
+
}
|
|
311
|
+
async stop() {
|
|
312
|
+
const server = this.server;
|
|
313
|
+
// Forcibly drop live WS sockets first. wss.close() alone waits for clients
|
|
314
|
+
// to disconnect, so a phone left connected would hang stop() indefinitely.
|
|
315
|
+
// terminate() severs each immediately.
|
|
316
|
+
for (const client of this.wss?.clients ?? []) {
|
|
317
|
+
// Release viewer-owned resources synchronously. The later socket close
|
|
318
|
+
// event is idempotent because releaseSocket removes its auth binding.
|
|
319
|
+
this.releaseSocket(client);
|
|
320
|
+
client.terminate();
|
|
321
|
+
}
|
|
322
|
+
this.wss?.close();
|
|
323
|
+
this.wss = undefined;
|
|
324
|
+
this.server = undefined;
|
|
325
|
+
this.started = undefined;
|
|
326
|
+
this.publicBaseUrl = undefined;
|
|
327
|
+
this.passcode = undefined;
|
|
328
|
+
// releaseSocket normally empties this map. Retain a defensive fallback for
|
|
329
|
+
// manually injected bookkeeping (tests / future non-WS transports).
|
|
330
|
+
for (const deviceId of this.onlineCounts.keys())
|
|
331
|
+
this.emit("device-offline", deviceId);
|
|
332
|
+
this.onlineCounts.clear();
|
|
333
|
+
await this.opts.uploads?.cancelActiveTransfers();
|
|
334
|
+
if (!server)
|
|
335
|
+
return;
|
|
336
|
+
// server.close() only stops accepting new connections and waits for live
|
|
337
|
+
// ones to end — a lingering upgraded/keep-alive socket keeps its callback
|
|
338
|
+
// from ever firing. Destroy any remaining connections so close() resolves.
|
|
339
|
+
server.closeAllConnections?.();
|
|
340
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
341
|
+
}
|
|
342
|
+
status() {
|
|
343
|
+
return this.started;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized event emitted from a resident stream-json claude process. The
|
|
3
|
+
* raw stream-json is collapsed into a small, render-friendly union that the
|
|
4
|
+
* room layer persists to messages.jsonl and mirrors to the phone. System
|
|
5
|
+
* hook/init noise is dropped.
|
|
6
|
+
*/
|
|
7
|
+
export type ResidentAgentEvent = {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "tool";
|
|
12
|
+
id?: string;
|
|
13
|
+
tool: string;
|
|
14
|
+
summary: string;
|
|
15
|
+
input?: Record<string, unknown>;
|
|
16
|
+
} | {
|
|
17
|
+
type: "tool_result";
|
|
18
|
+
id?: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
isError: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
type: "turn_end";
|
|
23
|
+
reason: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "error";
|
|
26
|
+
error: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: "exit";
|
|
29
|
+
code: number | null;
|
|
30
|
+
signal: string | null;
|
|
31
|
+
} | {
|
|
32
|
+
type: "approval_request";
|
|
33
|
+
requestId: string;
|
|
34
|
+
toolName: string;
|
|
35
|
+
displayName?: string;
|
|
36
|
+
input: unknown;
|
|
37
|
+
description?: string;
|
|
38
|
+
};
|
|
39
|
+
/** A decision for a tool-approval (`can_use_tool`) control request. */
|
|
40
|
+
export type ControlDecision = {
|
|
41
|
+
behavior: "allow";
|
|
42
|
+
updatedInput?: unknown;
|
|
43
|
+
} | {
|
|
44
|
+
behavior: "deny";
|
|
45
|
+
message: string;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Build the `control_response` envelope claude expects on stdin for a
|
|
49
|
+
* `can_use_tool` prompt. Pure + exported so the wire shape is unit-testable.
|
|
50
|
+
*
|
|
51
|
+
* Critically: the stdio control protocol's Zod schema requires `updatedInput`
|
|
52
|
+
* to be a RECORD on the allow branch — an allow decision that omits it makes
|
|
53
|
+
* claude reject the whole response (ZodError invalid_union) and the tool
|
|
54
|
+
* silently fails. So we default a missing `updatedInput` to `{}` here, at the
|
|
55
|
+
* single choke point where every decision becomes wire bytes, regardless of
|
|
56
|
+
* which caller (renderer, phone, future code) produced it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildControlResponse(requestId: string, decision: ControlDecision): {
|
|
59
|
+
type: string;
|
|
60
|
+
response: {
|
|
61
|
+
subtype: string;
|
|
62
|
+
request_id: string;
|
|
63
|
+
response: ControlDecision;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Parse ONE line of claude stream-json output into zero-or-more normalized
|
|
68
|
+
* events. Returns [] for lines we intentionally ignore (system/init/hook/
|
|
69
|
+
* rate_limit) or unparseable lines. Pure + synchronous so it is unit-testable
|
|
70
|
+
* against recorded output without spawning claude.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseStreamJsonLine(line: string): ResidentAgentEvent[];
|
|
73
|
+
export interface ResidentAgentOptions {
|
|
74
|
+
command: string;
|
|
75
|
+
cwd: string;
|
|
76
|
+
permissionMode: "default" | "acceptEdits" | "bypassPermissions";
|
|
77
|
+
resumeSessionId?: string;
|
|
78
|
+
/** Optional host policy appended to the external agent's system prompt. */
|
|
79
|
+
appendSystemPrompt?: string;
|
|
80
|
+
onEvent: (event: ResidentAgentEvent) => void;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A long-lived claude process in stream-json mode. Feed user turns via send();
|
|
84
|
+
* normalized events arrive on onEvent. Context is continuous for the lifetime
|
|
85
|
+
* of the process (same conversation). Stopped explicitly via stop().
|
|
86
|
+
*/
|
|
87
|
+
export declare class ResidentAgentProcess {
|
|
88
|
+
private readonly opts;
|
|
89
|
+
private child?;
|
|
90
|
+
constructor(opts: ResidentAgentOptions);
|
|
91
|
+
start(): void;
|
|
92
|
+
/** Feed one user turn. Returns false if the process isn't running. */
|
|
93
|
+
send(text: string): boolean;
|
|
94
|
+
/** Reply to a `control_request` (can_use_tool) approval prompt over stdin. */
|
|
95
|
+
respondControl(requestId: string, decision: ControlDecision): void;
|
|
96
|
+
isRunning(): boolean;
|
|
97
|
+
stop(): void;
|
|
98
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { pathWithCommonBins } from "./path-bins.js";
|
|
4
|
+
/**
|
|
5
|
+
* Build the `control_response` envelope claude expects on stdin for a
|
|
6
|
+
* `can_use_tool` prompt. Pure + exported so the wire shape is unit-testable.
|
|
7
|
+
*
|
|
8
|
+
* Critically: the stdio control protocol's Zod schema requires `updatedInput`
|
|
9
|
+
* to be a RECORD on the allow branch — an allow decision that omits it makes
|
|
10
|
+
* claude reject the whole response (ZodError invalid_union) and the tool
|
|
11
|
+
* silently fails. So we default a missing `updatedInput` to `{}` here, at the
|
|
12
|
+
* single choke point where every decision becomes wire bytes, regardless of
|
|
13
|
+
* which caller (renderer, phone, future code) produced it.
|
|
14
|
+
*/
|
|
15
|
+
export function buildControlResponse(requestId, decision) {
|
|
16
|
+
const normalized = decision.behavior === "allow"
|
|
17
|
+
? { behavior: "allow", updatedInput: decision.updatedInput ?? {} }
|
|
18
|
+
: decision;
|
|
19
|
+
return {
|
|
20
|
+
type: "control_response",
|
|
21
|
+
response: { subtype: "success", request_id: requestId, response: normalized },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const TOOL_SUMMARY_KEYS = ["command", "file_path", "path", "url", "pattern", "query"];
|
|
25
|
+
function argsSummary(input) {
|
|
26
|
+
if (!input)
|
|
27
|
+
return "";
|
|
28
|
+
for (const k of TOOL_SUMMARY_KEYS) {
|
|
29
|
+
const v = input[k];
|
|
30
|
+
if (typeof v === "string")
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse ONE line of claude stream-json output into zero-or-more normalized
|
|
37
|
+
* events. Returns [] for lines we intentionally ignore (system/init/hook/
|
|
38
|
+
* rate_limit) or unparseable lines. Pure + synchronous so it is unit-testable
|
|
39
|
+
* against recorded output without spawning claude.
|
|
40
|
+
*/
|
|
41
|
+
export function parseStreamJsonLine(line) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (!trimmed)
|
|
44
|
+
return [];
|
|
45
|
+
let msg;
|
|
46
|
+
try {
|
|
47
|
+
msg = JSON.parse(trimmed);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const out = [];
|
|
53
|
+
if (msg.type === "control_request" && msg.request?.subtype === "can_use_tool") {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
type: "approval_request",
|
|
57
|
+
requestId: String(msg.request_id ?? ""),
|
|
58
|
+
toolName: String(msg.request?.tool_name ?? "tool"),
|
|
59
|
+
displayName: typeof msg.request?.display_name === "string" ? msg.request.display_name : undefined,
|
|
60
|
+
input: msg.request?.input,
|
|
61
|
+
description: typeof msg.request?.description === "string" ? msg.request.description : undefined,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
if (msg.type === "assistant" && Array.isArray(msg.message?.content)) {
|
|
66
|
+
for (const c of msg.message.content) {
|
|
67
|
+
if (c.type === "text" && typeof c.text === "string") {
|
|
68
|
+
out.push({ type: "text", text: c.text });
|
|
69
|
+
}
|
|
70
|
+
else if (c.type === "tool_use") {
|
|
71
|
+
out.push({
|
|
72
|
+
type: "tool",
|
|
73
|
+
id: typeof c.id === "string" ? c.id : undefined,
|
|
74
|
+
tool: c.name ?? "tool",
|
|
75
|
+
summary: argsSummary(c.input),
|
|
76
|
+
input: c.input && typeof c.input === "object" ? c.input : undefined,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if (msg.type === "user" && Array.isArray(msg.message?.content)) {
|
|
83
|
+
for (const c of msg.message.content) {
|
|
84
|
+
if (c.type === "tool_result") {
|
|
85
|
+
const text = Array.isArray(c.content)
|
|
86
|
+
? c.content.map((p) => (typeof p?.text === "string" ? p.text : "")).join("")
|
|
87
|
+
: typeof c.content === "string"
|
|
88
|
+
? c.content
|
|
89
|
+
: "";
|
|
90
|
+
out.push({
|
|
91
|
+
type: "tool_result",
|
|
92
|
+
id: typeof c.tool_use_id === "string" ? c.tool_use_id : undefined,
|
|
93
|
+
summary: text.slice(0, 400),
|
|
94
|
+
isError: Boolean(c.is_error),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
if (msg.type === "result") {
|
|
101
|
+
out.push({
|
|
102
|
+
type: "turn_end",
|
|
103
|
+
reason: typeof msg.subtype === "string" ? msg.subtype : "completed",
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
// system (init/hook), rate_limit_event, etc. → ignored noise.
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A long-lived claude process in stream-json mode. Feed user turns via send();
|
|
112
|
+
* normalized events arrive on onEvent. Context is continuous for the lifetime
|
|
113
|
+
* of the process (same conversation). Stopped explicitly via stop().
|
|
114
|
+
*/
|
|
115
|
+
export class ResidentAgentProcess {
|
|
116
|
+
opts;
|
|
117
|
+
child;
|
|
118
|
+
constructor(opts) {
|
|
119
|
+
this.opts = opts;
|
|
120
|
+
}
|
|
121
|
+
start() {
|
|
122
|
+
if (this.child)
|
|
123
|
+
return;
|
|
124
|
+
const args = [
|
|
125
|
+
"--print",
|
|
126
|
+
"--verbose",
|
|
127
|
+
"--input-format",
|
|
128
|
+
"stream-json",
|
|
129
|
+
"--output-format",
|
|
130
|
+
"stream-json",
|
|
131
|
+
"--permission-prompt-tool",
|
|
132
|
+
"stdio",
|
|
133
|
+
];
|
|
134
|
+
if (this.opts.appendSystemPrompt) {
|
|
135
|
+
args.push("--append-system-prompt", this.opts.appendSystemPrompt);
|
|
136
|
+
}
|
|
137
|
+
if (this.opts.resumeSessionId) {
|
|
138
|
+
args.push("--resume", this.opts.resumeSessionId);
|
|
139
|
+
}
|
|
140
|
+
args.push("--permission-mode", this.opts.permissionMode);
|
|
141
|
+
const child = spawn(this.opts.command, args, {
|
|
142
|
+
cwd: this.opts.cwd,
|
|
143
|
+
env: { ...process.env, PATH: pathWithCommonBins() },
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
146
|
+
});
|
|
147
|
+
this.child = child;
|
|
148
|
+
if (child.stdout) {
|
|
149
|
+
const rl = createInterface({ input: child.stdout });
|
|
150
|
+
rl.on("line", (line) => {
|
|
151
|
+
for (const ev of parseStreamJsonLine(line))
|
|
152
|
+
this.opts.onEvent(ev);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
child.stderr?.on("data", (chunk) => {
|
|
156
|
+
// claude prints non-fatal warnings to stderr; surface as error events
|
|
157
|
+
// only if they look like real errors (cheap heuristic).
|
|
158
|
+
const text = String(chunk);
|
|
159
|
+
if (/error/i.test(text))
|
|
160
|
+
this.opts.onEvent({ type: "error", error: text.trim().slice(0, 400) });
|
|
161
|
+
});
|
|
162
|
+
child.on("error", (err) => {
|
|
163
|
+
const isMissing = err.code === "ENOENT";
|
|
164
|
+
this.opts.onEvent({
|
|
165
|
+
type: "error",
|
|
166
|
+
error: isMissing
|
|
167
|
+
? `未找到命令 "${this.opts.command}"。请先安装 Claude Code CLI 并确保它在 PATH 中。`
|
|
168
|
+
: err.message,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
child.on("exit", (code, signal) => {
|
|
172
|
+
this.child = undefined;
|
|
173
|
+
this.opts.onEvent({ type: "exit", code, signal });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Feed one user turn. Returns false if the process isn't running. */
|
|
177
|
+
send(text) {
|
|
178
|
+
if (!this.child?.stdin || this.child.stdin.destroyed)
|
|
179
|
+
return false;
|
|
180
|
+
const line = JSON.stringify({ type: "user", message: { role: "user", content: text } });
|
|
181
|
+
this.child.stdin.write(line + "\n");
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
/** Reply to a `control_request` (can_use_tool) approval prompt over stdin. */
|
|
185
|
+
respondControl(requestId, decision) {
|
|
186
|
+
if (!this.child?.stdin || this.child.stdin.destroyed)
|
|
187
|
+
return;
|
|
188
|
+
this.child.stdin.write(JSON.stringify(buildControlResponse(requestId, decision)) + "\n");
|
|
189
|
+
}
|
|
190
|
+
isRunning() {
|
|
191
|
+
return Boolean(this.child);
|
|
192
|
+
}
|
|
193
|
+
stop() {
|
|
194
|
+
const child = this.child;
|
|
195
|
+
this.child = undefined;
|
|
196
|
+
// Guard pid > 1: process.kill(-1) would SIGTERM every process the user can
|
|
197
|
+
// reach, and -0 the caller's own group. `!child?.pid` already catches
|
|
198
|
+
// undefined/0; the `> 1` also rules out the (theoretical) pid-1 case so this
|
|
199
|
+
// negative-pid group kill is consistent with killProcessGroup's guard.
|
|
200
|
+
if (!child?.pid || child.pid <= 1) {
|
|
201
|
+
child?.kill("SIGTERM");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
process.kill(-child.pid, "SIGTERM");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
try {
|
|
209
|
+
child.kill("SIGTERM");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* already gone */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|