surf-cli 2.7.2 → 2.9.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 (45) hide show
  1. package/README.md +208 -13
  2. package/dist/content/index.js +116 -0
  3. package/dist/content/index.js.map +1 -0
  4. package/dist/manifest.json +2 -11
  5. package/dist/options/options.js +3 -3
  6. package/dist/options/options.js.map +1 -1
  7. package/dist/service-worker/index.js +261 -61
  8. package/dist/service-worker/index.js.map +1 -1
  9. package/native/abort.cjs +65 -0
  10. package/native/ai-queue.cjs +64 -0
  11. package/native/aistudio-build.cjs +21 -13
  12. package/native/aistudio-client.cjs +40 -20
  13. package/native/browser-lock.cjs +169 -0
  14. package/native/chatgpt-client.cjs +63 -30
  15. package/native/cli.cjs +947 -460
  16. package/native/client-transport.cjs +168 -0
  17. package/native/config.cjs +2 -2
  18. package/native/do-executor.cjs +25 -51
  19. package/native/do-parser.cjs +12 -0
  20. package/native/doctor.cjs +633 -0
  21. package/native/endpoint.cjs +174 -0
  22. package/native/file-transfer.cjs +734 -0
  23. package/native/gemini-client.cjs +244 -88
  24. package/native/grok-client.cjs +321 -212
  25. package/native/host-helpers.cjs +88 -16
  26. package/native/host-sessions.cjs +283 -0
  27. package/native/host.cjs +811 -616
  28. package/native/listener.cjs +20 -0
  29. package/native/mcp-server.cjs +60 -62
  30. package/native/network-export.cjs +113 -0
  31. package/native/perplexity-client.cjs +46 -17
  32. package/native/remote-auth.cjs +279 -0
  33. package/native/remote-transport.cjs +337 -0
  34. package/native/request-pending.cjs +148 -0
  35. package/native/socket-path.cjs +46 -0
  36. package/package.json +11 -9
  37. package/scripts/install-native-host.cjs +184 -51
  38. package/scripts/uninstall-native-host.cjs +93 -15
  39. package/skills/README.md +11 -5
  40. package/skills/deep-x-research/SKILL.md +106 -0
  41. package/skills/surf/SKILL.md +77 -22
  42. package/dist/content/accessibility-tree.js +0 -11
  43. package/dist/content/accessibility-tree.js.map +0 -1
  44. package/dist/content/visual-indicator.js +0 -111
  45. package/dist/content/visual-indicator.js.map +0 -1
package/native/host.cjs CHANGED
@@ -3,6 +3,8 @@ const net = require("net");
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
+ const { AsyncLocalStorage } = require("async_hooks");
7
+ const requestStorage = new AsyncLocalStorage();
6
8
  const https = require("https");
7
9
  const { execSync } = require("child_process");
8
10
  const { GoogleGenerativeAI } = require("@google/generative-ai");
@@ -12,13 +14,84 @@ const perplexityClient = require("./perplexity-client.cjs");
12
14
  const grokClient = require("./grok-client.cjs");
13
15
  const aistudioClient = require("./aistudio-client.cjs");
14
16
  const aistudioBuild = require("./aistudio-build.cjs");
15
- const { mapToolToMessage, mapComputerAction, formatToolContent } = require("./host-helpers.cjs");
17
+ const { mapToolToMessage, mapComputerAction, formatToolContent, buildProviderUploadMessage } = require("./host-helpers.cjs");
16
18
 
17
19
  const IS_WIN = process.platform === "win32";
18
- const SURF_TMP = IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp";
19
- const SOCKET_PATH = IS_WIN ? "//./pipe/surf" : "/tmp/surf.sock";
20
+ const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
21
+ const { parseListenEndpoint } = require("./listener.cjs");
22
+ const { getStateDir } = require("./remote-auth.cjs");
23
+ const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
24
+ const { HostSessionManager, resolveRequestDeadlineMs } = require("./host-sessions.cjs");
25
+ const { abortError, throwIfAborted } = require("./abort.cjs");
26
+ const { BoundedAiQueue } = require("./ai-queue.cjs");
27
+ const { RequestPendingMap } = require("./request-pending.cjs");
28
+ const { cleanupFilePaths, createStagingDirectory, createTransferState, materializeRemoteTool, rewriteTransferPaths, streamFileDownload, transferError } = require("./file-transfer.cjs");
29
+ const { writeNetworkExport } = require("./network-export.cjs");
30
+ const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
31
+ const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
32
+ ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
33
+ : null;
20
34
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
21
35
 
36
+ // The endpoint passed here is already validated by the caller. Keeping this
37
+ // lifecycle separate lets tests use an ephemeral loopback port without adding
38
+ // a localhost escape hatch to SURF_LISTEN parsing.
39
+ function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onFatal }) {
40
+ const localServer = net.createServer(handler);
41
+ const tcpServer = tcpEndpoint ? net.createServer(handler) : null;
42
+ let shuttingDown = false;
43
+ let startPromise = null;
44
+ const close = (server) => {
45
+ if (!server) return;
46
+ try { server.close(); } catch (error) {
47
+ if (error.code !== "ERR_SERVER_NOT_RUNNING") throw error;
48
+ }
49
+ };
50
+ const unlink = () => { if (!IS_WIN) { try { fs.unlinkSync(localPath); } catch {} } };
51
+ const listen = (server, options) => new Promise((resolve, reject) => {
52
+ server.once("error", reject);
53
+ server.listen(options, () => {
54
+ server.removeListener("error", reject);
55
+ if (shuttingDown) { close(server); unlink(); }
56
+ resolve();
57
+ });
58
+ });
59
+ const start = () => {
60
+ if (startPromise) return startPromise;
61
+ startPromise = (async () => {
62
+ try {
63
+ await listen(localServer, localPath);
64
+ if (shuttingDown) return false;
65
+ if (!IS_WIN) { try { fs.chmodSync(localPath, 0o600); } catch {} }
66
+ if (tcpServer) {
67
+ await listen(tcpServer, tcpEndpoint);
68
+ if (shuttingDown) return false;
69
+ }
70
+ onReady();
71
+ return true;
72
+ } catch (error) {
73
+ if (!shuttingDown) onFatal(error);
74
+ close(localServer); close(tcpServer); unlink();
75
+ return false;
76
+ } finally {
77
+ if (shuttingDown) { close(localServer); close(tcpServer); unlink(); }
78
+ }
79
+ })();
80
+ return startPromise;
81
+ };
82
+ return {
83
+ localServer,
84
+ tcpServer,
85
+ start,
86
+ async shutdown() {
87
+ shuttingDown = true;
88
+ close(localServer); close(tcpServer); unlink();
89
+ if (startPromise) await startPromise;
90
+ unlink();
91
+ },
92
+ };
93
+ }
94
+
22
95
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
23
96
  function resizeImage(filePath, maxSize) {
24
97
  const platform = process.platform;
@@ -55,29 +128,9 @@ function resizeImage(filePath, maxSize) {
55
128
  }
56
129
  }
57
130
 
58
- const aiRequestQueue = [];
59
- let aiRequestInProgress = false;
60
-
61
- function queueAiRequest(handler) {
62
- return new Promise((resolve, reject) => {
63
- aiRequestQueue.push({ handler, resolve, reject });
64
- processAiQueue();
65
- });
66
- }
67
-
68
- async function processAiQueue() {
69
- if (aiRequestInProgress || aiRequestQueue.length === 0) return;
70
- aiRequestInProgress = true;
71
- const { handler, resolve, reject } = aiRequestQueue.shift();
72
- try {
73
- const result = await handler();
74
- resolve(result);
75
- } catch (err) {
76
- reject(err);
77
- } finally {
78
- aiRequestInProgress = false;
79
- setTimeout(processAiQueue, 2000);
80
- }
131
+ let aiQueue;
132
+ function queueAiRequest(handler, request = requestStorage.getStore()) {
133
+ return aiQueue.enqueue(handler, request);
81
134
  }
82
135
  const LOG_FILE = path.join(SURF_TMP, "surf-host.log");
83
136
  const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
@@ -90,7 +143,8 @@ const DEFAULT_RETRY_OPTIONS = {
90
143
  retryableStatusCodes: [429, 500, 502, 503, 504]
91
144
  };
92
145
 
93
- async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount = 0) {
146
+ async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount = 0, signal) {
147
+ throwIfAborted(signal);
94
148
  try {
95
149
  return await fn();
96
150
  } catch (error) {
@@ -126,8 +180,8 @@ async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount =
126
180
  const jitter = 0.8 + Math.random() * 0.4;
127
181
  const delayWithJitter = Math.floor(delay * jitter);
128
182
 
129
- await new Promise(resolve => setTimeout(resolve, delayWithJitter));
130
- return withRetry(fn, retryOptions, retryCount + 1);
183
+ await require("./abort.cjs").abortableDelay(delayWithJitter, signal);
184
+ return withRetry(fn, retryOptions, retryCount + 1, signal);
131
185
  }
132
186
  }
133
187
 
@@ -195,13 +249,14 @@ class GeminiClient {
195
249
 
196
250
  async analyze(query, pageContext, options = {}) {
197
251
  const mode = options.mode || detectQueryMode(query);
252
+ throwIfAborted(options.signal);
198
253
  const promptFn = AI_PROMPTS[mode];
199
254
  const prompt = promptFn(query, pageContext);
200
255
 
201
256
  const result = await withRetry(async () => {
202
257
  const response = await this.model.generateContent(prompt);
203
258
  return response.response.text();
204
- });
259
+ }, DEFAULT_RETRY_OPTIONS, 0, options.signal);
205
260
 
206
261
  let content = result.trim();
207
262
 
@@ -292,57 +347,238 @@ const log = (msg) => {
292
347
  fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${msg}\n`);
293
348
  };
294
349
 
350
+ if (require.main === module) {
295
351
  log("Host starting...");
296
352
 
297
353
  if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
298
354
 
299
355
  const pendingRequests = new Map();
300
- const pendingToolRequests = new Map();
356
+ const pendingToolRequests = new RequestPendingMap({ getRequest: () => requestStorage.getStore() });
301
357
  const activeStreams = new Map();
358
+ const socketContexts = new WeakMap();
359
+ const socketWriters = new WeakMap();
302
360
  let requestCounter = 0;
303
361
 
304
- function sendToolResponse(socket, id, result, error) {
305
- const response = { type: "tool_response", id };
306
-
307
- if (error) {
308
- response.error = { content: [{ type: "text", text: error }] };
309
- } else {
310
- response.result = { content: formatToolContent(result, log) };
362
+ function auditSession(event) {
363
+ const context = event.context;
364
+ const principal = context?.principal;
365
+ const request = event.request;
366
+ log(`SESSION ${JSON.stringify({
367
+ event: event.event,
368
+ outcome: event.outcome,
369
+ principalId: principal?.clientId || "local",
370
+ principalLabel: principal?.label || "local",
371
+ peer: context?.socket?.remoteAddress || "local",
372
+ requestId: request?.id,
373
+ tool: request?.tool,
374
+ elapsedMs: event.elapsedMs,
375
+ })}`);
376
+ }
377
+
378
+ aiQueue = new BoundedAiQueue({
379
+ maxQueued: 8,
380
+ audit: (event) => auditSession(event),
381
+ run: (handler, request) => request
382
+ ? requestStorage.run(request, () => handler())
383
+ : handler(),
384
+ });
385
+
386
+ function sendSocket(socket, value, options = {}) {
387
+ const writer = socketWriters.get(socket);
388
+ return writer ? writer.send(value, options) : writeFrame(socket, value);
389
+ }
390
+
391
+ function sendOwnedExtensionMessage(request, message) {
392
+ const cleanupMessage = typeof message?.type === "string" && /(?:CLOSE_TAB|TAB_CLOSE)$/.test(message.type);
393
+ if (request?.hardBoundary) throwIfAborted(request.signal, "Request timed out");
394
+ if (!cleanupMessage) throwIfAborted(request?.signal, "Request cancelled");
395
+ writeMessage(message);
396
+ }
397
+
398
+ function requestCallExtension(request, tool, message, timeoutMs = 30000, cleanup = false) {
399
+ cleanup = cleanup || tool === "close_tab";
400
+ if (request?.hardBoundary) throwIfAborted(request.signal, "Request timed out");
401
+ if (!cleanup) throwIfAborted(request?.signal, "Request cancelled");
402
+ return new Promise((resolve, reject) => {
403
+ const id = ++requestCounter;
404
+ const timer = setTimeout(() => {
405
+ pendingToolRequests.expire(id, new Error(`Timeout waiting for extension: ${tool}`));
406
+ }, timeoutMs);
407
+ const pending = {
408
+ request,
409
+ cleanup,
410
+ tool,
411
+ resolve: (result) => {
412
+ clearTimeout(timer);
413
+ resolve(result);
414
+ },
415
+ reject: (error) => {
416
+ clearTimeout(timer);
417
+ reject(error);
418
+ },
419
+ };
420
+ pendingToolRequests.set(id, pending);
421
+ try {
422
+ if (cleanup) writeMessage({ ...message, id });
423
+ else sendOwnedExtensionMessage(request, { ...message, id });
424
+ } catch (error) {
425
+ pendingToolRequests.delete(id);
426
+ reject(error);
427
+ }
428
+ });
429
+ }
430
+
431
+ const sessionManager = new HostSessionManager({
432
+ audit: auditSession,
433
+ onTimeout(context, request) {
434
+ pendingToolRequests.hardDeadline(request);
435
+ const response = {
436
+ type: "tool_response",
437
+ id: request.id,
438
+ error: { content: [{ type: "text", text: "Request timed out" }] },
439
+ };
440
+ cleanupRequestTransfers(request)
441
+ .then(() => {
442
+ sessionManager.complete(context, request.id, "hard-timeout");
443
+ if (!context.closed) return sendSocket(context.socket, response);
444
+ })
445
+ .catch((error) => log(`Error settling timed-out request: ${error.message}`));
446
+ },
447
+ });
448
+
449
+ async function discardRequestTransfers(message, state) {
450
+ if (!state) return;
451
+ const ids = [];
452
+ for (const entry of message?._surfTransfers?.uploads || []) if (typeof entry?.transferId === "string") ids.push(entry.transferId);
453
+ for (const entry of message?._surfTransfers?.downloads || []) if (typeof entry?.transferId === "string") ids.push(entry.transferId);
454
+ await Promise.all([...new Set(ids)].map((id) => state.discardCompleted(id)));
455
+ }
456
+
457
+ async function applyRequestTransfers(msg, request, transferState, getTransferState) {
458
+ if (!request.context?.isRemote) return;
459
+ const materialized = await materializeRemoteTool({
460
+ tool: request.tool,
461
+ args: msg.params?.args || {},
462
+ metadata: msg._surfTransfers,
463
+ pathRefs: msg._surfPaths || [],
464
+ transferState,
465
+ getTransferState,
466
+ });
467
+ request.transferState = materialized.transferState;
468
+ request.outputTransfers = materialized.outputTransfers;
469
+ request.pathRewrites = materialized.pathRewrites;
470
+ request.transferCleanup = materialized.transferCleanup;
471
+ msg.params = { ...msg.params, args: materialized.args };
472
+ }
473
+
474
+ async function cleanupRequestTransfers(request) {
475
+ if (!request || request.transferCleanupStarted) return request?.transferCleanupPromise;
476
+ request.transferCleanupStarted = true;
477
+ const paths = request.transferCleanup || [];
478
+ request.transferCleanup = [];
479
+ request.transferCleanupPromise = cleanupFilePaths(paths);
480
+ await request.transferCleanupPromise;
481
+ return request.transferCleanupPromise;
482
+ }
483
+
484
+ function completeOwnedRequest(context, id, outcome) {
485
+ const request = context?.activeRequest;
486
+ if (!request || request.id !== id) return Promise.resolve();
487
+ if (request.pendingEntries?.size && !request.hardBoundary) {
488
+ if (request.completionRequested) return request.completionPromise;
489
+ request.completionRequested = true;
490
+ request.completionOutcome = outcome;
491
+ request.completionPromise = new Promise((resolve) => {
492
+ pendingToolRequests.onDrain(request, () => {
493
+ sessionManager.complete(context, id, request.completionOutcome);
494
+ resolve();
495
+ });
496
+ });
497
+ return request.completionPromise;
311
498
  }
312
-
313
- try {
314
- socket.write(JSON.stringify(response) + "\n");
315
- } catch (e) {
316
- log(`Error sending tool_response: ${e.message}`);
499
+ sessionManager.complete(context, id, outcome);
500
+ return Promise.resolve();
501
+ }
502
+
503
+ async function sendRequestDownloads(context, request, result) {
504
+ if (!request) return result;
505
+ let rewritten = result;
506
+ for (const output of request.outputTransfers || []) {
507
+ await streamFileDownload({
508
+ writer: { send: (frame) => sendSocket(context.socket, frame) },
509
+ state: request.transferState,
510
+ filePath: output.path,
511
+ transferId: output.transferId,
512
+ original: output.original,
513
+ });
317
514
  }
515
+ rewritten = rewriteTransferPaths(rewritten, request.pathRewrites || []);
516
+ return rewritten;
517
+ }
518
+
519
+ function sendToolResponse(socket, id, result, error) {
520
+ const context = socketContexts.get(socket);
521
+ if (context && !sessionManager.canRespond(context, id)) return;
522
+ const request = context?.activeRequest;
523
+ let finalError = error;
524
+ (async () => {
525
+ let output = result;
526
+ try {
527
+ if (!error) output = await sendRequestDownloads(context, request, result);
528
+ } catch (transferFailure) {
529
+ finalError = transferFailure.message;
530
+ }
531
+ if (finalError && request) {
532
+ finalError = rewriteTransferPaths(finalError, request.pathRewrites || []);
533
+ }
534
+ await cleanupRequestTransfers(request);
535
+ if (request?.settled) return;
536
+ const outcome = request?.signal.aborted
537
+ ? (request.tombstoned ? "cleanup-settled" : "cancelled")
538
+ : finalError ? "error" : "completed";
539
+ await completeOwnedRequest(context, id, outcome);
540
+ const response = { type: "tool_response", id };
541
+ if (finalError) response.error = { content: [{ type: "text", text: finalError }] };
542
+ else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
543
+ if (!context?.closed) await sendSocket(socket, response);
544
+ })().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
545
+ }
546
+
547
+ function stopActiveStream(streamId, { notifyExtension = true } = {}) {
548
+ const stream = activeStreams.get(streamId);
549
+ if (!stream) return;
550
+ activeStreams.delete(streamId);
551
+ sessionManager.stopStream(socketContexts.get(stream.socket));
552
+ if (notifyExtension) writeMessage({ type: "STREAM_STOP", streamId });
318
553
  }
319
554
 
320
555
  function handleStreamRequest(msg, socket) {
321
556
  const { streamType, options, id: originalId } = msg;
322
557
  const tabId = msg.tabId;
323
558
  const streamId = ++requestCounter;
324
-
559
+
325
560
  activeStreams.set(streamId, {
326
561
  socket,
327
562
  originalId,
328
563
  streamType,
329
564
  });
330
-
565
+
331
566
  writeMessage({
332
567
  type: streamType,
333
568
  streamId,
334
569
  options: options || {},
335
570
  tabId,
336
571
  });
337
-
338
- try {
339
- socket.write(JSON.stringify({ type: "stream_started", streamId }) + "\n");
340
- } catch (e) {
341
- log(`Error sending stream_started: ${e.message}`);
342
- }
572
+
573
+ sendSocket(socket, { type: "stream_started", streamId }, { stream: true }).catch((error) => {
574
+ log(`Error sending stream_started: ${error.message}`);
575
+ stopActiveStream(streamId);
576
+ socket.destroy(error);
577
+ });
343
578
  }
344
579
 
345
- function handleToolRequest(msg, socket) {
580
+ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore()) {
581
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
346
582
  const { method, params } = msg;
347
583
  const originalId = msg.id || null;
348
584
 
@@ -384,14 +620,14 @@ function handleToolRequest(msg, socket) {
384
620
  }
385
621
 
386
622
  if (extensionMsg.type === "LOCAL_WAIT") {
387
- setTimeout(() => {
388
- sendToolResponse(socket, originalId, { success: true }, null);
389
- }, extensionMsg.seconds * 1000);
623
+ require("./abort.cjs").abortableDelay(extensionMsg.seconds * 1000, requestContext.signal)
624
+ .then(() => sendToolResponse(socket, originalId, { success: true }, null))
625
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
390
626
  return;
391
627
  }
392
628
 
393
629
  if (extensionMsg.type === "BATCH_EXECUTE") {
394
- executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId);
630
+ executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId, requestContext);
395
631
  return;
396
632
  }
397
633
 
@@ -407,46 +643,23 @@ function handleToolRequest(msg, socket) {
407
643
  return;
408
644
  }
409
645
 
410
- const pageRequestId = ++requestCounter;
411
- pendingToolRequests.set(pageRequestId, {
412
- socket: null,
413
- originalId: null,
414
- tool: "read_page",
415
- onComplete: async (pageResult) => {
416
- if (pageResult.error) {
417
- sendToolResponse(socket, originalId, null, `Failed to read page: ${pageResult.error}`);
418
- return;
419
- }
420
-
421
- const pageContent = pageResult.pageContent || "";
422
- if (!pageContent) {
423
- sendToolResponse(socket, originalId, null, "No page content available");
424
- return;
425
- }
426
-
427
- try {
428
- const gemini = getGeminiClient(apiKey);
429
- const result = await gemini.analyze(extensionMsg.query, pageContent, { mode: extensionMsg.mode });
430
-
431
- if (result.mode === "find") {
432
- sendToolResponse(socket, originalId, {
433
- ref: result.content === "NOT_FOUND" ? null : result.content,
434
- mode: result.mode,
435
- aiResult: true
436
- }, null);
437
- } else {
438
- sendToolResponse(socket, originalId, {
439
- content: result.content,
440
- mode: result.mode,
441
- aiResult: true
442
- }, null);
443
- }
444
- } catch (err) {
445
- sendToolResponse(socket, originalId, null, `AI analysis failed: ${err.message}`);
446
- }
447
- }
646
+ requestCallExtension(
647
+ requestContext,
648
+ "read_page",
649
+ { type: "READ_PAGE", options: { filter: "interactive" }, tabId: extensionMsg.tabId },
650
+ 45000,
651
+ ).then(async (pageResult) => {
652
+ if (pageResult.error) throw new Error(`Failed to read page: ${pageResult.error}`);
653
+ const pageContent = pageResult.pageContent || "";
654
+ if (!pageContent) throw new Error("No page content available");
655
+ const gemini = getGeminiClient(apiKey);
656
+ const result = await gemini.analyze(extensionMsg.query, pageContent, { mode: extensionMsg.mode, signal: requestContext.signal });
657
+ return result.mode === "find"
658
+ ? { ref: result.content === "NOT_FOUND" ? null : result.content, mode: result.mode, aiResult: true }
659
+ : { content: result.content, mode: result.mode, aiResult: true };
660
+ }).then((result) => sendToolResponse(socket, originalId, result, null)).catch((err) => {
661
+ sendToolResponse(socket, originalId, null, err.message);
448
662
  });
449
- writeMessage({ type: "READ_PAGE", options: { filter: "interactive" }, tabId: extensionMsg.tabId, id: pageRequestId });
450
663
  return;
451
664
  }
452
665
 
@@ -456,16 +669,12 @@ function handleToolRequest(msg, socket) {
456
669
  queueAiRequest(async () => {
457
670
  let pageContext = null;
458
671
  if (withPage) {
459
- const pageResult = await new Promise((resolve) => {
460
- const pageId = ++requestCounter;
461
- pendingToolRequests.set(pageId, {
462
- socket: null,
463
- originalId: null,
464
- tool: "read_page",
465
- onComplete: resolve
466
- });
467
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
468
- });
672
+ const pageResult = await requestCallExtension(
673
+ requestContext,
674
+ "read_page",
675
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
676
+ 45000,
677
+ );
469
678
  if (pageResult && !pageResult.error) {
470
679
  pageContext = {
471
680
  url: pageResult.url,
@@ -481,59 +690,36 @@ function handleToolRequest(msg, socket) {
481
690
 
482
691
  const result = await chatgptClient.query({
483
692
  prompt: fullPrompt,
693
+ signal: requestContext.signal,
484
694
  model,
485
695
  file,
486
696
  timeout,
487
- getCookies: () => new Promise((resolve) => {
488
- const cookieId = ++requestCounter;
489
- pendingToolRequests.set(cookieId, {
490
- socket: null,
491
- originalId: null,
492
- tool: "get_cookies",
493
- onComplete: (r) => resolve(r)
494
- });
495
- writeMessage({ type: "GET_CHATGPT_COOKIES", id: cookieId });
496
- }),
497
- createTab: () => new Promise((resolve) => {
498
- const tabCreateId = ++requestCounter;
499
- pendingToolRequests.set(tabCreateId, {
500
- socket: null,
501
- originalId: null,
502
- tool: "create_tab",
503
- onComplete: (r) => resolve(r)
504
- });
505
- writeMessage({ type: "CHATGPT_NEW_TAB", id: tabCreateId });
506
- }),
507
- closeTab: (tabIdToClose) => new Promise((resolve) => {
508
- const tabCloseId = ++requestCounter;
509
- pendingToolRequests.set(tabCloseId, {
510
- socket: null,
511
- originalId: null,
512
- tool: "close_tab",
513
- onComplete: (r) => resolve(r)
514
- });
515
- writeMessage({ type: "CHATGPT_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
516
- }),
517
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
518
- const evalId = ++requestCounter;
519
- pendingToolRequests.set(evalId, {
520
- socket: null,
521
- originalId: null,
522
- tool: "cdp_evaluate",
523
- onComplete: (r) => resolve(r)
524
- });
525
- writeMessage({ type: "CHATGPT_EVALUATE", tabId, expression, id: evalId });
526
- }),
527
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
528
- const cmdId = ++requestCounter;
529
- pendingToolRequests.set(cmdId, {
530
- socket: null,
531
- originalId: null,
532
- tool: "cdp_command",
533
- onComplete: (r) => resolve(r)
534
- });
535
- writeMessage({ type: "CHATGPT_CDP_COMMAND", tabId, method, params, id: cmdId });
536
- }),
697
+ getCookies: () => requestCallExtension(
698
+ requestContext,
699
+ "get_cookies",
700
+ { type: "GET_CHATGPT_COOKIES" },
701
+ ),
702
+ createTab: () => requestCallExtension(
703
+ requestContext,
704
+ "create_tab",
705
+ { type: "CHATGPT_NEW_TAB" },
706
+ ),
707
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "CHATGPT_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
708
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
709
+ requestContext,
710
+ "cdp_evaluate",
711
+ { type: "CHATGPT_EVALUATE", tabId, expression },
712
+ ),
713
+ cdpCommand: (tabId, method, params) => requestCallExtension(
714
+ requestContext,
715
+ "cdp_command",
716
+ { type: "CHATGPT_CDP_COMMAND", tabId, method, params },
717
+ ),
718
+ uploadFile: (tabId, filePaths) => requestCallExtension(
719
+ requestContext,
720
+ "upload_file",
721
+ buildProviderUploadMessage("chatgpt", tabId, filePaths),
722
+ ),
537
723
  log: (msg) => log(`[chatgpt] ${msg}`)
538
724
  });
539
725
 
@@ -557,16 +743,12 @@ function handleToolRequest(msg, socket) {
557
743
  queueAiRequest(async () => {
558
744
  let pageContext = null;
559
745
  if (withPage) {
560
- const pageResult = await new Promise((resolve) => {
561
- const pageId = ++requestCounter;
562
- pendingToolRequests.set(pageId, {
563
- socket: null,
564
- originalId: null,
565
- tool: "read_page",
566
- onComplete: resolve
567
- });
568
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
569
- });
746
+ const pageResult = await requestCallExtension(
747
+ requestContext,
748
+ "read_page",
749
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
750
+ 45000,
751
+ );
570
752
  if (pageResult && !pageResult.error) {
571
753
  pageContext = {
572
754
  url: pageResult.url,
@@ -582,49 +764,26 @@ function handleToolRequest(msg, socket) {
582
764
 
583
765
  const result = await perplexityClient.query({
584
766
  prompt: fullPrompt,
767
+ signal: requestContext.signal,
585
768
  mode: mode || 'search',
586
769
  model,
587
770
  timeout: timeout || 120000,
588
- createTab: () => new Promise((resolve) => {
589
- const tabCreateId = ++requestCounter;
590
- pendingToolRequests.set(tabCreateId, {
591
- socket: null,
592
- originalId: null,
593
- tool: "create_tab",
594
- onComplete: (r) => resolve(r)
595
- });
596
- writeMessage({ type: "PERPLEXITY_NEW_TAB", id: tabCreateId });
597
- }),
598
- closeTab: (tabIdToClose) => new Promise((resolve) => {
599
- const tabCloseId = ++requestCounter;
600
- pendingToolRequests.set(tabCloseId, {
601
- socket: null,
602
- originalId: null,
603
- tool: "close_tab",
604
- onComplete: (r) => resolve(r)
605
- });
606
- writeMessage({ type: "PERPLEXITY_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
607
- }),
608
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
609
- const evalId = ++requestCounter;
610
- pendingToolRequests.set(evalId, {
611
- socket: null,
612
- originalId: null,
613
- tool: "cdp_evaluate",
614
- onComplete: (r) => resolve(r)
615
- });
616
- writeMessage({ type: "PERPLEXITY_EVALUATE", tabId, expression, id: evalId });
617
- }),
618
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
619
- const cmdId = ++requestCounter;
620
- pendingToolRequests.set(cmdId, {
621
- socket: null,
622
- originalId: null,
623
- tool: "cdp_command",
624
- onComplete: (r) => resolve(r)
625
- });
626
- writeMessage({ type: "PERPLEXITY_CDP_COMMAND", tabId, method, params, id: cmdId });
627
- }),
771
+ createTab: () => requestCallExtension(
772
+ requestContext,
773
+ "create_tab",
774
+ { type: "PERPLEXITY_NEW_TAB" },
775
+ ),
776
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "PERPLEXITY_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
777
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
778
+ requestContext,
779
+ "cdp_evaluate",
780
+ { type: "PERPLEXITY_EVALUATE", tabId, expression },
781
+ ),
782
+ cdpCommand: (tabId, method, params) => requestCallExtension(
783
+ requestContext,
784
+ "cdp_command",
785
+ { type: "PERPLEXITY_CDP_COMMAND", tabId, method, params },
786
+ ),
628
787
  log: (msg) => log(`[perplexity] ${msg}`)
629
788
  });
630
789
 
@@ -652,16 +811,12 @@ function handleToolRequest(msg, socket) {
652
811
  // 1. Get page context if requested
653
812
  let pageContext = null;
654
813
  if (withPage) {
655
- const pageResult = await new Promise((resolve) => {
656
- const pageId = ++requestCounter;
657
- pendingToolRequests.set(pageId, {
658
- socket: null,
659
- originalId: null,
660
- tool: "get_page_text",
661
- onComplete: resolve
662
- });
663
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
664
- });
814
+ const pageResult = await requestCallExtension(
815
+ requestContext,
816
+ "get_page_text",
817
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
818
+ 45000,
819
+ );
665
820
  if (pageResult && !pageResult.error) {
666
821
  pageContext = {
667
822
  url: pageResult.url,
@@ -679,7 +834,8 @@ function handleToolRequest(msg, socket) {
679
834
  // 3. Call Gemini client
680
835
  const result = await geminiClient.query({
681
836
  prompt: fullPrompt,
682
- model: model || "gemini-3-pro",
837
+ signal: requestContext.signal,
838
+ model: model || "gemini-3.1-pro",
683
839
  file,
684
840
  generateImage,
685
841
  editImage,
@@ -687,67 +843,32 @@ function handleToolRequest(msg, socket) {
687
843
  youtube,
688
844
  aspectRatio,
689
845
  timeout: timeout || 300000,
690
- getCookies: () => new Promise((resolve) => {
691
- const cookieId = ++requestCounter;
692
- pendingToolRequests.set(cookieId, {
693
- socket: null,
694
- originalId: null,
695
- tool: "get_cookies",
696
- onComplete: (r) => resolve(r)
697
- });
698
- writeMessage({ type: "GET_GOOGLE_COOKIES", id: cookieId });
699
- }),
700
- createTab: () => new Promise((resolve) => {
701
- const tabCreateId = ++requestCounter;
702
- pendingToolRequests.set(tabCreateId, {
703
- socket: null,
704
- originalId: null,
705
- tool: "create_tab",
706
- onComplete: (r) => resolve(r)
707
- });
708
- writeMessage({ type: "GEMINI_NEW_TAB", id: tabCreateId });
709
- }),
710
- closeTab: (tabIdToClose) => new Promise((resolve) => {
711
- const tabCloseId = ++requestCounter;
712
- pendingToolRequests.set(tabCloseId, {
713
- socket: null,
714
- originalId: null,
715
- tool: "close_tab",
716
- onComplete: (r) => resolve(r)
717
- });
718
- writeMessage({ type: "GEMINI_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
719
- }),
720
- jsEval: (tabId, code) => new Promise((resolve) => {
721
- const jsId = ++requestCounter;
722
- pendingToolRequests.set(jsId, {
723
- socket: null,
724
- originalId: null,
725
- tool: "js_eval",
726
- onComplete: (r) => resolve(r)
727
- });
728
- log(`[gemini] Sending EXECUTE_JAVASCRIPT id=${jsId} tabId=${tabId} code=${code.length} chars`);
729
- writeMessage({ type: "EXECUTE_JAVASCRIPT", tabId, code, id: jsId });
730
- }),
731
- uploadFile: (tabId, filePaths) => new Promise((resolve) => {
732
- const uploadId = ++requestCounter;
733
- pendingToolRequests.set(uploadId, {
734
- socket: null,
735
- originalId: null,
736
- tool: "upload_file",
737
- onComplete: (r) => resolve(r)
738
- });
739
- writeMessage({ type: "UPLOAD_FILE_TO_TAB", tabId, filePaths, id: uploadId });
740
- }),
741
- fetchUrl: (url) => new Promise((resolve) => {
742
- const fetchId = ++requestCounter;
743
- pendingToolRequests.set(fetchId, {
744
- socket: null,
745
- originalId: null,
746
- tool: "fetch_url",
747
- onComplete: (r) => resolve(r)
748
- });
749
- writeMessage({ type: "GEMINI_FETCH_URL", url, id: fetchId });
750
- }),
846
+ getCookies: () => requestCallExtension(
847
+ requestContext,
848
+ "get_cookies",
849
+ { type: "GET_GOOGLE_COOKIES" },
850
+ ),
851
+ createTab: () => requestCallExtension(
852
+ requestContext,
853
+ "create_tab",
854
+ { type: "GEMINI_NEW_TAB" },
855
+ ),
856
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "GEMINI_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
857
+ jsEval: (tabId, code) => requestCallExtension(
858
+ requestContext,
859
+ "js_eval",
860
+ { type: "EXECUTE_JAVASCRIPT", tabId, code },
861
+ ),
862
+ uploadFile: (tabId, filePaths) => requestCallExtension(
863
+ requestContext,
864
+ "upload_file",
865
+ buildProviderUploadMessage("gemini", tabId, filePaths),
866
+ ),
867
+ fetchUrl: (url) => requestCallExtension(
868
+ requestContext,
869
+ "fetch_url",
870
+ { type: "GEMINI_FETCH_URL", url },
871
+ ),
751
872
  log: (msg) => log(`[gemini] ${msg}`)
752
873
  });
753
874
 
@@ -776,16 +897,12 @@ function handleToolRequest(msg, socket) {
776
897
  // 1. Get page context if requested
777
898
  let pageContext = null;
778
899
  if (withPage) {
779
- const pageResult = await new Promise((resolve) => {
780
- const pageId = ++requestCounter;
781
- pendingToolRequests.set(pageId, {
782
- socket: null,
783
- originalId: null,
784
- tool: "get_page_text",
785
- onComplete: resolve
786
- });
787
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
788
- });
900
+ const pageResult = await requestCallExtension(
901
+ requestContext,
902
+ "get_page_text",
903
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
904
+ 45000,
905
+ );
789
906
  if (pageResult && !pageResult.error) {
790
907
  pageContext = {
791
908
  url: pageResult.url,
@@ -803,59 +920,31 @@ function handleToolRequest(msg, socket) {
803
920
  // 3. Call Grok client
804
921
  const result = await grokClient.query({
805
922
  prompt: fullPrompt,
923
+ signal: requestContext.signal,
806
924
  model: model,
807
925
  deepSearch: deepSearch || false,
808
926
  timeout: timeout || 300000,
809
- getCookies: () => new Promise((resolve) => {
810
- const cookieId = ++requestCounter;
811
- pendingToolRequests.set(cookieId, {
812
- socket: null,
813
- originalId: null,
814
- tool: "get_cookies",
815
- onComplete: (r) => resolve(r)
816
- });
817
- writeMessage({ type: "GET_TWITTER_COOKIES", id: cookieId });
818
- }),
819
- createTab: () => new Promise((resolve) => {
820
- const tabCreateId = ++requestCounter;
821
- pendingToolRequests.set(tabCreateId, {
822
- socket: null,
823
- originalId: null,
824
- tool: "create_tab",
825
- onComplete: (r) => resolve(r)
826
- });
827
- writeMessage({ type: "GROK_NEW_TAB", id: tabCreateId });
828
- }),
829
- closeTab: (tabIdToClose) => new Promise((resolve) => {
830
- const tabCloseId = ++requestCounter;
831
- pendingToolRequests.set(tabCloseId, {
832
- socket: null,
833
- originalId: null,
834
- tool: "close_tab",
835
- onComplete: (r) => resolve(r)
836
- });
837
- writeMessage({ type: "GROK_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
838
- }),
839
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
840
- const evalId = ++requestCounter;
841
- pendingToolRequests.set(evalId, {
842
- socket: null,
843
- originalId: null,
844
- tool: "cdp_evaluate",
845
- onComplete: (r) => resolve(r)
846
- });
847
- writeMessage({ type: "GROK_EVALUATE", tabId, expression, id: evalId });
848
- }),
849
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
850
- const cmdId = ++requestCounter;
851
- pendingToolRequests.set(cmdId, {
852
- socket: null,
853
- originalId: null,
854
- tool: "cdp_command",
855
- onComplete: (r) => resolve(r)
856
- });
857
- writeMessage({ type: "GROK_CDP_COMMAND", tabId, method, params, id: cmdId });
858
- }),
927
+ getCookies: () => requestCallExtension(
928
+ requestContext,
929
+ "get_cookies",
930
+ { type: "GET_TWITTER_COOKIES" },
931
+ ),
932
+ createTab: () => requestCallExtension(
933
+ requestContext,
934
+ "create_tab",
935
+ { type: "GROK_NEW_TAB" },
936
+ ),
937
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "GROK_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
938
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
939
+ requestContext,
940
+ "cdp_evaluate",
941
+ { type: "GROK_EVALUATE", tabId, expression },
942
+ ),
943
+ cdpCommand: (tabId, method, params) => requestCallExtension(
944
+ requestContext,
945
+ "cdp_command",
946
+ { type: "GROK_CDP_COMMAND", tabId, method, params },
947
+ ),
859
948
  log: (msg) => log(`[grok] ${msg}`)
860
949
  });
861
950
 
@@ -894,46 +983,29 @@ function handleToolRequest(msg, socket) {
894
983
 
895
984
  queueAiRequest(async () => {
896
985
  const result = await grokClient.validate({
897
- getCookies: () => new Promise((resolve) => {
898
- const cookieId = ++requestCounter;
899
- pendingToolRequests.set(cookieId, {
900
- socket: null,
901
- originalId: null,
902
- tool: "get_cookies",
903
- onComplete: (r) => resolve(r)
904
- });
905
- writeMessage({ type: "GET_TWITTER_COOKIES", id: cookieId });
906
- }),
907
- createTab: () => new Promise((resolve) => {
908
- const tabCreateId = ++requestCounter;
909
- pendingToolRequests.set(tabCreateId, {
910
- socket: null,
911
- originalId: null,
912
- tool: "create_tab",
913
- onComplete: (r) => resolve(r)
914
- });
915
- writeMessage({ type: "GROK_NEW_TAB", id: tabCreateId });
916
- }),
917
- closeTab: (tabIdToClose) => new Promise((resolve) => {
918
- const tabCloseId = ++requestCounter;
919
- pendingToolRequests.set(tabCloseId, {
920
- socket: null,
921
- originalId: null,
922
- tool: "close_tab",
923
- onComplete: (r) => resolve(r)
924
- });
925
- writeMessage({ type: "GROK_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
926
- }),
927
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
928
- const evalId = ++requestCounter;
929
- pendingToolRequests.set(evalId, {
930
- socket: null,
931
- originalId: null,
932
- tool: "cdp_evaluate",
933
- onComplete: (r) => resolve(r)
934
- });
935
- writeMessage({ type: "GROK_EVALUATE", tabId, expression, id: evalId });
936
- }),
986
+ signal: requestContext.signal,
987
+ getCookies: () => requestCallExtension(
988
+ requestContext,
989
+ "get_cookies",
990
+ { type: "GET_TWITTER_COOKIES" },
991
+ ),
992
+ createTab: () => requestCallExtension(
993
+ requestContext,
994
+ "create_tab",
995
+ { type: "GROK_NEW_TAB" },
996
+ ),
997
+ closeTab: (tabIdToClose) => requestCallExtension(
998
+ requestContext,
999
+ "close_tab",
1000
+ { type: "GROK_CLOSE_TAB", tabId: tabIdToClose },
1001
+ 45000,
1002
+ true,
1003
+ ),
1004
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
1005
+ requestContext,
1006
+ "cdp_evaluate",
1007
+ { type: "GROK_EVALUATE", tabId, expression },
1008
+ ),
937
1009
  log: (msg) => log(`[grok:validate] ${msg}`)
938
1010
  });
939
1011
 
@@ -941,19 +1013,25 @@ function handleToolRequest(msg, socket) {
941
1013
  }).then((result) => {
942
1014
  // If --save-models flag was passed and we found models, save them
943
1015
  if (saveModels && result.models && result.models.length > 0) {
944
- // Convert scraped model names to our format
1016
+ // Convert scraped model names to selectable IDs.
945
1017
  const modelMap = {};
1018
+ const defaultModels = Object.values(grokClient.DEFAULT_GROK_MODELS || {});
946
1019
  result.models.forEach(name => {
947
1020
  const nameLower = name.toLowerCase();
1021
+ const normalizedName = grokClient.normalizeGrokModelLabel(name);
1022
+ const knownModel = defaultModels.find(model => {
1023
+ const normalizedDefaultName = grokClient.normalizeGrokModelLabel(model.name);
1024
+ return normalizedName.includes(normalizedDefaultName) || normalizedDefaultName.includes(normalizedName);
1025
+ });
948
1026
  // Match known model keywords to generate consistent short IDs
949
1027
  let shortId;
950
- if (nameLower.includes('thinking')) shortId = 'thinking';
1028
+ if (knownModel) shortId = knownModel.id;
951
1029
  else if (nameLower.includes('expert')) shortId = 'expert';
952
1030
  else if (nameLower.includes('fast')) shortId = 'fast';
953
1031
  else if (nameLower.includes('auto')) shortId = 'auto';
954
1032
  else shortId = nameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
955
1033
 
956
- modelMap[shortId] = { id: shortId, name: name, desc: "" };
1034
+ modelMap[shortId] = { id: shortId, name: name, desc: knownModel?.desc || "" };
957
1035
  });
958
1036
  const saveResult = grokClient.saveModels(modelMap);
959
1037
  result.savedModels = saveResult;
@@ -972,30 +1050,12 @@ function handleToolRequest(msg, socket) {
972
1050
  queueAiRequest(async () => {
973
1051
  const EXT_CALL_TIMEOUT_MS = 30000;
974
1052
 
975
- const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => new Promise((resolve, reject) => {
976
- const id = ++requestCounter;
977
-
978
- if (msg && msg.type === "AISTUDIO_NEW_TAB") {
1053
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1054
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
979
1055
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/prompts/new_chat")}`);
980
1056
  }
981
-
982
- const timeoutId = setTimeout(() => {
983
- pendingToolRequests.delete(id);
984
- reject(new Error(`Timeout waiting for extension: ${toolName}`));
985
- }, timeoutMs);
986
-
987
- pendingToolRequests.set(id, {
988
- socket: null,
989
- originalId: null,
990
- tool: toolName,
991
- onComplete: (r) => {
992
- clearTimeout(timeoutId);
993
- resolve(r);
994
- }
995
- });
996
-
997
- writeMessage({ ...msg, id });
998
- });
1057
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1058
+ };
999
1059
 
1000
1060
  // 1. Get page context if requested
1001
1061
  let pageContext = null;
@@ -1029,6 +1089,7 @@ function handleToolRequest(msg, socket) {
1029
1089
  // 3. Call AI Studio client
1030
1090
  const result = await aistudioClient.query({
1031
1091
  prompt: fullPrompt,
1092
+ signal: requestContext.signal,
1032
1093
  model: model || undefined,
1033
1094
  timeout: timeout || 300000,
1034
1095
  getCookies: () => callExtension("get_cookies", { type: "GET_GOOGLE_COOKIES" }, 45000),
@@ -1087,33 +1148,16 @@ function handleToolRequest(msg, socket) {
1087
1148
  queueAiRequest(async () => {
1088
1149
  const EXT_CALL_TIMEOUT_MS = 30000;
1089
1150
 
1090
- const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => new Promise((resolve, reject) => {
1091
- const id = ++requestCounter;
1092
-
1093
- if (msg && msg.type === "AISTUDIO_NEW_TAB") {
1151
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1152
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
1094
1153
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/apps")}`);
1095
1154
  }
1096
-
1097
- const timeoutId = setTimeout(() => {
1098
- pendingToolRequests.delete(id);
1099
- reject(new Error(`Timeout waiting for extension: ${toolName}`));
1100
- }, timeoutMs);
1101
-
1102
- pendingToolRequests.set(id, {
1103
- socket: null,
1104
- originalId: null,
1105
- tool: toolName,
1106
- onComplete: (r) => {
1107
- clearTimeout(timeoutId);
1108
- resolve(r);
1109
- }
1110
- });
1111
-
1112
- writeMessage({ ...msg, id });
1113
- });
1155
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1156
+ };
1114
1157
 
1115
1158
  const result = await aistudioBuild.build({
1116
1159
  prompt: query,
1160
+ signal: requestContext.signal,
1117
1161
  model: model || undefined,
1118
1162
  output,
1119
1163
  keepOpen,
@@ -1164,6 +1208,7 @@ function handleToolRequest(msg, socket) {
1164
1208
  let lastError = null;
1165
1209
 
1166
1210
  const sendNextKey = () => {
1211
+ if (requestContext.signal.aborted) return;
1167
1212
  if (completed >= repeat) {
1168
1213
  if (lastError) {
1169
1214
  sendToolResponse(socket, originalId, null, `Key repeat failed: ${lastError}`);
@@ -1172,18 +1217,15 @@ function handleToolRequest(msg, socket) {
1172
1217
  }
1173
1218
  return;
1174
1219
  }
1175
- const id = ++requestCounter;
1176
- pendingToolRequests.set(id, {
1177
- socket: null,
1178
- originalId: null,
1220
+ requestCallExtension(
1221
+ requestContext,
1179
1222
  tool,
1180
- onComplete: (result) => {
1181
- if (result.error) lastError = result.error;
1182
- completed++;
1183
- setTimeout(sendNextKey, 50);
1184
- }
1185
- });
1186
- writeMessage({ type: "EXECUTE_KEY", key, tabId: tid, id });
1223
+ { type: "EXECUTE_KEY", key, tabId: tid },
1224
+ ).then((result) => {
1225
+ if (result.error) lastError = result.error;
1226
+ completed++;
1227
+ return require("./abort.cjs").abortableDelay(50, requestContext.signal);
1228
+ }).then(sendNextKey).catch((error) => sendToolResponse(socket, originalId, null, error.message));
1187
1229
  };
1188
1230
  sendNextKey();
1189
1231
  return;
@@ -1191,23 +1233,25 @@ function handleToolRequest(msg, socket) {
1191
1233
 
1192
1234
  if (extensionMsg.type === "NAMED_TAB_SWITCH" || extensionMsg.type === "NAMED_TAB_CLOSE") {
1193
1235
  const { name, type: opType } = extensionMsg;
1194
- const lookupId = ++requestCounter;
1195
- pendingToolRequests.set(lookupId, {
1196
- socket: null,
1197
- originalId: null,
1198
- tool: "tabs_get_by_name",
1199
- onComplete: (result) => {
1200
- if (result.error || !result.tabId) {
1201
- sendToolResponse(socket, originalId, null, result.error || `No tab found with name "${name}"`);
1202
- return;
1203
- }
1204
- const actionId = ++requestCounter;
1205
- const actionType = opType === "NAMED_TAB_SWITCH" ? "SWITCH_TAB" : "CLOSE_TAB";
1206
- pendingToolRequests.set(actionId, { socket, originalId, tool, tabId: result.tabId });
1207
- writeMessage({ type: actionType, tabId: result.tabId, id: actionId });
1236
+ requestCallExtension(
1237
+ requestContext,
1238
+ "tabs_get_by_name",
1239
+ { type: "TABS_GET_BY_NAME", name },
1240
+ ).then((result) => {
1241
+ if (result.error || !result.tabId) {
1242
+ throw new Error(result.error || `No tab found with name "${name}"`);
1208
1243
  }
1209
- });
1210
- writeMessage({ type: "TABS_GET_BY_NAME", name, id: lookupId });
1244
+ const actionType = opType === "NAMED_TAB_SWITCH" ? "SWITCH_TAB" : "CLOSE_TAB";
1245
+ const actionTool = opType === "NAMED_TAB_SWITCH" ? "switch_tab" : "close_tab";
1246
+ return requestCallExtension(
1247
+ requestContext,
1248
+ actionTool,
1249
+ { type: actionType, tabId: result.tabId },
1250
+ 30000,
1251
+ actionTool === "close_tab",
1252
+ );
1253
+ }).then((result) => sendToolResponse(socket, originalId, result, result?.error || null))
1254
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1211
1255
  return;
1212
1256
  }
1213
1257
 
@@ -1217,7 +1261,11 @@ function handleToolRequest(msg, socket) {
1217
1261
  originalId,
1218
1262
  tool,
1219
1263
  savePath: extensionMsg.savePath || args?.savePath,
1220
- autoScreenshot: args?.autoScreenshot,
1264
+ autoScreenshot: args?.autoScreenshot === true,
1265
+ autoScreenshotOutput: args?.autoScreenshotOutput,
1266
+ networkExport: extensionMsg.type === "EXPORT_NETWORK_REQUESTS",
1267
+ networkExportPath: args?.output,
1268
+ networkExportFormat: extensionMsg.har ? "har" : extensionMsg.jsonl ? "jsonl" : "json",
1221
1269
  fullRes: extensionMsg.fullRes || args?.fullRes,
1222
1270
  maxSize: extensionMsg.maxSize || args?.maxSize,
1223
1271
  tabId: extensionMsg.tabId || tabId
@@ -1230,12 +1278,14 @@ function handleToolRequest(msg, socket) {
1230
1278
  writeMessage(finalMsg);
1231
1279
  }
1232
1280
 
1233
- function executeBatch(actions, tabId, socket, originalId) {
1281
+ function executeBatch(actions, tabId, socket, originalId, requestContext = requestStorage.getStore()) {
1282
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
1234
1283
  const results = [];
1235
1284
  const DELAY_MS = 100;
1236
1285
  let currentIndex = 0;
1237
1286
 
1238
1287
  function executeNextAction() {
1288
+ if (requestContext.signal.aborted) return;
1239
1289
  if (currentIndex >= actions.length) {
1240
1290
  sendToolResponse(socket, originalId, {
1241
1291
  success: true,
@@ -1266,16 +1316,14 @@ function executeBatch(actions, tabId, socket, originalId) {
1266
1316
  if (extensionMsg.type === "LOCAL_WAIT") {
1267
1317
  results.push({ index: currentIndex, type: action.type, success: true });
1268
1318
  currentIndex++;
1269
- setTimeout(executeNextAction, extensionMsg.seconds * 1000);
1319
+ require("./abort.cjs").abortableDelay(extensionMsg.seconds * 1000, requestContext.signal)
1320
+ .then(executeNextAction)
1321
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1270
1322
  return;
1271
1323
  }
1272
1324
 
1273
- const id = ++requestCounter;
1274
- pendingToolRequests.set(id, {
1275
- socket: null,
1276
- originalId: null,
1277
- tool: toolName,
1278
- onComplete: (result) => {
1325
+ requestCallExtension(requestContext, toolName, extensionMsg, 30000)
1326
+ .then((result) => {
1279
1327
  if (result.error) {
1280
1328
  results.push({ index: currentIndex, type: action.type, success: false, error: result.error });
1281
1329
  sendToolResponse(socket, originalId, {
@@ -1287,15 +1335,12 @@ function executeBatch(actions, tabId, socket, originalId) {
1287
1335
  }, null);
1288
1336
  return;
1289
1337
  }
1290
-
1291
1338
  results.push({ index: currentIndex, type: action.type, success: true });
1292
1339
  currentIndex++;
1293
-
1294
- setTimeout(executeNextAction, DELAY_MS);
1295
- }
1296
- });
1297
-
1298
- writeMessage({ ...extensionMsg, id });
1340
+ return require("./abort.cjs").abortableDelay(DELAY_MS, requestContext.signal)
1341
+ .then(executeNextAction);
1342
+ })
1343
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1299
1344
  }
1300
1345
 
1301
1346
  executeNextAction();
@@ -1356,7 +1401,7 @@ function processInput() {
1356
1401
 
1357
1402
  try {
1358
1403
  const msg = JSON.parse(jsonStr);
1359
- log(`Received from extension: ${JSON.stringify(msg)}`);
1404
+ log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
1360
1405
 
1361
1406
  if (msg.type === "GET_AUTH") {
1362
1407
  log("Handling GET_AUTH from extension");
@@ -1390,24 +1435,24 @@ function processInput() {
1390
1435
  if (msg.type === "STREAM_EVENT") {
1391
1436
  const stream = activeStreams.get(msg.streamId);
1392
1437
  if (stream) {
1393
- try {
1394
- stream.socket.write(JSON.stringify(msg.event) + "\n");
1395
- } catch (e) {
1396
- log(`Error forwarding stream event: ${e.message}`);
1397
- activeStreams.delete(msg.streamId);
1398
- writeMessage({ type: "STREAM_STOP", streamId: msg.streamId });
1399
- }
1438
+ sendSocket(stream.socket, msg.event, { stream: true }).catch((error) => {
1439
+ log(`Error forwarding stream event: ${error.message}`);
1440
+ stopActiveStream(msg.streamId);
1441
+ stream.socket.destroy(error);
1442
+ });
1400
1443
  }
1401
1444
  return;
1402
1445
  }
1403
-
1446
+
1404
1447
  if (msg.type === "STREAM_ERROR") {
1405
1448
  const stream = activeStreams.get(msg.streamId);
1406
1449
  if (stream) {
1407
- try {
1408
- stream.socket.write(JSON.stringify({ error: msg.error }) + "\n");
1409
- } catch (e) {}
1410
- activeStreams.delete(msg.streamId);
1450
+ sendSocket(stream.socket, { error: msg.error }, { stream: true })
1451
+ .catch((error) => {
1452
+ log(`Error forwarding stream error: ${error.message}`);
1453
+ stream.socket.destroy(error);
1454
+ })
1455
+ .finally(() => stopActiveStream(msg.streamId));
1411
1456
  }
1412
1457
  return;
1413
1458
  }
@@ -1415,19 +1460,41 @@ function processInput() {
1415
1460
 
1416
1461
  if (msg.id && pendingToolRequests.has(msg.id)) {
1417
1462
  const pending = pendingToolRequests.get(msg.id);
1463
+ if (pending.request?.signal.aborted || pending.request?.tombstoned) {
1464
+ const request = pending.request;
1465
+ const topLevelResponse = !pending.resolve && !pending.onComplete;
1466
+ pendingToolRequests.resolve(msg.id, msg);
1467
+ if (topLevelResponse && request?.context) {
1468
+ completeOwnedRequest(request.context, request.id, "cleanup-settled");
1469
+ }
1470
+ return;
1471
+ }
1472
+ if (pending.resolve || pending.onComplete) {
1473
+ pendingToolRequests.resolve(msg.id, msg);
1474
+ return;
1475
+ }
1418
1476
  pendingToolRequests.delete(msg.id);
1419
-
1420
- if (pending.onComplete) {
1421
- pending.onComplete(msg);
1422
- } else {
1477
+ {
1478
+
1423
1479
  const { socket, originalId, savePath, autoScreenshot, tabId: storedTabId } = pending;
1424
1480
  const tabId = storedTabId || msg._resolvedTabId;
1481
+ const failAutoScreenshot = (message) => pending.autoScreenshotOutput
1482
+ ? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
1483
+ : sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: message }, null);
1425
1484
 
1426
- if (savePath && msg.base64) {
1485
+ if (pending.networkExport && Array.isArray(msg.entries)) {
1486
+ try {
1487
+ const exportResult = writeNetworkExport(pending.networkExportPath, msg.entries, pending.networkExportFormat);
1488
+ sendToolResponse(socket, originalId, exportResult, null);
1489
+ } catch (error) {
1490
+ sendToolResponse(socket, originalId, null, `Failed to export network requests: ${error.message}`);
1491
+ }
1492
+ } else if (savePath && msg.base64) {
1427
1493
  try {
1428
1494
  const dir = path.dirname(savePath);
1429
1495
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1430
- fs.writeFileSync(savePath, Buffer.from(msg.base64, "base64"));
1496
+ fs.writeFileSync(savePath, Buffer.from(msg.base64, "base64"), { mode: 0o600 });
1497
+ try { fs.chmodSync(savePath, 0o600); } catch {}
1431
1498
  const origWidth = msg.width || 0;
1432
1499
  const origHeight = msg.height || 0;
1433
1500
  const maxSize = pending.maxSize || 1200;
@@ -1450,8 +1517,7 @@ function processInput() {
1450
1517
  }
1451
1518
  } else if (autoScreenshot && tabId && !msg.error && !msg.base64) {
1452
1519
 
1453
- const screenshotId = ++requestCounter;
1454
- const screenshotPath = path.join(SURF_TMP, `pi-auto-${Date.now()}.png`);
1520
+ const screenshotPath = pending.autoScreenshotOutput || path.join(SURF_TMP, `pi-auto-${Date.now()}.png`);
1455
1521
 
1456
1522
  const autoFiles = fs.readdirSync(SURF_TMP)
1457
1523
  .filter(f => f.startsWith("pi-auto-") && f.endsWith(".png"))
@@ -1462,14 +1528,17 @@ function processInput() {
1462
1528
  try { fs.unlinkSync(path.join(SURF_TMP, f.name)); } catch (e) {}
1463
1529
  });
1464
1530
  }
1465
- pendingToolRequests.set(screenshotId, {
1466
- socket: null,
1467
- originalId: null,
1468
- tool: "screenshot",
1469
- onComplete: (screenshotMsg) => {
1531
+ require("./abort.cjs").abortableDelay(500, pending.request?.signal)
1532
+ .then(() => requestCallExtension(
1533
+ pending.request,
1534
+ "screenshot",
1535
+ { type: "EXECUTE_SCREENSHOT", tabId },
1536
+ ))
1537
+ .then((screenshotMsg) => {
1470
1538
  if (screenshotMsg.base64) {
1471
1539
  try {
1472
- fs.writeFileSync(screenshotPath, Buffer.from(screenshotMsg.base64, "base64"));
1540
+ fs.writeFileSync(screenshotPath, Buffer.from(screenshotMsg.base64, "base64"), { mode: 0o600 });
1541
+ try { fs.chmodSync(screenshotPath, 0o600); } catch {}
1473
1542
  const origW = screenshotMsg.width || 0;
1474
1543
  const origH = screenshotMsg.height || 0;
1475
1544
  let finalW = origW, finalH = origH;
@@ -1486,16 +1555,17 @@ function processInput() {
1486
1555
  autoScreenshot: { path: screenshotPath, width: finalW, height: finalH, originalWidth: origW, originalHeight: origH }
1487
1556
  }, null);
1488
1557
  } catch (e) {
1489
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: e.message }, null);
1558
+ failAutoScreenshot(e.message);
1490
1559
  }
1491
1560
  } else {
1492
1561
  const errMsg = screenshotMsg.error || "Failed to capture";
1493
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: errMsg }, null);
1562
+ failAutoScreenshot(errMsg);
1494
1563
  }
1495
- }
1496
- });
1497
- setTimeout(() => writeMessage({ type: "EXECUTE_SCREENSHOT", tabId, id: screenshotId }), 500);
1564
+ })
1565
+ .catch((error) => failAutoScreenshot(error.message));
1498
1566
  return;
1567
+ } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
1568
+ failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
1499
1569
  } else if (msg.results && msg.savePath) {
1500
1570
  try {
1501
1571
  const dir = msg.savePath;
@@ -1529,11 +1599,7 @@ function processInput() {
1529
1599
  }
1530
1600
  } else if (msg.id && pendingRequests.has(msg.id)) {
1531
1601
  const { socket } = pendingRequests.get(msg.id);
1532
- try {
1533
- socket.write(JSON.stringify(msg) + "\n");
1534
- } catch (e) {
1535
- log(`Error writing to CLI socket: ${e.message}`);
1536
- }
1602
+ sendSocket(socket, msg).catch((error) => log(`Error writing to CLI socket: ${error.message}`));
1537
1603
  pendingRequests.delete(msg.id);
1538
1604
  }
1539
1605
  } catch (e) {
@@ -1556,17 +1622,12 @@ const connectedSockets = new Set();
1556
1622
  process.stdin.on("end", () => {
1557
1623
  log("stdin ended (extension disconnected), notifying clients");
1558
1624
  for (const socket of Array.from(connectedSockets)) {
1559
- try {
1560
- socket.write(JSON.stringify({
1561
- type: "extension_disconnected",
1562
- message: "Surf extension was reloaded. Restart your command."
1563
- }) + "\n");
1564
- socket.end();
1565
- } catch (e) {
1566
- // Socket may already be closed
1567
- }
1625
+ sendSocket(socket, {
1626
+ type: "extension_disconnected",
1627
+ message: "Surf extension was reloaded. Restart your command."
1628
+ }).finally(() => socket.end()).catch(() => socket.end());
1568
1629
  }
1569
- process.exit(0);
1630
+ shutdown(0);
1570
1631
  });
1571
1632
 
1572
1633
  process.stdin.on("error", (err) => {
@@ -1577,142 +1638,276 @@ process.stdout.on("error", (err) => {
1577
1638
  log(`stdout error: ${err.message}`);
1578
1639
  });
1579
1640
 
1580
- const server = net.createServer((socket) => {
1641
+ const handleClient = (socket) => {
1642
+ const isRemote = Boolean(socket.remoteAddress && socket.remotePort);
1643
+ let transferState;
1644
+ let transferReady;
1645
+ let transferCleanupPromise;
1646
+ const cleanupTransfers = () => {
1647
+ if (!transferCleanupPromise) {
1648
+ transferCleanupPromise = transferReady
1649
+ ? transferReady.then((state) => state?.cleanup())
1650
+ : Promise.resolve();
1651
+ }
1652
+ return transferCleanupPromise;
1653
+ };
1654
+ socket.transferCleanup = cleanupTransfers;
1655
+ const ensureTransferState = () => {
1656
+ if (context?.closed || socket.destroyed) throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
1657
+ if (!transferReady) {
1658
+ transferCleanupPromise = undefined;
1659
+ transferReady = createStagingDirectory(SURF_TMP)
1660
+ .then(async (directory) => {
1661
+ if (context?.closed || socket.destroyed) {
1662
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(() => {});
1663
+ throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
1664
+ }
1665
+ try {
1666
+ return createTransferState({ directory, writer: { send: (frame) => sendSocket(socket, frame) }, onActivity: () => sessionManager.touch(socketContexts.get(socket)) });
1667
+ } catch (error) {
1668
+ return fs.promises.rm(directory, { recursive: true, force: true }).catch(() => {}).then(() => { throw error; });
1669
+ }
1670
+ })
1671
+ .catch((error) => { transferReady = undefined; throw error; });
1672
+ }
1673
+ return transferReady;
1674
+ };
1675
+ let context;
1676
+ try {
1677
+ context = sessionManager.admit(socket, isRemote);
1678
+ } catch (error) {
1679
+ sendSocket(socket, { error: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
1680
+ return;
1681
+ }
1682
+ const writer = createSocketWriter(socket, {
1683
+ maxPendingBytes: 4 * 1024 * 1024,
1684
+ onOverflow: ({ stream, error }) => {
1685
+ auditSession({ event: stream ? "stream" : "writer", context, outcome: "overflow", request: context.activeRequest });
1686
+ sessionManager.stopStream(context);
1687
+ socket.destroy(error);
1688
+ },
1689
+ });
1690
+ socketContexts.set(socket, context);
1691
+ socketWriters.set(socket, writer);
1581
1692
  log("CLI client connected");
1582
1693
  connectedSockets.add(socket);
1583
1694
  socket.on("close", () => connectedSockets.delete(socket));
1584
1695
 
1585
- let dataBuffer = "";
1696
+ const stateDir = getStateDir();
1697
+ let principal = null;
1698
+ const authSession = isRemote ? createServerAuthSession({
1699
+ socket,
1700
+ stateDir,
1701
+ send: (value) => sendSocket(socket, value),
1702
+ async onAuthenticated(authenticatedPrincipal) {
1703
+ sessionManager.authenticate(context, authenticatedPrincipal);
1704
+ principal = authenticatedPrincipal;
1705
+ log(`Remote client authenticated: ${authenticatedPrincipal.label} (${authenticatedPrincipal.clientId})`);
1706
+ },
1707
+ onError(error) {
1708
+ log(`Remote authentication rejected: ${error.message}`);
1709
+ sendSocket(socket, { type: "auth_error", message: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
1710
+ },
1711
+ }) : null;
1712
+ let messageChain = Promise.resolve();
1713
+ const processMessage = async (msg) => {
1714
+ if (context.closed) return;
1715
+ if (isRemote && !authSession.authenticated) {
1716
+ await authSession.handle(msg);
1717
+ return;
1718
+ }
1719
+ if (isRemote) {
1720
+ let authorized = false;
1721
+ try {
1722
+ authorized = Boolean(principal && isClientAuthorized(stateDir, principal.clientId));
1723
+ } catch (error) {
1724
+ log(`Remote authorization registry check failed: ${error.message}`);
1725
+ }
1726
+ if (!authorized) {
1727
+ await sendSocket(socket, { error: "remote client authorization is unavailable or revoked" }).catch(() => {});
1728
+ socket.destroy();
1729
+ return;
1730
+ }
1731
+ }
1586
1732
 
1587
- socket.on("data", (data) => {
1588
- dataBuffer += data.toString();
1589
- const lines = dataBuffer.split("\n");
1590
- dataBuffer = lines.pop() || "";
1733
+ if (isRemote && msg.type && msg.type.startsWith("transfer_")) {
1734
+ transferState ||= await ensureTransferState();
1735
+ await transferState.handle(msg);
1736
+ return;
1737
+ }
1591
1738
 
1592
- for (const line of lines) {
1593
- if (!line.trim()) continue;
1739
+ if (msg.type === "tool_request") {
1740
+ const tool = msg.params?.tool || "unknown";
1741
+ let request;
1594
1742
  try {
1595
- const msg = JSON.parse(line);
1596
-
1597
- if (msg.type === "GET_AUTH") {
1598
- log("Handling GET_AUTH locally");
1599
- try {
1600
- if (fs.existsSync(AUTH_FILE)) {
1601
- const authData = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1602
- socket.write(JSON.stringify({
1603
- id: msg.id || 0,
1604
- auth: authData,
1605
- hint: null
1606
- }) + "\n");
1607
- } else {
1608
- socket.write(JSON.stringify({
1609
- id: msg.id || 0,
1610
- auth: null,
1611
- hint: "No OAuth credentials found. Run 'pi --login anthropic' in terminal to authenticate with Claude Max."
1612
- }) + "\n");
1613
- }
1614
- } catch (e) {
1615
- log(`Error reading auth file: ${e.message}`);
1616
- socket.write(JSON.stringify({
1617
- id: msg.id || 0,
1618
- auth: null,
1619
- hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
1620
- }) + "\n");
1621
- }
1622
- continue;
1623
- }
1624
-
1625
- if (msg.type === "tool_request") {
1626
- log("Handling tool_request: " + msg.method + " " + (msg.params?.tool || ""));
1627
- try {
1628
- handleToolRequest(msg, socket);
1629
- } catch (e) {
1630
- socket.write(JSON.stringify({ error: e.message || "Request failed" }) + "\n");
1631
- }
1632
- continue;
1633
- }
1634
-
1635
- if (msg.type === "stream_request") {
1636
- log("Handling stream_request: " + msg.streamType);
1637
- handleStreamRequest(msg, socket);
1638
- continue;
1639
- }
1640
-
1641
- if (msg.type === "stream_stop") {
1642
- log("Handling stream_stop");
1643
- for (const [streamId, stream] of activeStreams.entries()) {
1644
- if (stream.socket === socket) {
1645
- writeMessage({ type: "STREAM_STOP", streamId });
1646
- activeStreams.delete(streamId);
1647
- }
1648
- }
1649
- continue;
1743
+ const deadlineMs = TEST_REQUEST_DEADLINE_MS || resolveRequestDeadlineMs(tool, msg.params?.args);
1744
+ request = await sessionManager.beginRequest(context, { id: msg.id, tool, deadlineMs });
1745
+ request.context = context;
1746
+ } catch (error) {
1747
+ if (transferState) await discardRequestTransfers(msg, transferState);
1748
+ await sendSocket(socket, { type: "tool_response", id: msg.id || null, error: { content: [{ type: "text", text: error.message }] } }).catch(() => {});
1749
+ return;
1750
+ }
1751
+ log(`Handling tool_request: ${msg.method} ${tool}${principal ? ` for ${principal.label}` : ""}`);
1752
+ try {
1753
+ if (isRemote) {
1754
+ await applyRequestTransfers(msg, request, transferState, ensureTransferState);
1650
1755
  }
1651
-
1652
- const id = ++requestCounter;
1653
- log(`Forwarding to extension: id=${id} type=${msg.type}`);
1654
- pendingRequests.set(id, { socket });
1655
- writeMessage({ ...msg, id });
1756
+ throwIfAborted(request.signal, "Request cancelled");
1757
+ requestStorage.run(request, () => handleToolRequest(msg, socket, request));
1656
1758
  } catch (e) {
1657
- log(`Error parsing CLI request: ${e.message}`);
1658
- socket.write(JSON.stringify({ error: "Invalid request" }) + "\n");
1759
+ await discardRequestTransfers(msg, transferState);
1760
+ sendToolResponse(socket, msg.id || null, null, e.message || "Request failed");
1659
1761
  }
1762
+ return;
1660
1763
  }
1764
+
1765
+ if (msg.type === "stream_request") {
1766
+ if (msg.streamType !== "STREAM_CONSOLE" && msg.streamType !== "STREAM_NETWORK") {
1767
+ log(`Rejecting unsupported stream type: ${msg.streamType}`);
1768
+ await sendSocket(socket, { error: `Unsupported stream type: ${msg.streamType}` }).catch(() => {});
1769
+ return;
1770
+ }
1771
+ if (!sessionManager.canStartStream(context)) {
1772
+ await sendSocket(socket, { error: "stream limit reached or connection is not stream-only" }).catch(() => {});
1773
+ socket.destroy();
1774
+ return;
1775
+ }
1776
+ log(`Handling stream_request: ${msg.streamType}`);
1777
+ handleStreamRequest(msg, socket);
1778
+ return;
1779
+ }
1780
+
1781
+ if (msg.type === "stream_stop") {
1782
+ log("Handling stream_stop");
1783
+ for (const [streamId, stream] of activeStreams.entries()) {
1784
+ if (stream.socket === socket) stopActiveStream(streamId);
1785
+ }
1786
+ return;
1787
+ }
1788
+
1789
+ log(`Rejecting unsupported socket request type: ${msg.type}`);
1790
+ await sendSocket(socket, { error: `Unsupported request type: ${msg.type}` }).catch(() => {});
1791
+ };
1792
+
1793
+ const parser = createFrameParser({
1794
+ onFrame(msg) {
1795
+ messageChain = messageChain.then(() => processMessage(msg)).catch((error) => {
1796
+ log(`Error handling CLI request: ${error.message}`);
1797
+ if (isRemote && msg.type && msg.type.startsWith("transfer_")) {
1798
+ sendSocket(socket, { type: "transfer_error", version: 1, transferId: msg.transferId, error: error.message || "Transfer failed" })
1799
+ .finally(() => socket.destroy()).catch(() => socket.destroy());
1800
+ } else {
1801
+ sendSocket(socket, { error: error.message || "Request failed" }).catch(() => {});
1802
+ }
1803
+ });
1804
+ },
1805
+ onError(error) {
1806
+ log(`CLI frame rejected: ${error.message}`);
1807
+ if (isRemote && !authSession.authenticated) {
1808
+ sendSocket(socket, { type: "auth_error", message: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
1809
+ } else {
1810
+ socket.destroy();
1811
+ }
1812
+ },
1813
+ maxFrameBytes: MAX_CLIENT_FRAME_BYTES,
1661
1814
  });
1662
1815
 
1816
+ socket.on("data", (data) => parser.push(data));
1817
+
1663
1818
  socket.on("error", (err) => {
1664
1819
  log(`CLI socket error: ${err.message}`);
1665
1820
  });
1666
1821
 
1667
1822
  socket.on("close", () => {
1668
- log("CLI client disconnected");
1669
- for (const [id, pending] of pendingRequests.entries()) {
1670
- if (pending.socket === socket) {
1671
- pendingRequests.delete(id);
1672
- }
1673
- }
1674
- for (const [id, pending] of pendingToolRequests.entries()) {
1675
- if (pending.socket === socket && !pending.autoScreenshot) {
1676
- pendingToolRequests.delete(id);
1677
- }
1823
+ parser.close();
1824
+ authSession?.close();
1825
+ const activeRequest = context.activeRequest;
1826
+ let cleanupPendingId;
1827
+ if (activeRequest && !activeRequest.queued) {
1828
+ cleanupPendingId = `transfer-cleanup-${++requestCounter}`;
1829
+ pendingToolRequests.set(cleanupPendingId, {
1830
+ request: activeRequest,
1831
+ cleanup: true,
1832
+ tool: "transfer_cleanup",
1833
+ resolve: () => {},
1834
+ reject: () => {},
1835
+ });
1836
+ completeOwnedRequest(context, activeRequest.id, "cleanup-settled");
1678
1837
  }
1838
+ const cleanupPromise = cleanupTransfers();
1839
+ cleanupPromise.finally(() => {
1840
+ if (cleanupPendingId) pendingToolRequests.delete(cleanupPendingId);
1841
+ }).catch(() => {});
1842
+ writer.close();
1843
+ sessionManager.close(context);
1844
+ if (activeRequest) pendingToolRequests.tombstoneAfterAbort(activeRequest);
1845
+ log("CLI client disconnected");
1679
1846
  for (const [streamId, stream] of activeStreams.entries()) {
1680
- if (stream.socket === socket) {
1681
- writeMessage({ type: "STREAM_STOP", streamId });
1682
- activeStreams.delete(streamId);
1683
- }
1847
+ if (stream.socket === socket) stopActiveStream(streamId);
1684
1848
  }
1685
1849
  });
1686
- });
1687
-
1688
- server.listen(SOCKET_PATH, () => {
1689
- log("Socket server listening on " + SOCKET_PATH);
1690
- if (!IS_WIN) { try { fs.chmodSync(SOCKET_PATH, 0o600); } catch {} }
1691
- writeMessage({ type: "HOST_READY" });
1692
- log("Sent HOST_READY to extension");
1693
- });
1850
+ };
1694
1851
 
1695
- server.on("error", (err) => {
1696
- log(`Server error: ${err.message}`);
1697
- });
1852
+ let listenerLifecycle = null;
1853
+ let shuttingDown = false;
1854
+ let exitCode = 0;
1855
+ let exitScheduled = false;
1856
+ function scheduleExit() {
1857
+ if (exitScheduled) return;
1858
+ exitScheduled = true;
1859
+ setTimeout(() => process.exit(exitCode), 50);
1860
+ }
1861
+ function shutdown(code = 0) {
1862
+ exitCode = Math.max(exitCode, code);
1863
+ if (shuttingDown) return;
1864
+ shuttingDown = true;
1865
+ const cleanupPromises = [...connectedSockets].map((socket) => socket.transferCleanup?.() || Promise.resolve());
1866
+ for (const socket of connectedSockets) socket.destroy();
1867
+ pendingRequests.clear(); pendingToolRequests.clear(); activeStreams.clear();
1868
+ Promise.allSettled([...cleanupPromises, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
1869
+ }
1870
+ function failStartup(error, endpoint) {
1871
+ log(`Listener startup failed (${endpoint}): ${error.message}`);
1872
+ shutdown(1);
1873
+ }
1874
+ async function startListeners() {
1875
+ let endpoint;
1876
+ try {
1877
+ endpoint = process.env.SURF_LISTEN ? parseListenEndpoint(process.env.SURF_LISTEN) : null;
1878
+ listenerLifecycle = createListenerLifecycle({
1879
+ localPath: SOCKET_PATH,
1880
+ tcpEndpoint: endpoint && { host: endpoint.host, port: endpoint.port },
1881
+ handler: handleClient,
1882
+ onReady: () => {
1883
+ if (endpoint) log(`TCP listener listening on ${endpoint.display}`);
1884
+ writeMessage({ type: "HOST_READY" });
1885
+ log("Sent HOST_READY to extension");
1886
+ },
1887
+ onFatal: (error) => failStartup(error, endpoint?.display || process.env.SURF_LISTEN || SOCKET_PATH),
1888
+ });
1889
+ if (shuttingDown) await listenerLifecycle.shutdown();
1890
+ await listenerLifecycle.start();
1891
+ } catch (error) { failStartup(error, endpoint?.display || process.env.SURF_LISTEN || SOCKET_PATH); }
1892
+ }
1893
+ startListeners();
1698
1894
 
1699
1895
  process.on("SIGTERM", () => {
1700
1896
  log("SIGTERM received");
1701
- server.close();
1702
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1703
- process.exit(0);
1897
+ shutdown();
1704
1898
  });
1705
1899
 
1706
1900
  process.on("SIGINT", () => {
1707
1901
  log("SIGINT received");
1708
- server.close();
1709
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1710
- process.exit(0);
1902
+ shutdown();
1711
1903
  });
1712
1904
 
1713
1905
  process.on("uncaughtException", (err) => {
1714
1906
  log(`Uncaught exception: ${err.message}\n${err.stack}`);
1715
- process.exit(1);
1907
+ shutdown(1);
1716
1908
  });
1717
1909
 
1718
1910
  log("Host initialization complete, waiting for connections...");
1911
+ } else {
1912
+ module.exports = { createListenerLifecycle, MAX_CLIENT_FRAME_BYTES };
1913
+ }