surf-cli 2.8.0 → 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 (42) hide show
  1. package/README.md +98 -4
  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 +2 -2
  14. package/native/chatgpt-client.cjs +47 -31
  15. package/native/cli.cjs +300 -204
  16. package/native/client-transport.cjs +168 -0
  17. package/native/do-executor.cjs +25 -44
  18. package/native/doctor.cjs +55 -5
  19. package/native/endpoint.cjs +174 -0
  20. package/native/file-transfer.cjs +734 -0
  21. package/native/gemini-client.cjs +156 -71
  22. package/native/grok-client.cjs +98 -89
  23. package/native/host-helpers.cjs +37 -12
  24. package/native/host-sessions.cjs +283 -0
  25. package/native/host.cjs +800 -620
  26. package/native/listener.cjs +20 -0
  27. package/native/mcp-server.cjs +60 -65
  28. package/native/network-export.cjs +113 -0
  29. package/native/perplexity-client.cjs +46 -17
  30. package/native/remote-auth.cjs +279 -0
  31. package/native/remote-transport.cjs +337 -0
  32. package/native/request-pending.cjs +148 -0
  33. package/native/socket-path.cjs +1 -1
  34. package/package.json +8 -6
  35. package/scripts/install-native-host.cjs +36 -5
  36. package/skills/README.md +11 -5
  37. package/skills/deep-x-research/SKILL.md +106 -0
  38. package/skills/surf/SKILL.md +31 -4
  39. package/dist/content/accessibility-tree.js +0 -11
  40. package/dist/content/accessibility-tree.js.map +0 -1
  41. package/dist/content/visual-indicator.js +0 -111
  42. 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");
@@ -16,8 +18,80 @@ const { mapToolToMessage, mapComputerAction, formatToolContent, buildProviderUpl
16
18
 
17
19
  const IS_WIN = process.platform === "win32";
18
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;
19
34
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
20
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
+
21
95
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
22
96
  function resizeImage(filePath, maxSize) {
23
97
  const platform = process.platform;
@@ -54,29 +128,9 @@ function resizeImage(filePath, maxSize) {
54
128
  }
55
129
  }
56
130
 
57
- const aiRequestQueue = [];
58
- let aiRequestInProgress = false;
59
-
60
- function queueAiRequest(handler) {
61
- return new Promise((resolve, reject) => {
62
- aiRequestQueue.push({ handler, resolve, reject });
63
- processAiQueue();
64
- });
65
- }
66
-
67
- async function processAiQueue() {
68
- if (aiRequestInProgress || aiRequestQueue.length === 0) return;
69
- aiRequestInProgress = true;
70
- const { handler, resolve, reject } = aiRequestQueue.shift();
71
- try {
72
- const result = await handler();
73
- resolve(result);
74
- } catch (err) {
75
- reject(err);
76
- } finally {
77
- aiRequestInProgress = false;
78
- setTimeout(processAiQueue, 2000);
79
- }
131
+ let aiQueue;
132
+ function queueAiRequest(handler, request = requestStorage.getStore()) {
133
+ return aiQueue.enqueue(handler, request);
80
134
  }
81
135
  const LOG_FILE = path.join(SURF_TMP, "surf-host.log");
82
136
  const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
@@ -89,7 +143,8 @@ const DEFAULT_RETRY_OPTIONS = {
89
143
  retryableStatusCodes: [429, 500, 502, 503, 504]
90
144
  };
91
145
 
92
- 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);
93
148
  try {
94
149
  return await fn();
95
150
  } catch (error) {
@@ -125,8 +180,8 @@ async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount =
125
180
  const jitter = 0.8 + Math.random() * 0.4;
126
181
  const delayWithJitter = Math.floor(delay * jitter);
127
182
 
128
- await new Promise(resolve => setTimeout(resolve, delayWithJitter));
129
- return withRetry(fn, retryOptions, retryCount + 1);
183
+ await require("./abort.cjs").abortableDelay(delayWithJitter, signal);
184
+ return withRetry(fn, retryOptions, retryCount + 1, signal);
130
185
  }
131
186
  }
132
187
 
@@ -194,13 +249,14 @@ class GeminiClient {
194
249
 
195
250
  async analyze(query, pageContext, options = {}) {
196
251
  const mode = options.mode || detectQueryMode(query);
252
+ throwIfAborted(options.signal);
197
253
  const promptFn = AI_PROMPTS[mode];
198
254
  const prompt = promptFn(query, pageContext);
199
255
 
200
256
  const result = await withRetry(async () => {
201
257
  const response = await this.model.generateContent(prompt);
202
258
  return response.response.text();
203
- });
259
+ }, DEFAULT_RETRY_OPTIONS, 0, options.signal);
204
260
 
205
261
  let content = result.trim();
206
262
 
@@ -291,57 +347,238 @@ const log = (msg) => {
291
347
  fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${msg}\n`);
292
348
  };
293
349
 
350
+ if (require.main === module) {
294
351
  log("Host starting...");
295
352
 
296
353
  if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
297
354
 
298
355
  const pendingRequests = new Map();
299
- const pendingToolRequests = new Map();
356
+ const pendingToolRequests = new RequestPendingMap({ getRequest: () => requestStorage.getStore() });
300
357
  const activeStreams = new Map();
358
+ const socketContexts = new WeakMap();
359
+ const socketWriters = new WeakMap();
301
360
  let requestCounter = 0;
302
361
 
303
- function sendToolResponse(socket, id, result, error) {
304
- const response = { type: "tool_response", id };
305
-
306
- if (error) {
307
- response.error = { content: [{ type: "text", text: error }] };
308
- } else {
309
- 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;
310
498
  }
311
-
312
- try {
313
- socket.write(JSON.stringify(response) + "\n");
314
- } catch (e) {
315
- 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
+ });
316
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 });
317
553
  }
318
554
 
319
555
  function handleStreamRequest(msg, socket) {
320
556
  const { streamType, options, id: originalId } = msg;
321
557
  const tabId = msg.tabId;
322
558
  const streamId = ++requestCounter;
323
-
559
+
324
560
  activeStreams.set(streamId, {
325
561
  socket,
326
562
  originalId,
327
563
  streamType,
328
564
  });
329
-
565
+
330
566
  writeMessage({
331
567
  type: streamType,
332
568
  streamId,
333
569
  options: options || {},
334
570
  tabId,
335
571
  });
336
-
337
- try {
338
- socket.write(JSON.stringify({ type: "stream_started", streamId }) + "\n");
339
- } catch (e) {
340
- log(`Error sending stream_started: ${e.message}`);
341
- }
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
+ });
342
578
  }
343
579
 
344
- function handleToolRequest(msg, socket) {
580
+ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore()) {
581
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
345
582
  const { method, params } = msg;
346
583
  const originalId = msg.id || null;
347
584
 
@@ -383,14 +620,14 @@ function handleToolRequest(msg, socket) {
383
620
  }
384
621
 
385
622
  if (extensionMsg.type === "LOCAL_WAIT") {
386
- setTimeout(() => {
387
- sendToolResponse(socket, originalId, { success: true }, null);
388
- }, 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));
389
626
  return;
390
627
  }
391
628
 
392
629
  if (extensionMsg.type === "BATCH_EXECUTE") {
393
- executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId);
630
+ executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId, requestContext);
394
631
  return;
395
632
  }
396
633
 
@@ -406,46 +643,23 @@ function handleToolRequest(msg, socket) {
406
643
  return;
407
644
  }
408
645
 
409
- const pageRequestId = ++requestCounter;
410
- pendingToolRequests.set(pageRequestId, {
411
- socket: null,
412
- originalId: null,
413
- tool: "read_page",
414
- onComplete: async (pageResult) => {
415
- if (pageResult.error) {
416
- sendToolResponse(socket, originalId, null, `Failed to read page: ${pageResult.error}`);
417
- return;
418
- }
419
-
420
- const pageContent = pageResult.pageContent || "";
421
- if (!pageContent) {
422
- sendToolResponse(socket, originalId, null, "No page content available");
423
- return;
424
- }
425
-
426
- try {
427
- const gemini = getGeminiClient(apiKey);
428
- const result = await gemini.analyze(extensionMsg.query, pageContent, { mode: extensionMsg.mode });
429
-
430
- if (result.mode === "find") {
431
- sendToolResponse(socket, originalId, {
432
- ref: result.content === "NOT_FOUND" ? null : result.content,
433
- mode: result.mode,
434
- aiResult: true
435
- }, null);
436
- } else {
437
- sendToolResponse(socket, originalId, {
438
- content: result.content,
439
- mode: result.mode,
440
- aiResult: true
441
- }, null);
442
- }
443
- } catch (err) {
444
- sendToolResponse(socket, originalId, null, `AI analysis failed: ${err.message}`);
445
- }
446
- }
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);
447
662
  });
448
- writeMessage({ type: "READ_PAGE", options: { filter: "interactive" }, tabId: extensionMsg.tabId, id: pageRequestId });
449
663
  return;
450
664
  }
451
665
 
@@ -455,16 +669,12 @@ function handleToolRequest(msg, socket) {
455
669
  queueAiRequest(async () => {
456
670
  let pageContext = null;
457
671
  if (withPage) {
458
- const pageResult = await new Promise((resolve) => {
459
- const pageId = ++requestCounter;
460
- pendingToolRequests.set(pageId, {
461
- socket: null,
462
- originalId: null,
463
- tool: "read_page",
464
- onComplete: resolve
465
- });
466
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
467
- });
672
+ const pageResult = await requestCallExtension(
673
+ requestContext,
674
+ "read_page",
675
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
676
+ 45000,
677
+ );
468
678
  if (pageResult && !pageResult.error) {
469
679
  pageContext = {
470
680
  url: pageResult.url,
@@ -480,69 +690,36 @@ function handleToolRequest(msg, socket) {
480
690
 
481
691
  const result = await chatgptClient.query({
482
692
  prompt: fullPrompt,
693
+ signal: requestContext.signal,
483
694
  model,
484
695
  file,
485
696
  timeout,
486
- getCookies: () => new Promise((resolve) => {
487
- const cookieId = ++requestCounter;
488
- pendingToolRequests.set(cookieId, {
489
- socket: null,
490
- originalId: null,
491
- tool: "get_cookies",
492
- onComplete: (r) => resolve(r)
493
- });
494
- writeMessage({ type: "GET_CHATGPT_COOKIES", id: cookieId });
495
- }),
496
- createTab: () => new Promise((resolve) => {
497
- const tabCreateId = ++requestCounter;
498
- pendingToolRequests.set(tabCreateId, {
499
- socket: null,
500
- originalId: null,
501
- tool: "create_tab",
502
- onComplete: (r) => resolve(r)
503
- });
504
- writeMessage({ type: "CHATGPT_NEW_TAB", id: tabCreateId });
505
- }),
506
- closeTab: (tabIdToClose) => new Promise((resolve) => {
507
- const tabCloseId = ++requestCounter;
508
- pendingToolRequests.set(tabCloseId, {
509
- socket: null,
510
- originalId: null,
511
- tool: "close_tab",
512
- onComplete: (r) => resolve(r)
513
- });
514
- writeMessage({ type: "CHATGPT_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
515
- }),
516
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
517
- const evalId = ++requestCounter;
518
- pendingToolRequests.set(evalId, {
519
- socket: null,
520
- originalId: null,
521
- tool: "cdp_evaluate",
522
- onComplete: (r) => resolve(r)
523
- });
524
- writeMessage({ type: "CHATGPT_EVALUATE", tabId, expression, id: evalId });
525
- }),
526
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
527
- const cmdId = ++requestCounter;
528
- pendingToolRequests.set(cmdId, {
529
- socket: null,
530
- originalId: null,
531
- tool: "cdp_command",
532
- onComplete: (r) => resolve(r)
533
- });
534
- writeMessage({ type: "CHATGPT_CDP_COMMAND", tabId, method, params, id: cmdId });
535
- }),
536
- uploadFile: (tabId, filePaths) => new Promise((resolve) => {
537
- const uploadId = ++requestCounter;
538
- pendingToolRequests.set(uploadId, {
539
- socket: null,
540
- originalId: null,
541
- tool: "upload_file",
542
- onComplete: (r) => resolve(r)
543
- });
544
- writeMessage(buildProviderUploadMessage("chatgpt", tabId, filePaths, uploadId));
545
- }),
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
+ ),
546
723
  log: (msg) => log(`[chatgpt] ${msg}`)
547
724
  });
548
725
 
@@ -566,16 +743,12 @@ function handleToolRequest(msg, socket) {
566
743
  queueAiRequest(async () => {
567
744
  let pageContext = null;
568
745
  if (withPage) {
569
- const pageResult = await new Promise((resolve) => {
570
- const pageId = ++requestCounter;
571
- pendingToolRequests.set(pageId, {
572
- socket: null,
573
- originalId: null,
574
- tool: "read_page",
575
- onComplete: resolve
576
- });
577
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
578
- });
746
+ const pageResult = await requestCallExtension(
747
+ requestContext,
748
+ "read_page",
749
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
750
+ 45000,
751
+ );
579
752
  if (pageResult && !pageResult.error) {
580
753
  pageContext = {
581
754
  url: pageResult.url,
@@ -591,49 +764,26 @@ function handleToolRequest(msg, socket) {
591
764
 
592
765
  const result = await perplexityClient.query({
593
766
  prompt: fullPrompt,
767
+ signal: requestContext.signal,
594
768
  mode: mode || 'search',
595
769
  model,
596
770
  timeout: timeout || 120000,
597
- createTab: () => new Promise((resolve) => {
598
- const tabCreateId = ++requestCounter;
599
- pendingToolRequests.set(tabCreateId, {
600
- socket: null,
601
- originalId: null,
602
- tool: "create_tab",
603
- onComplete: (r) => resolve(r)
604
- });
605
- writeMessage({ type: "PERPLEXITY_NEW_TAB", id: tabCreateId });
606
- }),
607
- closeTab: (tabIdToClose) => new Promise((resolve) => {
608
- const tabCloseId = ++requestCounter;
609
- pendingToolRequests.set(tabCloseId, {
610
- socket: null,
611
- originalId: null,
612
- tool: "close_tab",
613
- onComplete: (r) => resolve(r)
614
- });
615
- writeMessage({ type: "PERPLEXITY_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
616
- }),
617
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
618
- const evalId = ++requestCounter;
619
- pendingToolRequests.set(evalId, {
620
- socket: null,
621
- originalId: null,
622
- tool: "cdp_evaluate",
623
- onComplete: (r) => resolve(r)
624
- });
625
- writeMessage({ type: "PERPLEXITY_EVALUATE", tabId, expression, id: evalId });
626
- }),
627
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
628
- const cmdId = ++requestCounter;
629
- pendingToolRequests.set(cmdId, {
630
- socket: null,
631
- originalId: null,
632
- tool: "cdp_command",
633
- onComplete: (r) => resolve(r)
634
- });
635
- writeMessage({ type: "PERPLEXITY_CDP_COMMAND", tabId, method, params, id: cmdId });
636
- }),
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
+ ),
637
787
  log: (msg) => log(`[perplexity] ${msg}`)
638
788
  });
639
789
 
@@ -661,16 +811,12 @@ function handleToolRequest(msg, socket) {
661
811
  // 1. Get page context if requested
662
812
  let pageContext = null;
663
813
  if (withPage) {
664
- const pageResult = await new Promise((resolve) => {
665
- const pageId = ++requestCounter;
666
- pendingToolRequests.set(pageId, {
667
- socket: null,
668
- originalId: null,
669
- tool: "get_page_text",
670
- onComplete: resolve
671
- });
672
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
673
- });
814
+ const pageResult = await requestCallExtension(
815
+ requestContext,
816
+ "get_page_text",
817
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
818
+ 45000,
819
+ );
674
820
  if (pageResult && !pageResult.error) {
675
821
  pageContext = {
676
822
  url: pageResult.url,
@@ -688,7 +834,8 @@ function handleToolRequest(msg, socket) {
688
834
  // 3. Call Gemini client
689
835
  const result = await geminiClient.query({
690
836
  prompt: fullPrompt,
691
- model: model || "gemini-3-pro",
837
+ signal: requestContext.signal,
838
+ model: model || "gemini-3.1-pro",
692
839
  file,
693
840
  generateImage,
694
841
  editImage,
@@ -696,67 +843,32 @@ function handleToolRequest(msg, socket) {
696
843
  youtube,
697
844
  aspectRatio,
698
845
  timeout: timeout || 300000,
699
- getCookies: () => new Promise((resolve) => {
700
- const cookieId = ++requestCounter;
701
- pendingToolRequests.set(cookieId, {
702
- socket: null,
703
- originalId: null,
704
- tool: "get_cookies",
705
- onComplete: (r) => resolve(r)
706
- });
707
- writeMessage({ type: "GET_GOOGLE_COOKIES", id: cookieId });
708
- }),
709
- createTab: () => new Promise((resolve) => {
710
- const tabCreateId = ++requestCounter;
711
- pendingToolRequests.set(tabCreateId, {
712
- socket: null,
713
- originalId: null,
714
- tool: "create_tab",
715
- onComplete: (r) => resolve(r)
716
- });
717
- writeMessage({ type: "GEMINI_NEW_TAB", id: tabCreateId });
718
- }),
719
- closeTab: (tabIdToClose) => new Promise((resolve) => {
720
- const tabCloseId = ++requestCounter;
721
- pendingToolRequests.set(tabCloseId, {
722
- socket: null,
723
- originalId: null,
724
- tool: "close_tab",
725
- onComplete: (r) => resolve(r)
726
- });
727
- writeMessage({ type: "GEMINI_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
728
- }),
729
- jsEval: (tabId, code) => new Promise((resolve) => {
730
- const jsId = ++requestCounter;
731
- pendingToolRequests.set(jsId, {
732
- socket: null,
733
- originalId: null,
734
- tool: "js_eval",
735
- onComplete: (r) => resolve(r)
736
- });
737
- log(`[gemini] Sending EXECUTE_JAVASCRIPT id=${jsId} tabId=${tabId} code=${code.length} chars`);
738
- writeMessage({ type: "EXECUTE_JAVASCRIPT", tabId, code, id: jsId });
739
- }),
740
- uploadFile: (tabId, filePaths) => new Promise((resolve) => {
741
- const uploadId = ++requestCounter;
742
- pendingToolRequests.set(uploadId, {
743
- socket: null,
744
- originalId: null,
745
- tool: "upload_file",
746
- onComplete: (r) => resolve(r)
747
- });
748
- writeMessage(buildProviderUploadMessage("gemini", tabId, filePaths, uploadId));
749
- }),
750
- fetchUrl: (url) => new Promise((resolve) => {
751
- const fetchId = ++requestCounter;
752
- pendingToolRequests.set(fetchId, {
753
- socket: null,
754
- originalId: null,
755
- tool: "fetch_url",
756
- onComplete: (r) => resolve(r)
757
- });
758
- writeMessage({ type: "GEMINI_FETCH_URL", url, id: fetchId });
759
- }),
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
+ ),
760
872
  log: (msg) => log(`[gemini] ${msg}`)
761
873
  });
762
874
 
@@ -785,16 +897,12 @@ function handleToolRequest(msg, socket) {
785
897
  // 1. Get page context if requested
786
898
  let pageContext = null;
787
899
  if (withPage) {
788
- const pageResult = await new Promise((resolve) => {
789
- const pageId = ++requestCounter;
790
- pendingToolRequests.set(pageId, {
791
- socket: null,
792
- originalId: null,
793
- tool: "get_page_text",
794
- onComplete: resolve
795
- });
796
- writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId });
797
- });
900
+ const pageResult = await requestCallExtension(
901
+ requestContext,
902
+ "get_page_text",
903
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
904
+ 45000,
905
+ );
798
906
  if (pageResult && !pageResult.error) {
799
907
  pageContext = {
800
908
  url: pageResult.url,
@@ -812,59 +920,31 @@ function handleToolRequest(msg, socket) {
812
920
  // 3. Call Grok client
813
921
  const result = await grokClient.query({
814
922
  prompt: fullPrompt,
923
+ signal: requestContext.signal,
815
924
  model: model,
816
925
  deepSearch: deepSearch || false,
817
926
  timeout: timeout || 300000,
818
- getCookies: () => new Promise((resolve) => {
819
- const cookieId = ++requestCounter;
820
- pendingToolRequests.set(cookieId, {
821
- socket: null,
822
- originalId: null,
823
- tool: "get_cookies",
824
- onComplete: (r) => resolve(r)
825
- });
826
- writeMessage({ type: "GET_TWITTER_COOKIES", id: cookieId });
827
- }),
828
- createTab: () => new Promise((resolve) => {
829
- const tabCreateId = ++requestCounter;
830
- pendingToolRequests.set(tabCreateId, {
831
- socket: null,
832
- originalId: null,
833
- tool: "create_tab",
834
- onComplete: (r) => resolve(r)
835
- });
836
- writeMessage({ type: "GROK_NEW_TAB", id: tabCreateId });
837
- }),
838
- closeTab: (tabIdToClose) => new Promise((resolve) => {
839
- const tabCloseId = ++requestCounter;
840
- pendingToolRequests.set(tabCloseId, {
841
- socket: null,
842
- originalId: null,
843
- tool: "close_tab",
844
- onComplete: (r) => resolve(r)
845
- });
846
- writeMessage({ type: "GROK_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
847
- }),
848
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
849
- const evalId = ++requestCounter;
850
- pendingToolRequests.set(evalId, {
851
- socket: null,
852
- originalId: null,
853
- tool: "cdp_evaluate",
854
- onComplete: (r) => resolve(r)
855
- });
856
- writeMessage({ type: "GROK_EVALUATE", tabId, expression, id: evalId });
857
- }),
858
- cdpCommand: (tabId, method, params) => new Promise((resolve) => {
859
- const cmdId = ++requestCounter;
860
- pendingToolRequests.set(cmdId, {
861
- socket: null,
862
- originalId: null,
863
- tool: "cdp_command",
864
- onComplete: (r) => resolve(r)
865
- });
866
- writeMessage({ type: "GROK_CDP_COMMAND", tabId, method, params, id: cmdId });
867
- }),
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
+ ),
868
948
  log: (msg) => log(`[grok] ${msg}`)
869
949
  });
870
950
 
@@ -903,46 +983,29 @@ function handleToolRequest(msg, socket) {
903
983
 
904
984
  queueAiRequest(async () => {
905
985
  const result = await grokClient.validate({
906
- getCookies: () => new Promise((resolve) => {
907
- const cookieId = ++requestCounter;
908
- pendingToolRequests.set(cookieId, {
909
- socket: null,
910
- originalId: null,
911
- tool: "get_cookies",
912
- onComplete: (r) => resolve(r)
913
- });
914
- writeMessage({ type: "GET_TWITTER_COOKIES", id: cookieId });
915
- }),
916
- createTab: () => new Promise((resolve) => {
917
- const tabCreateId = ++requestCounter;
918
- pendingToolRequests.set(tabCreateId, {
919
- socket: null,
920
- originalId: null,
921
- tool: "create_tab",
922
- onComplete: (r) => resolve(r)
923
- });
924
- writeMessage({ type: "GROK_NEW_TAB", id: tabCreateId });
925
- }),
926
- closeTab: (tabIdToClose) => new Promise((resolve) => {
927
- const tabCloseId = ++requestCounter;
928
- pendingToolRequests.set(tabCloseId, {
929
- socket: null,
930
- originalId: null,
931
- tool: "close_tab",
932
- onComplete: (r) => resolve(r)
933
- });
934
- writeMessage({ type: "GROK_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId });
935
- }),
936
- cdpEvaluate: (tabId, expression) => new Promise((resolve) => {
937
- const evalId = ++requestCounter;
938
- pendingToolRequests.set(evalId, {
939
- socket: null,
940
- originalId: null,
941
- tool: "cdp_evaluate",
942
- onComplete: (r) => resolve(r)
943
- });
944
- writeMessage({ type: "GROK_EVALUATE", tabId, expression, id: evalId });
945
- }),
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
+ ),
946
1009
  log: (msg) => log(`[grok:validate] ${msg}`)
947
1010
  });
948
1011
 
@@ -987,30 +1050,12 @@ function handleToolRequest(msg, socket) {
987
1050
  queueAiRequest(async () => {
988
1051
  const EXT_CALL_TIMEOUT_MS = 30000;
989
1052
 
990
- const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => new Promise((resolve, reject) => {
991
- const id = ++requestCounter;
992
-
993
- if (msg && msg.type === "AISTUDIO_NEW_TAB") {
1053
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1054
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
994
1055
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/prompts/new_chat")}`);
995
1056
  }
996
-
997
- const timeoutId = setTimeout(() => {
998
- pendingToolRequests.delete(id);
999
- reject(new Error(`Timeout waiting for extension: ${toolName}`));
1000
- }, timeoutMs);
1001
-
1002
- pendingToolRequests.set(id, {
1003
- socket: null,
1004
- originalId: null,
1005
- tool: toolName,
1006
- onComplete: (r) => {
1007
- clearTimeout(timeoutId);
1008
- resolve(r);
1009
- }
1010
- });
1011
-
1012
- writeMessage({ ...msg, id });
1013
- });
1057
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1058
+ };
1014
1059
 
1015
1060
  // 1. Get page context if requested
1016
1061
  let pageContext = null;
@@ -1044,6 +1089,7 @@ function handleToolRequest(msg, socket) {
1044
1089
  // 3. Call AI Studio client
1045
1090
  const result = await aistudioClient.query({
1046
1091
  prompt: fullPrompt,
1092
+ signal: requestContext.signal,
1047
1093
  model: model || undefined,
1048
1094
  timeout: timeout || 300000,
1049
1095
  getCookies: () => callExtension("get_cookies", { type: "GET_GOOGLE_COOKIES" }, 45000),
@@ -1102,33 +1148,16 @@ function handleToolRequest(msg, socket) {
1102
1148
  queueAiRequest(async () => {
1103
1149
  const EXT_CALL_TIMEOUT_MS = 30000;
1104
1150
 
1105
- const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => new Promise((resolve, reject) => {
1106
- const id = ++requestCounter;
1107
-
1108
- if (msg && msg.type === "AISTUDIO_NEW_TAB") {
1151
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1152
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
1109
1153
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/apps")}`);
1110
1154
  }
1111
-
1112
- const timeoutId = setTimeout(() => {
1113
- pendingToolRequests.delete(id);
1114
- reject(new Error(`Timeout waiting for extension: ${toolName}`));
1115
- }, timeoutMs);
1116
-
1117
- pendingToolRequests.set(id, {
1118
- socket: null,
1119
- originalId: null,
1120
- tool: toolName,
1121
- onComplete: (r) => {
1122
- clearTimeout(timeoutId);
1123
- resolve(r);
1124
- }
1125
- });
1126
-
1127
- writeMessage({ ...msg, id });
1128
- });
1155
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1156
+ };
1129
1157
 
1130
1158
  const result = await aistudioBuild.build({
1131
1159
  prompt: query,
1160
+ signal: requestContext.signal,
1132
1161
  model: model || undefined,
1133
1162
  output,
1134
1163
  keepOpen,
@@ -1179,6 +1208,7 @@ function handleToolRequest(msg, socket) {
1179
1208
  let lastError = null;
1180
1209
 
1181
1210
  const sendNextKey = () => {
1211
+ if (requestContext.signal.aborted) return;
1182
1212
  if (completed >= repeat) {
1183
1213
  if (lastError) {
1184
1214
  sendToolResponse(socket, originalId, null, `Key repeat failed: ${lastError}`);
@@ -1187,18 +1217,15 @@ function handleToolRequest(msg, socket) {
1187
1217
  }
1188
1218
  return;
1189
1219
  }
1190
- const id = ++requestCounter;
1191
- pendingToolRequests.set(id, {
1192
- socket: null,
1193
- originalId: null,
1220
+ requestCallExtension(
1221
+ requestContext,
1194
1222
  tool,
1195
- onComplete: (result) => {
1196
- if (result.error) lastError = result.error;
1197
- completed++;
1198
- setTimeout(sendNextKey, 50);
1199
- }
1200
- });
1201
- 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));
1202
1229
  };
1203
1230
  sendNextKey();
1204
1231
  return;
@@ -1206,23 +1233,25 @@ function handleToolRequest(msg, socket) {
1206
1233
 
1207
1234
  if (extensionMsg.type === "NAMED_TAB_SWITCH" || extensionMsg.type === "NAMED_TAB_CLOSE") {
1208
1235
  const { name, type: opType } = extensionMsg;
1209
- const lookupId = ++requestCounter;
1210
- pendingToolRequests.set(lookupId, {
1211
- socket: null,
1212
- originalId: null,
1213
- tool: "tabs_get_by_name",
1214
- onComplete: (result) => {
1215
- if (result.error || !result.tabId) {
1216
- sendToolResponse(socket, originalId, null, result.error || `No tab found with name "${name}"`);
1217
- return;
1218
- }
1219
- const actionId = ++requestCounter;
1220
- const actionType = opType === "NAMED_TAB_SWITCH" ? "SWITCH_TAB" : "CLOSE_TAB";
1221
- pendingToolRequests.set(actionId, { socket, originalId, tool, tabId: result.tabId });
1222
- 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}"`);
1223
1243
  }
1224
- });
1225
- 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));
1226
1255
  return;
1227
1256
  }
1228
1257
 
@@ -1232,7 +1261,11 @@ function handleToolRequest(msg, socket) {
1232
1261
  originalId,
1233
1262
  tool,
1234
1263
  savePath: extensionMsg.savePath || args?.savePath,
1235
- 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",
1236
1269
  fullRes: extensionMsg.fullRes || args?.fullRes,
1237
1270
  maxSize: extensionMsg.maxSize || args?.maxSize,
1238
1271
  tabId: extensionMsg.tabId || tabId
@@ -1245,12 +1278,14 @@ function handleToolRequest(msg, socket) {
1245
1278
  writeMessage(finalMsg);
1246
1279
  }
1247
1280
 
1248
- function executeBatch(actions, tabId, socket, originalId) {
1281
+ function executeBatch(actions, tabId, socket, originalId, requestContext = requestStorage.getStore()) {
1282
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
1249
1283
  const results = [];
1250
1284
  const DELAY_MS = 100;
1251
1285
  let currentIndex = 0;
1252
1286
 
1253
1287
  function executeNextAction() {
1288
+ if (requestContext.signal.aborted) return;
1254
1289
  if (currentIndex >= actions.length) {
1255
1290
  sendToolResponse(socket, originalId, {
1256
1291
  success: true,
@@ -1281,16 +1316,14 @@ function executeBatch(actions, tabId, socket, originalId) {
1281
1316
  if (extensionMsg.type === "LOCAL_WAIT") {
1282
1317
  results.push({ index: currentIndex, type: action.type, success: true });
1283
1318
  currentIndex++;
1284
- 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));
1285
1322
  return;
1286
1323
  }
1287
1324
 
1288
- const id = ++requestCounter;
1289
- pendingToolRequests.set(id, {
1290
- socket: null,
1291
- originalId: null,
1292
- tool: toolName,
1293
- onComplete: (result) => {
1325
+ requestCallExtension(requestContext, toolName, extensionMsg, 30000)
1326
+ .then((result) => {
1294
1327
  if (result.error) {
1295
1328
  results.push({ index: currentIndex, type: action.type, success: false, error: result.error });
1296
1329
  sendToolResponse(socket, originalId, {
@@ -1302,15 +1335,12 @@ function executeBatch(actions, tabId, socket, originalId) {
1302
1335
  }, null);
1303
1336
  return;
1304
1337
  }
1305
-
1306
1338
  results.push({ index: currentIndex, type: action.type, success: true });
1307
1339
  currentIndex++;
1308
-
1309
- setTimeout(executeNextAction, DELAY_MS);
1310
- }
1311
- });
1312
-
1313
- 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));
1314
1344
  }
1315
1345
 
1316
1346
  executeNextAction();
@@ -1371,7 +1401,7 @@ function processInput() {
1371
1401
 
1372
1402
  try {
1373
1403
  const msg = JSON.parse(jsonStr);
1374
- log(`Received from extension: ${JSON.stringify(msg)}`);
1404
+ log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
1375
1405
 
1376
1406
  if (msg.type === "GET_AUTH") {
1377
1407
  log("Handling GET_AUTH from extension");
@@ -1405,24 +1435,24 @@ function processInput() {
1405
1435
  if (msg.type === "STREAM_EVENT") {
1406
1436
  const stream = activeStreams.get(msg.streamId);
1407
1437
  if (stream) {
1408
- try {
1409
- stream.socket.write(JSON.stringify(msg.event) + "\n");
1410
- } catch (e) {
1411
- log(`Error forwarding stream event: ${e.message}`);
1412
- activeStreams.delete(msg.streamId);
1413
- writeMessage({ type: "STREAM_STOP", streamId: msg.streamId });
1414
- }
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
+ });
1415
1443
  }
1416
1444
  return;
1417
1445
  }
1418
-
1446
+
1419
1447
  if (msg.type === "STREAM_ERROR") {
1420
1448
  const stream = activeStreams.get(msg.streamId);
1421
1449
  if (stream) {
1422
- try {
1423
- stream.socket.write(JSON.stringify({ error: msg.error }) + "\n");
1424
- } catch (e) {}
1425
- 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));
1426
1456
  }
1427
1457
  return;
1428
1458
  }
@@ -1430,19 +1460,41 @@ function processInput() {
1430
1460
 
1431
1461
  if (msg.id && pendingToolRequests.has(msg.id)) {
1432
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
+ }
1433
1476
  pendingToolRequests.delete(msg.id);
1434
-
1435
- if (pending.onComplete) {
1436
- pending.onComplete(msg);
1437
- } else {
1477
+ {
1478
+
1438
1479
  const { socket, originalId, savePath, autoScreenshot, tabId: storedTabId } = pending;
1439
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);
1440
1484
 
1441
- 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) {
1442
1493
  try {
1443
1494
  const dir = path.dirname(savePath);
1444
1495
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1445
- 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 {}
1446
1498
  const origWidth = msg.width || 0;
1447
1499
  const origHeight = msg.height || 0;
1448
1500
  const maxSize = pending.maxSize || 1200;
@@ -1465,8 +1517,7 @@ function processInput() {
1465
1517
  }
1466
1518
  } else if (autoScreenshot && tabId && !msg.error && !msg.base64) {
1467
1519
 
1468
- const screenshotId = ++requestCounter;
1469
- 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`);
1470
1521
 
1471
1522
  const autoFiles = fs.readdirSync(SURF_TMP)
1472
1523
  .filter(f => f.startsWith("pi-auto-") && f.endsWith(".png"))
@@ -1477,14 +1528,17 @@ function processInput() {
1477
1528
  try { fs.unlinkSync(path.join(SURF_TMP, f.name)); } catch (e) {}
1478
1529
  });
1479
1530
  }
1480
- pendingToolRequests.set(screenshotId, {
1481
- socket: null,
1482
- originalId: null,
1483
- tool: "screenshot",
1484
- 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) => {
1485
1538
  if (screenshotMsg.base64) {
1486
1539
  try {
1487
- 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 {}
1488
1542
  const origW = screenshotMsg.width || 0;
1489
1543
  const origH = screenshotMsg.height || 0;
1490
1544
  let finalW = origW, finalH = origH;
@@ -1501,16 +1555,17 @@ function processInput() {
1501
1555
  autoScreenshot: { path: screenshotPath, width: finalW, height: finalH, originalWidth: origW, originalHeight: origH }
1502
1556
  }, null);
1503
1557
  } catch (e) {
1504
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: e.message }, null);
1558
+ failAutoScreenshot(e.message);
1505
1559
  }
1506
1560
  } else {
1507
1561
  const errMsg = screenshotMsg.error || "Failed to capture";
1508
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: errMsg }, null);
1562
+ failAutoScreenshot(errMsg);
1509
1563
  }
1510
- }
1511
- });
1512
- setTimeout(() => writeMessage({ type: "EXECUTE_SCREENSHOT", tabId, id: screenshotId }), 500);
1564
+ })
1565
+ .catch((error) => failAutoScreenshot(error.message));
1513
1566
  return;
1567
+ } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
1568
+ failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
1514
1569
  } else if (msg.results && msg.savePath) {
1515
1570
  try {
1516
1571
  const dir = msg.savePath;
@@ -1544,11 +1599,7 @@ function processInput() {
1544
1599
  }
1545
1600
  } else if (msg.id && pendingRequests.has(msg.id)) {
1546
1601
  const { socket } = pendingRequests.get(msg.id);
1547
- try {
1548
- socket.write(JSON.stringify(msg) + "\n");
1549
- } catch (e) {
1550
- log(`Error writing to CLI socket: ${e.message}`);
1551
- }
1602
+ sendSocket(socket, msg).catch((error) => log(`Error writing to CLI socket: ${error.message}`));
1552
1603
  pendingRequests.delete(msg.id);
1553
1604
  }
1554
1605
  } catch (e) {
@@ -1571,17 +1622,12 @@ const connectedSockets = new Set();
1571
1622
  process.stdin.on("end", () => {
1572
1623
  log("stdin ended (extension disconnected), notifying clients");
1573
1624
  for (const socket of Array.from(connectedSockets)) {
1574
- try {
1575
- socket.write(JSON.stringify({
1576
- type: "extension_disconnected",
1577
- message: "Surf extension was reloaded. Restart your command."
1578
- }) + "\n");
1579
- socket.end();
1580
- } catch (e) {
1581
- // Socket may already be closed
1582
- }
1625
+ sendSocket(socket, {
1626
+ type: "extension_disconnected",
1627
+ message: "Surf extension was reloaded. Restart your command."
1628
+ }).finally(() => socket.end()).catch(() => socket.end());
1583
1629
  }
1584
- process.exit(0);
1630
+ shutdown(0);
1585
1631
  });
1586
1632
 
1587
1633
  process.stdin.on("error", (err) => {
@@ -1592,142 +1638,276 @@ process.stdout.on("error", (err) => {
1592
1638
  log(`stdout error: ${err.message}`);
1593
1639
  });
1594
1640
 
1595
- 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);
1596
1692
  log("CLI client connected");
1597
1693
  connectedSockets.add(socket);
1598
1694
  socket.on("close", () => connectedSockets.delete(socket));
1599
1695
 
1600
- 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
+ }
1601
1732
 
1602
- socket.on("data", (data) => {
1603
- dataBuffer += data.toString();
1604
- const lines = dataBuffer.split("\n");
1605
- dataBuffer = lines.pop() || "";
1733
+ if (isRemote && msg.type && msg.type.startsWith("transfer_")) {
1734
+ transferState ||= await ensureTransferState();
1735
+ await transferState.handle(msg);
1736
+ return;
1737
+ }
1606
1738
 
1607
- for (const line of lines) {
1608
- if (!line.trim()) continue;
1739
+ if (msg.type === "tool_request") {
1740
+ const tool = msg.params?.tool || "unknown";
1741
+ let request;
1609
1742
  try {
1610
- const msg = JSON.parse(line);
1611
-
1612
- if (msg.type === "GET_AUTH") {
1613
- log("Handling GET_AUTH locally");
1614
- try {
1615
- if (fs.existsSync(AUTH_FILE)) {
1616
- const authData = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1617
- socket.write(JSON.stringify({
1618
- id: msg.id || 0,
1619
- auth: authData,
1620
- hint: null
1621
- }) + "\n");
1622
- } else {
1623
- socket.write(JSON.stringify({
1624
- id: msg.id || 0,
1625
- auth: null,
1626
- hint: "No OAuth credentials found. Run 'pi --login anthropic' in terminal to authenticate with Claude Max."
1627
- }) + "\n");
1628
- }
1629
- } catch (e) {
1630
- log(`Error reading auth file: ${e.message}`);
1631
- socket.write(JSON.stringify({
1632
- id: msg.id || 0,
1633
- auth: null,
1634
- hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
1635
- }) + "\n");
1636
- }
1637
- continue;
1638
- }
1639
-
1640
- if (msg.type === "tool_request") {
1641
- log("Handling tool_request: " + msg.method + " " + (msg.params?.tool || ""));
1642
- try {
1643
- handleToolRequest(msg, socket);
1644
- } catch (e) {
1645
- socket.write(JSON.stringify({ error: e.message || "Request failed" }) + "\n");
1646
- }
1647
- continue;
1648
- }
1649
-
1650
- if (msg.type === "stream_request") {
1651
- log("Handling stream_request: " + msg.streamType);
1652
- handleStreamRequest(msg, socket);
1653
- continue;
1654
- }
1655
-
1656
- if (msg.type === "stream_stop") {
1657
- log("Handling stream_stop");
1658
- for (const [streamId, stream] of activeStreams.entries()) {
1659
- if (stream.socket === socket) {
1660
- writeMessage({ type: "STREAM_STOP", streamId });
1661
- activeStreams.delete(streamId);
1662
- }
1663
- }
1664
- 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);
1665
1755
  }
1666
-
1667
- const id = ++requestCounter;
1668
- log(`Forwarding to extension: id=${id} type=${msg.type}`);
1669
- pendingRequests.set(id, { socket });
1670
- writeMessage({ ...msg, id });
1756
+ throwIfAborted(request.signal, "Request cancelled");
1757
+ requestStorage.run(request, () => handleToolRequest(msg, socket, request));
1671
1758
  } catch (e) {
1672
- log(`Error parsing CLI request: ${e.message}`);
1673
- socket.write(JSON.stringify({ error: "Invalid request" }) + "\n");
1759
+ await discardRequestTransfers(msg, transferState);
1760
+ sendToolResponse(socket, msg.id || null, null, e.message || "Request failed");
1674
1761
  }
1762
+ return;
1675
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,
1676
1814
  });
1677
1815
 
1816
+ socket.on("data", (data) => parser.push(data));
1817
+
1678
1818
  socket.on("error", (err) => {
1679
1819
  log(`CLI socket error: ${err.message}`);
1680
1820
  });
1681
1821
 
1682
1822
  socket.on("close", () => {
1683
- log("CLI client disconnected");
1684
- for (const [id, pending] of pendingRequests.entries()) {
1685
- if (pending.socket === socket) {
1686
- pendingRequests.delete(id);
1687
- }
1688
- }
1689
- for (const [id, pending] of pendingToolRequests.entries()) {
1690
- if (pending.socket === socket && !pending.autoScreenshot) {
1691
- pendingToolRequests.delete(id);
1692
- }
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");
1693
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");
1694
1846
  for (const [streamId, stream] of activeStreams.entries()) {
1695
- if (stream.socket === socket) {
1696
- writeMessage({ type: "STREAM_STOP", streamId });
1697
- activeStreams.delete(streamId);
1698
- }
1847
+ if (stream.socket === socket) stopActiveStream(streamId);
1699
1848
  }
1700
1849
  });
1701
- });
1702
-
1703
- server.listen(SOCKET_PATH, () => {
1704
- log("Socket server listening on " + SOCKET_PATH);
1705
- if (!IS_WIN) { try { fs.chmodSync(SOCKET_PATH, 0o600); } catch {} }
1706
- writeMessage({ type: "HOST_READY" });
1707
- log("Sent HOST_READY to extension");
1708
- });
1850
+ };
1709
1851
 
1710
- server.on("error", (err) => {
1711
- log(`Server error: ${err.message}`);
1712
- });
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();
1713
1894
 
1714
1895
  process.on("SIGTERM", () => {
1715
1896
  log("SIGTERM received");
1716
- server.close();
1717
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1718
- process.exit(0);
1897
+ shutdown();
1719
1898
  });
1720
1899
 
1721
1900
  process.on("SIGINT", () => {
1722
1901
  log("SIGINT received");
1723
- server.close();
1724
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1725
- process.exit(0);
1902
+ shutdown();
1726
1903
  });
1727
1904
 
1728
1905
  process.on("uncaughtException", (err) => {
1729
1906
  log(`Uncaught exception: ${err.message}\n${err.stack}`);
1730
- process.exit(1);
1907
+ shutdown(1);
1731
1908
  });
1732
1909
 
1733
1910
  log("Host initialization complete, waiting for connections...");
1911
+ } else {
1912
+ module.exports = { createListenerLifecycle, MAX_CLIENT_FRAME_BYTES };
1913
+ }