@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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/dist/attachment-service.d.ts +80 -0
  3. package/dist/attachment-service.js +587 -0
  4. package/dist/bin/code-shell-serve.d.ts +2 -0
  5. package/dist/bin/code-shell-serve.js +7 -0
  6. package/dist/client-message-id.d.ts +1 -0
  7. package/dist/client-message-id.js +8 -0
  8. package/dist/image-byte-probe.d.ts +17 -0
  9. package/dist/image-byte-probe.js +330 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +10 -0
  12. package/dist/index.mobile-remote.d.ts +21 -0
  13. package/dist/index.mobile-remote.js +21 -0
  14. package/dist/index.serve.d.ts +3 -0
  15. package/dist/index.serve.js +3 -0
  16. package/dist/index.storage.d.ts +10 -0
  17. package/dist/index.storage.js +10 -0
  18. package/dist/index.worker.d.ts +2 -0
  19. package/dist/index.worker.js +2 -0
  20. package/dist/mobile-remote/access-passcode.d.ts +80 -0
  21. package/dist/mobile-remote/access-passcode.js +354 -0
  22. package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
  23. package/dist/mobile-remote/cloudflared-binary.js +242 -0
  24. package/dist/mobile-remote/codex-parse.d.ts +34 -0
  25. package/dist/mobile-remote/codex-parse.js +131 -0
  26. package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
  27. package/dist/mobile-remote/codex-room-agent.js +213 -0
  28. package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
  29. package/dist/mobile-remote/mobile-attachments.js +119 -0
  30. package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
  31. package/dist/mobile-remote/mobile-chat-turn.js +67 -0
  32. package/dist/mobile-remote/mobile-history.d.ts +22 -0
  33. package/dist/mobile-remote/mobile-history.js +27 -0
  34. package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
  35. package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
  36. package/dist/mobile-remote/mobile-static.d.ts +43 -0
  37. package/dist/mobile-remote/mobile-static.js +154 -0
  38. package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
  39. package/dist/mobile-remote/mobile-upload-service.js +370 -0
  40. package/dist/mobile-remote/pairing.d.ts +8 -0
  41. package/dist/mobile-remote/pairing.js +23 -0
  42. package/dist/mobile-remote/path-bins.d.ts +7 -0
  43. package/dist/mobile-remote/path-bins.js +16 -0
  44. package/dist/mobile-remote/pending-approvals.d.ts +14 -0
  45. package/dist/mobile-remote/pending-approvals.js +54 -0
  46. package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
  47. package/dist/mobile-remote/remote-host-manager.js +345 -0
  48. package/dist/mobile-remote/resident-agent.d.ts +98 -0
  49. package/dist/mobile-remote/resident-agent.js +216 -0
  50. package/dist/mobile-remote/room-manager.d.ts +286 -0
  51. package/dist/mobile-remote/room-manager.js +765 -0
  52. package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
  53. package/dist/mobile-remote/trusted-device-store.js +118 -0
  54. package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
  55. package/dist/mobile-remote/tunnel-manager.js +375 -0
  56. package/dist/mobile-remote/types.d.ts +1 -0
  57. package/dist/mobile-remote/types.js +1 -0
  58. package/dist/mobile-remote/viewer-identity.d.ts +5 -0
  59. package/dist/mobile-remote/viewer-identity.js +5 -0
  60. package/dist/serve/cli.d.ts +15 -0
  61. package/dist/serve/cli.js +115 -0
  62. package/dist/serve/headless-server.d.ts +40 -0
  63. package/dist/serve/headless-server.js +276 -0
  64. package/dist/sessions-service.d.ts +73 -0
  65. package/dist/sessions-service.js +303 -0
  66. package/dist/static-files.d.ts +1 -0
  67. package/dist/static-files.js +25 -0
  68. package/dist/worker-bridge-core.d.ts +154 -0
  69. package/dist/worker-bridge-core.js +315 -0
  70. 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
+ }