@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,370 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { Transform } from "node:stream";
7
+ export const MAX_MOBILE_IMAGE_BYTES = 10 * 1024 * 1024;
8
+ export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
9
+ export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
10
+ export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
11
+ const IMAGE_MIMES = new Set([
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ ]);
17
+ function normalizedContentType(value) {
18
+ return String(value ?? "")
19
+ .split(";", 1)[0]
20
+ .trim()
21
+ .toLowerCase();
22
+ }
23
+ function reply(res, status, message) {
24
+ if (res.headersSent || res.writableEnded)
25
+ return;
26
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
27
+ res.end(message);
28
+ }
29
+ /** One-time, device-bound HTTP upload tickets backed by a temporary spool. */
30
+ export class MobileUploadService {
31
+ opts;
32
+ uploads = new Map();
33
+ activeTransfers = new Map();
34
+ now;
35
+ uploadTtlMs;
36
+ startupCleanup;
37
+ timer;
38
+ disposed = false;
39
+ disposePromise;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ this.now = opts.now ?? (() => Date.now());
43
+ this.uploadTtlMs = opts.uploadTtlMs ?? MOBILE_UPLOAD_TTL_MS;
44
+ this.startupCleanup = this.cleanupOrphanSpools(this.now(), false);
45
+ const interval = opts.cleanupIntervalMs ?? 60_000;
46
+ if (interval > 0) {
47
+ this.timer = setInterval(() => void this.cleanupExpired().catch(() => undefined), interval);
48
+ this.timer.unref?.();
49
+ }
50
+ }
51
+ /** Wait until startup orphan recovery has completed. */
52
+ async ready() {
53
+ await this.startupCleanup;
54
+ }
55
+ begin(deviceId, metadata) {
56
+ if (this.disposed)
57
+ throw new Error("upload service is shutting down");
58
+ void this.cleanupExpired().catch(() => undefined);
59
+ const mime = normalizedContentType(metadata.mime);
60
+ if (!deviceId.trim())
61
+ throw new Error("authenticated device is required");
62
+ if (!metadata.clientId?.trim())
63
+ throw new Error("attachment clientId is required");
64
+ if (!metadata.name?.trim())
65
+ throw new Error("attachment name is required");
66
+ if (!IMAGE_MIMES.has(mime))
67
+ throw new Error("unsupported image MIME");
68
+ if (!Number.isSafeInteger(metadata.size) ||
69
+ metadata.size <= 0 ||
70
+ metadata.size > MAX_MOBILE_IMAGE_BYTES) {
71
+ throw new Error("attachment size is outside the allowed range");
72
+ }
73
+ let deviceTickets = 0;
74
+ for (const upload of this.uploads.values()) {
75
+ if (upload.deviceId === deviceId)
76
+ deviceTickets += 1;
77
+ }
78
+ if (deviceTickets >= MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE) {
79
+ throw new Error("too many pending attachment uploads for this device");
80
+ }
81
+ const uploadId = randomBytes(24).toString("base64url");
82
+ const expiresAt = this.now() + this.uploadTtlMs;
83
+ this.uploads.set(uploadId, {
84
+ ...metadata,
85
+ mime,
86
+ uploadId,
87
+ deviceId,
88
+ expiresAt,
89
+ status: "pending",
90
+ claimAttempts: 0,
91
+ });
92
+ return {
93
+ clientId: metadata.clientId,
94
+ uploadId,
95
+ putUrl: `/api/mobile/uploads/${uploadId}`,
96
+ expiresAt,
97
+ };
98
+ }
99
+ async acceptPut(uploadId, req, res) {
100
+ try {
101
+ await this.ready();
102
+ }
103
+ catch {
104
+ reply(res, 500, "upload storage is unavailable");
105
+ return;
106
+ }
107
+ if (this.disposed) {
108
+ reply(res, 503, "upload service is shutting down");
109
+ return;
110
+ }
111
+ const record = this.uploads.get(uploadId);
112
+ if (!record || record.expiresAt <= this.now()) {
113
+ if (record)
114
+ await this.expire(uploadId, record);
115
+ reply(res, 404, "upload ticket not found or expired");
116
+ return;
117
+ }
118
+ if (req.method !== "PUT") {
119
+ reply(res, 405, "method not allowed");
120
+ return;
121
+ }
122
+ if (record.status !== "pending") {
123
+ reply(res, 409, "upload ticket is already in use");
124
+ return;
125
+ }
126
+ if (normalizedContentType(req.headers["content-type"]) !== record.mime) {
127
+ reply(res, 415, "content type does not match ticket");
128
+ return;
129
+ }
130
+ const declaredLength = Number(req.headers["content-length"]);
131
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_MOBILE_IMAGE_BYTES) {
132
+ reply(res, 413, "upload is too large");
133
+ return;
134
+ }
135
+ if (Number.isFinite(declaredLength) && declaredLength !== record.size) {
136
+ reply(res, 400, "content length does not match ticket");
137
+ return;
138
+ }
139
+ // Reserve the ticket synchronously before the first filesystem await. Two
140
+ // near-simultaneous PUTs used to both observe `pending`, then race on the
141
+ // same .part path and reset/delete each other's record. The duplicate now
142
+ // receives the existing 409 above while this first request runs normally.
143
+ record.status = "uploading";
144
+ try {
145
+ await mkdir(this.opts.rootDir, { recursive: true });
146
+ }
147
+ catch {
148
+ if (this.uploads.get(uploadId) === record && record.status === "uploading") {
149
+ record.status = "pending";
150
+ }
151
+ reply(res, 500, "upload storage is unavailable");
152
+ return;
153
+ }
154
+ const partPath = join(this.opts.rootDir, `${uploadId}.part`);
155
+ const finalPath = join(this.opts.rootDir, `${uploadId}.upload`);
156
+ const hash = createHash("sha256");
157
+ let received = 0;
158
+ let deadlineExpired = false;
159
+ const counter = new Transform({
160
+ transform(chunk, _encoding, callback) {
161
+ received += chunk.byteLength;
162
+ if (received > MAX_MOBILE_IMAGE_BYTES) {
163
+ callback(new Error("upload exceeds size limit"));
164
+ return;
165
+ }
166
+ hash.update(chunk);
167
+ callback(null, chunk);
168
+ },
169
+ });
170
+ const controller = new AbortController();
171
+ const deadline = setTimeout(() => {
172
+ deadlineExpired = true;
173
+ controller.abort(new Error("upload ticket expired during transfer"));
174
+ }, Math.max(0, record.expiresAt - this.now()));
175
+ deadline.unref?.();
176
+ const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx" }), {
177
+ signal: controller.signal,
178
+ });
179
+ this.activeTransfers.set(uploadId, { controller, done: transfer });
180
+ try {
181
+ await transfer;
182
+ if (received !== record.size) {
183
+ await rm(partPath, { force: true });
184
+ record.status = "pending";
185
+ reply(res, 400, "received size does not match ticket");
186
+ return;
187
+ }
188
+ if (this.uploads.get(uploadId) !== record ||
189
+ record.status !== "uploading" ||
190
+ record.expiresAt <= this.now()) {
191
+ await rm(partPath, { force: true });
192
+ if (this.uploads.get(uploadId) === record)
193
+ this.uploads.delete(uploadId);
194
+ reply(res, 408, "upload ticket expired during transfer");
195
+ return;
196
+ }
197
+ await rename(partPath, finalPath);
198
+ if (this.uploads.get(uploadId) !== record ||
199
+ record.status !== "uploading" ||
200
+ record.expiresAt <= this.now()) {
201
+ await rm(finalPath, { force: true });
202
+ if (this.uploads.get(uploadId) === record)
203
+ this.uploads.delete(uploadId);
204
+ reply(res, 408, "upload ticket expired during transfer");
205
+ return;
206
+ }
207
+ record.status = "ready";
208
+ record.path = finalPath;
209
+ record.sha256 = hash.digest("hex");
210
+ reply(res, 201, "uploaded");
211
+ }
212
+ catch (error) {
213
+ await rm(partPath, { force: true }).catch(() => undefined);
214
+ const expired = deadlineExpired || record.expiresAt <= this.now();
215
+ if (this.uploads.get(uploadId) === record) {
216
+ if (expired || this.disposed)
217
+ this.uploads.delete(uploadId);
218
+ else
219
+ record.status = "pending";
220
+ }
221
+ reply(res, expired ? 408 : String(error.message).includes("size limit") ? 413 : 400, "upload failed");
222
+ }
223
+ finally {
224
+ clearTimeout(deadline);
225
+ if (this.activeTransfers.get(uploadId)?.done === transfer) {
226
+ this.activeTransfers.delete(uploadId);
227
+ }
228
+ }
229
+ }
230
+ /** Atomically move a ready upload into a request-owned lease. */
231
+ claim(deviceId, uploadId) {
232
+ const record = this.uploads.get(uploadId);
233
+ if (!record || record.expiresAt <= this.now()) {
234
+ if (record)
235
+ void this.expire(uploadId, record);
236
+ throw new Error("upload is not ready or no longer exists");
237
+ }
238
+ if (record.deviceId !== deviceId)
239
+ throw new Error("upload belongs to another device");
240
+ if (record.status !== "ready")
241
+ throw new Error("upload is already claimed or not ready");
242
+ if (record.claimAttempts >= MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS) {
243
+ void this.expire(uploadId, record);
244
+ throw new Error("upload claim retry limit exceeded");
245
+ }
246
+ const claimId = randomBytes(18).toString("base64url");
247
+ record.status = "claimed";
248
+ record.claimId = claimId;
249
+ record.claimAttempts += 1;
250
+ return {
251
+ uploadId,
252
+ claimId,
253
+ clientId: record.clientId,
254
+ name: record.name,
255
+ mime: record.mime,
256
+ size: record.size,
257
+ path: record.path,
258
+ sha256: record.sha256,
259
+ };
260
+ }
261
+ /** Release a failed staging/dispatch attempt for a bounded retry. */
262
+ async release(deviceId, uploadId, claimId) {
263
+ const record = this.assertClaimOwner(deviceId, uploadId, claimId);
264
+ delete record.claimId;
265
+ if (record.claimAttempts >= MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS) {
266
+ this.uploads.delete(uploadId);
267
+ if (record.path)
268
+ await rm(record.path, { force: true }).catch(() => undefined);
269
+ return;
270
+ }
271
+ record.status = "ready";
272
+ }
273
+ /** Finalize a successful turn; only the active claim owner may consume it. */
274
+ async finalize(deviceId, uploadId, claimId) {
275
+ const record = this.assertClaimOwner(deviceId, uploadId, claimId);
276
+ this.uploads.delete(uploadId);
277
+ if (record.path)
278
+ await rm(record.path, { force: true }).catch(() => undefined);
279
+ }
280
+ async cleanupExpired() {
281
+ await this.ready();
282
+ const now = this.now();
283
+ const expired = [];
284
+ for (const [uploadId, record] of this.uploads) {
285
+ if (record.expiresAt <= now)
286
+ expired.push(this.expire(uploadId, record));
287
+ }
288
+ await Promise.allSettled(expired);
289
+ await this.cleanupOrphanSpools(now, false);
290
+ }
291
+ /** Abort all in-flight transfers and invalidate every outstanding ticket. */
292
+ async cancelActiveTransfers() {
293
+ await this.ready();
294
+ const records = [...this.uploads.entries()];
295
+ for (const [uploadId] of records)
296
+ this.uploads.delete(uploadId);
297
+ for (const transfer of this.activeTransfers.values()) {
298
+ transfer.controller.abort(new Error("upload service stopped"));
299
+ }
300
+ await Promise.allSettled([...this.activeTransfers.values()].map((item) => item.done));
301
+ await Promise.allSettled(records.map(([uploadId, record]) => this.cleanupRecordFiles(uploadId, record)));
302
+ }
303
+ /** Fully await timer shutdown, active aborts, and every spool unlink. */
304
+ async dispose() {
305
+ if (this.disposePromise)
306
+ return this.disposePromise;
307
+ this.disposed = true;
308
+ if (this.timer)
309
+ clearInterval(this.timer);
310
+ this.disposePromise = (async () => {
311
+ await this.cancelActiveTransfers();
312
+ await this.cleanupOrphanSpools(this.now(), true);
313
+ })();
314
+ return this.disposePromise;
315
+ }
316
+ async expire(uploadId, record) {
317
+ if (this.uploads.get(uploadId) === record)
318
+ this.uploads.delete(uploadId);
319
+ const active = this.activeTransfers.get(uploadId);
320
+ if (active) {
321
+ active.controller.abort(new Error("upload ticket expired"));
322
+ await active.done.catch(() => undefined);
323
+ }
324
+ await this.cleanupRecordFiles(uploadId, record);
325
+ }
326
+ async cleanupRecordFiles(uploadId, record) {
327
+ await Promise.allSettled([
328
+ rm(join(this.opts.rootDir, `${uploadId}.part`), { force: true }),
329
+ rm(join(this.opts.rootDir, `${uploadId}.upload`), { force: true }),
330
+ ...(record?.path ? [rm(record.path, { force: true })] : []),
331
+ ]);
332
+ }
333
+ async cleanupOrphanSpools(now, removeAll) {
334
+ try {
335
+ await mkdir(this.opts.rootDir, { recursive: true });
336
+ }
337
+ catch {
338
+ return;
339
+ }
340
+ const entries = await readdir(this.opts.rootDir, { withFileTypes: true }).catch(() => []);
341
+ const activeIds = new Set([...this.uploads.keys(), ...this.activeTransfers.keys()]);
342
+ await Promise.allSettled(entries.map(async (entry) => {
343
+ if (!entry.isFile())
344
+ return;
345
+ const match = /^([A-Za-z0-9_-]+)\.(part|upload)$/.exec(entry.name);
346
+ if (!match || activeIds.has(match[1]))
347
+ return;
348
+ const path = join(this.opts.rootDir, entry.name);
349
+ if (!removeAll) {
350
+ const info = await stat(path).catch(() => undefined);
351
+ if (!info)
352
+ return;
353
+ if (info.mtimeMs + this.uploadTtlMs > now)
354
+ return;
355
+ }
356
+ await rm(path, { force: true });
357
+ }));
358
+ }
359
+ assertClaimOwner(deviceId, uploadId, claimId) {
360
+ const record = this.uploads.get(uploadId);
361
+ if (!record)
362
+ throw new Error("upload claim no longer exists");
363
+ if (record.deviceId !== deviceId)
364
+ throw new Error("upload belongs to another device");
365
+ if (record.status !== "claimed" || record.claimId !== claimId) {
366
+ throw new Error("upload claim owner does not match");
367
+ }
368
+ return record;
369
+ }
370
+ }
@@ -0,0 +1,8 @@
1
+ import type { PairingToken } from "./types.js";
2
+ export declare class PairingTokenManager {
3
+ private readonly now;
4
+ private tokens;
5
+ constructor(now?: () => number);
6
+ createToken(ttlMs?: number): PairingToken;
7
+ consume(value: string): boolean;
8
+ }
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from "node:crypto";
2
+ export class PairingTokenManager {
3
+ now;
4
+ tokens = new Map();
5
+ constructor(now = () => Date.now()) {
6
+ this.now = now;
7
+ }
8
+ createToken(ttlMs = 10 * 60 * 1000) {
9
+ const token = {
10
+ value: randomBytes(32).toString("base64url"),
11
+ expiresAt: this.now() + ttlMs,
12
+ };
13
+ this.tokens.set(token.value, token);
14
+ return token;
15
+ }
16
+ consume(value) {
17
+ const token = this.tokens.get(value);
18
+ if (!token)
19
+ return false;
20
+ this.tokens.delete(value);
21
+ return token.expiresAt >= this.now();
22
+ }
23
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * macOS GUI-launched Electron has a minimal PATH (no Homebrew). Prepend common
3
+ * CLI dirs so `claude` / `codex` resolve. Mirrors the external-agent adapter's
4
+ * fix. Standalone (no heavy imports) so both resident-agent and codex-room-agent
5
+ * can use it without dragging in core.
6
+ */
7
+ export declare function pathWithCommonBins(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,16 @@
1
+ import { delimiter } from "node:path";
2
+ /**
3
+ * macOS GUI-launched Electron has a minimal PATH (no Homebrew). Prepend common
4
+ * CLI dirs so `claude` / `codex` resolve. Mirrors the external-agent adapter's
5
+ * fix. Standalone (no heavy imports) so both resident-agent and codex-room-agent
6
+ * can use it without dragging in core.
7
+ */
8
+ export function pathWithCommonBins(env = process.env) {
9
+ const extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
10
+ const current = (env.PATH ?? "").split(delimiter).filter(Boolean);
11
+ const merged = [];
12
+ for (const dir of [...extra, ...current])
13
+ if (!merged.includes(dir))
14
+ merged.push(dir);
15
+ return merged.join(delimiter);
16
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tracks currently pending core approval requests for mobile clients.
3
+ *
4
+ * Session snapshots intentionally contain only stream events. Approval requests
5
+ * are stateful: keep the latest unresolved raw JSON-RPC request here and replay
6
+ * it to a phone when that phone binds to the matching session.
7
+ */
8
+ export declare class PendingMobileApprovals {
9
+ private readonly bySession;
10
+ observeOutboundLine(line: string): void;
11
+ resolve(requestId: string): void;
12
+ replayLines(sessionId: string): string[];
13
+ forgetSession(sessionId: string): void;
14
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Tracks currently pending core approval requests for mobile clients.
3
+ *
4
+ * Session snapshots intentionally contain only stream events. Approval requests
5
+ * are stateful: keep the latest unresolved raw JSON-RPC request here and replay
6
+ * it to a phone when that phone binds to the matching session.
7
+ */
8
+ export class PendingMobileApprovals {
9
+ bySession = new Map();
10
+ observeOutboundLine(line) {
11
+ let msg;
12
+ try {
13
+ msg = JSON.parse(line);
14
+ }
15
+ catch {
16
+ return;
17
+ }
18
+ if (msg.method === "agent/approvalRequest") {
19
+ const params = msg.params;
20
+ const sessionId = params?.sessionId;
21
+ const requestId = params?.requestId;
22
+ if (typeof sessionId !== "string" || !sessionId)
23
+ return;
24
+ if (typeof requestId !== "string" || !requestId)
25
+ return;
26
+ let pending = this.bySession.get(sessionId);
27
+ if (!pending) {
28
+ pending = new Map();
29
+ this.bySession.set(sessionId, pending);
30
+ }
31
+ pending.set(requestId, line);
32
+ return;
33
+ }
34
+ if (msg.method === "agent/approvalResolved") {
35
+ const params = msg.params;
36
+ const requestId = params?.requestId;
37
+ if (typeof requestId === "string" && requestId)
38
+ this.resolve(requestId);
39
+ }
40
+ }
41
+ resolve(requestId) {
42
+ for (const [sessionId, pending] of this.bySession) {
43
+ pending.delete(requestId);
44
+ if (pending.size === 0)
45
+ this.bySession.delete(sessionId);
46
+ }
47
+ }
48
+ replayLines(sessionId) {
49
+ return [...(this.bySession.get(sessionId)?.values() ?? [])];
50
+ }
51
+ forgetSession(sessionId) {
52
+ this.bySession.delete(sessionId);
53
+ }
54
+ }
@@ -0,0 +1,117 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { type WebSocket } from "ws";
3
+ import type { AccessPasscode } from "./access-passcode.js";
4
+ import type { TrustedDeviceStore } from "./trusted-device-store.js";
5
+ import type { MobileClientEvent, MobileServerEvent } from "./types.js";
6
+ import type { MobileUploadService } from "./mobile-upload-service.js";
7
+ /**
8
+ * Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
9
+ * remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
10
+ * §6.5 network-safety rule. Excludes loopback (127.x), link-local (169.254.x),
11
+ * and common VPN/tunnel ranges (198.18.x carrier-grade test net used by some
12
+ * VPN clients) so we don't advertise an address the phone can't route to.
13
+ * Returns undefined if no suitable interface is found.
14
+ */
15
+ export declare function resolveLanHost(): string | undefined;
16
+ export interface RemoteHostStartOptions {
17
+ host: string;
18
+ port: number;
19
+ /**
20
+ * "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
21
+ * 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
22
+ * front of every HTTP route and the WS upgrade.
23
+ */
24
+ mode?: "lan" | "tunnel";
25
+ /** Required in tunnel mode: the public access gate. Ignored in lan mode. */
26
+ passcode?: AccessPasscode;
27
+ }
28
+ export interface RemoteHostStarted {
29
+ host: string;
30
+ port: number;
31
+ url: string;
32
+ mode: "lan" | "tunnel";
33
+ }
34
+ export interface RemoteHostManagerOptions {
35
+ devices: TrustedDeviceStore;
36
+ onClientEvent: (event: unknown, ws: WebSocket) => void;
37
+ /**
38
+ * Absolute path to the built mobile app (out/mobile). Defaults to the
39
+ * sibling of the bundled main (out/main → ../mobile). Overridable for tests.
40
+ */
41
+ mobileRootDir?: string;
42
+ /**
43
+ * Dev only: when set (from scripts/dev.ts via MOBILE_DEV_URL), /mobile/* is
44
+ * proxied to the mobile vite dev server for HMR instead of read from disk.
45
+ */
46
+ mobileDevUrl?: string;
47
+ /** One-time upload tickets. The route remains behind the tunnel passcode gate. */
48
+ uploads?: Pick<MobileUploadService, "acceptPut" | "cancelActiveTransfers">;
49
+ }
50
+ export declare class RemoteHostManager extends EventEmitter {
51
+ private readonly opts;
52
+ private server?;
53
+ private wss?;
54
+ private started?;
55
+ private pairing;
56
+ /** ws → authenticated device id. A socket absent here is unauthenticated. */
57
+ private authed;
58
+ /** Stable identity for each individual tab/socket, independent of device id. */
59
+ private viewers;
60
+ private nextViewerId;
61
+ /** deviceId → number of live authenticated sockets (a phone may open more
62
+ * than one). A device is "online" while its count is > 0. */
63
+ private onlineCounts;
64
+ /** Public base URL (tunnel domain) used by createPairingUrl when set. */
65
+ private publicBaseUrl?;
66
+ /** Passcode gate, present only in tunnel mode. */
67
+ private passcode?;
68
+ /** Where the built mobile app lives (out/mobile). */
69
+ private readonly mobileRootDir;
70
+ /** Dev proxy target for /mobile (HMR); undefined in prod. */
71
+ private readonly mobileDevUrl?;
72
+ constructor(opts: RemoteHostManagerOptions);
73
+ /** Device ids with at least one live socket right now. */
74
+ onlineDeviceIds(): string[];
75
+ /** Register a live socket for a device; emits `online-change` only when the
76
+ * device transitions offline→online (first socket). */
77
+ markOnline(deviceId: string): void;
78
+ /** Drop a socket for a device; emits `online-change` only when its last
79
+ * socket goes away (online→offline). */
80
+ markOffline(deviceId: string): void;
81
+ start(options: RemoteHostStartOptions): Promise<RemoteHostStarted>;
82
+ /**
83
+ * Override the base URL used to build pairing URLs (tunnel mode points this
84
+ * at the `https://*.trycloudflare.com` domain so the QR encodes the public
85
+ * address, not the loopback bind). Pass undefined to clear it.
86
+ */
87
+ setPublicBaseUrl(url?: string): void;
88
+ createPairingUrl(): {
89
+ token: string;
90
+ url: string;
91
+ expiresAt: number;
92
+ };
93
+ /**
94
+ * Handle pairing/auth client events. Returns a server event for those two
95
+ * cases (consumed inline by the WS layer) or `undefined` for events the
96
+ * caller should route elsewhere. Pairing tokens are one-use; auth checks the
97
+ * trusted-device store (which rejects revoked devices), so the remote host
98
+ * never executes tools itself — it only gates transport.
99
+ */
100
+ handleClientEvent(event: MobileClientEvent): MobileServerEvent | undefined;
101
+ /** Broadcast a server event to every connected, authenticated mobile socket. */
102
+ broadcast(event: MobileServerEvent): void;
103
+ /** Send a server event to ONLY the sockets belonging to one device. Used for
104
+ * per-device replies (chat.accepted, session.list.ok, permission.mode, …) so
105
+ * one phone's session/permission state never leaks onto another's screen.
106
+ * A device with no live socket (raced a disconnect) is a silent no-op. */
107
+ sendToDevice(deviceId: string, event: MobileServerEvent): void;
108
+ /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
109
+ sendRawToDevice(deviceId: string, payload: string): void;
110
+ /** Broadcast a raw line (e.g. a mirrored worker→renderer JSON-RPC line) to
111
+ * every authenticated mobile socket. Unauthenticated sockets are skipped so
112
+ * a half-paired client never sees session output. */
113
+ broadcastRaw(payload: string): void;
114
+ private releaseSocket;
115
+ stop(): Promise<void>;
116
+ status(): RemoteHostStarted | undefined;
117
+ }