surf-cli 2.8.0 → 2.10.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 (47) hide show
  1. package/README.md +146 -8
  2. package/native/abort.cjs +65 -0
  3. package/native/activity-journal.cjs +55 -0
  4. package/native/ai-queue.cjs +64 -0
  5. package/native/aistudio-build.cjs +21 -13
  6. package/native/aistudio-client.cjs +40 -20
  7. package/native/browser-lock.cjs +2 -2
  8. package/native/chatgpt-client.cjs +49 -31
  9. package/native/cli.cjs +352 -482
  10. package/native/client-transport.cjs +168 -0
  11. package/native/do-executor.cjs +68 -510
  12. package/native/do-parser.cjs +8 -249
  13. package/native/doctor.cjs +55 -5
  14. package/native/endpoint.cjs +174 -0
  15. package/native/file-transfer.cjs +734 -0
  16. package/native/gemini-client.cjs +156 -71
  17. package/native/grok-client.cjs +98 -89
  18. package/native/host-helpers.cjs +43 -26
  19. package/native/host-sessions.cjs +287 -0
  20. package/native/host.cjs +998 -620
  21. package/native/listener.cjs +20 -0
  22. package/native/mcp-server.cjs +60 -65
  23. package/native/network-export.cjs +116 -0
  24. package/native/network-store.cjs +38 -58
  25. package/native/perplexity-client.cjs +46 -17
  26. package/native/playbook-authoring.cjs +44 -0
  27. package/native/playbook-cli.cjs +157 -0
  28. package/native/playbook-client.cjs +259 -0
  29. package/native/playbook-receipts.cjs +109 -0
  30. package/native/playbook-records.cjs +208 -0
  31. package/native/playbook-runtime.cjs +177 -0
  32. package/native/playbooks.cjs +235 -0
  33. package/native/private-state.cjs +156 -0
  34. package/native/redaction.cjs +104 -0
  35. package/native/remote-auth.cjs +279 -0
  36. package/native/remote-transport.cjs +337 -0
  37. package/native/request-pending.cjs +148 -0
  38. package/native/socket-path.cjs +1 -1
  39. package/native/workflow-definition.cjs +368 -0
  40. package/native/workflow-runtime.cjs +225 -0
  41. package/package.json +9 -6
  42. package/playbooks/page/ops/read.json +22 -0
  43. package/playbooks/page/playbook.json +7 -0
  44. package/scripts/install-native-host.cjs +36 -5
  45. package/skills/README.md +11 -5
  46. package/skills/deep-x-research/SKILL.md +106 -0
  47. package/skills/surf/SKILL.md +72 -5
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,99 @@ 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, abortableDelay, 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 networkStore = require("./network-store.cjs");
31
+ const { redactUrlSecrets } = require("./redaction.cjs");
32
+ const { appendActivity, journalCommand } = require("./activity-journal.cjs");
33
+ const { reserveReceipt, updateReceipt } = require("./playbook-receipts.cjs");
34
+ const {
35
+ activeRecord,
36
+ appendRecordEvent,
37
+ attachNetworkTrace,
38
+ discardRecord,
39
+ markRecord,
40
+ pauseRecord,
41
+ resumeRecord,
42
+ startRecord,
43
+ stopRecord,
44
+ updateRecordContext,
45
+ } = require("./playbook-records.cjs");
46
+ const { resolveArgs, runPlaybookOp } = require("./playbook-runtime.cjs");
47
+ const { resolveOp } = require("./playbooks.cjs");
48
+ const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
49
+ const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
50
+ const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
51
+ ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
52
+ : null;
19
53
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
20
54
 
55
+ // The endpoint passed here is already validated by the caller. Keeping this
56
+ // lifecycle separate lets tests use an ephemeral loopback port without adding
57
+ // a localhost escape hatch to SURF_LISTEN parsing.
58
+ function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onFatal }) {
59
+ const localServer = net.createServer(handler);
60
+ const tcpServer = tcpEndpoint ? net.createServer(handler) : null;
61
+ let shuttingDown = false;
62
+ let startPromise = null;
63
+ const close = (server) => {
64
+ if (!server) return;
65
+ try { server.close(); } catch (error) {
66
+ if (error.code !== "ERR_SERVER_NOT_RUNNING") throw error;
67
+ }
68
+ };
69
+ const unlink = () => { if (!IS_WIN) { try { fs.unlinkSync(localPath); } catch {} } };
70
+ const listen = (server, options) => new Promise((resolve, reject) => {
71
+ server.once("error", reject);
72
+ server.listen(options, () => {
73
+ server.removeListener("error", reject);
74
+ if (shuttingDown) { close(server); unlink(); }
75
+ resolve();
76
+ });
77
+ });
78
+ const start = () => {
79
+ if (startPromise) return startPromise;
80
+ startPromise = (async () => {
81
+ try {
82
+ await listen(localServer, localPath);
83
+ if (shuttingDown) return false;
84
+ if (!IS_WIN) { try { fs.chmodSync(localPath, 0o600); } catch {} }
85
+ if (tcpServer) {
86
+ await listen(tcpServer, tcpEndpoint);
87
+ if (shuttingDown) return false;
88
+ }
89
+ onReady();
90
+ return true;
91
+ } catch (error) {
92
+ if (!shuttingDown) onFatal(error);
93
+ close(localServer); close(tcpServer); unlink();
94
+ return false;
95
+ } finally {
96
+ if (shuttingDown) { close(localServer); close(tcpServer); unlink(); }
97
+ }
98
+ })();
99
+ return startPromise;
100
+ };
101
+ return {
102
+ localServer,
103
+ tcpServer,
104
+ start,
105
+ async shutdown() {
106
+ shuttingDown = true;
107
+ close(localServer); close(tcpServer); unlink();
108
+ if (startPromise) await startPromise;
109
+ unlink();
110
+ },
111
+ };
112
+ }
113
+
21
114
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
22
115
  function resizeImage(filePath, maxSize) {
23
116
  const platform = process.platform;
@@ -54,29 +147,9 @@ function resizeImage(filePath, maxSize) {
54
147
  }
55
148
  }
56
149
 
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
- }
150
+ let aiQueue;
151
+ function queueAiRequest(handler, request = requestStorage.getStore()) {
152
+ return aiQueue.enqueue(handler, request);
80
153
  }
81
154
  const LOG_FILE = path.join(SURF_TMP, "surf-host.log");
82
155
  const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
@@ -89,7 +162,8 @@ const DEFAULT_RETRY_OPTIONS = {
89
162
  retryableStatusCodes: [429, 500, 502, 503, 504]
90
163
  };
91
164
 
92
- async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount = 0) {
165
+ async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount = 0, signal) {
166
+ throwIfAborted(signal);
93
167
  try {
94
168
  return await fn();
95
169
  } catch (error) {
@@ -125,8 +199,8 @@ async function withRetry(fn, retryOptions = DEFAULT_RETRY_OPTIONS, retryCount =
125
199
  const jitter = 0.8 + Math.random() * 0.4;
126
200
  const delayWithJitter = Math.floor(delay * jitter);
127
201
 
128
- await new Promise(resolve => setTimeout(resolve, delayWithJitter));
129
- return withRetry(fn, retryOptions, retryCount + 1);
202
+ await require("./abort.cjs").abortableDelay(delayWithJitter, signal);
203
+ return withRetry(fn, retryOptions, retryCount + 1, signal);
130
204
  }
131
205
  }
132
206
 
@@ -194,13 +268,14 @@ class GeminiClient {
194
268
 
195
269
  async analyze(query, pageContext, options = {}) {
196
270
  const mode = options.mode || detectQueryMode(query);
271
+ throwIfAborted(options.signal);
197
272
  const promptFn = AI_PROMPTS[mode];
198
273
  const prompt = promptFn(query, pageContext);
199
274
 
200
275
  const result = await withRetry(async () => {
201
276
  const response = await this.model.generateContent(prompt);
202
277
  return response.response.text();
203
- });
278
+ }, DEFAULT_RETRY_OPTIONS, 0, options.signal);
204
279
 
205
280
  let content = result.trim();
206
281
 
@@ -291,57 +366,378 @@ const log = (msg) => {
291
366
  fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${msg}\n`);
292
367
  };
293
368
 
369
+ if (require.main === module) {
294
370
  log("Host starting...");
295
371
 
296
372
  if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
297
373
 
298
374
  const pendingRequests = new Map();
299
- const pendingToolRequests = new Map();
375
+ const pendingToolRequests = new RequestPendingMap({ getRequest: () => requestStorage.getStore() });
300
376
  const activeStreams = new Map();
377
+ const socketContexts = new WeakMap();
378
+ const socketWriters = new WeakMap();
301
379
  let requestCounter = 0;
302
380
 
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) };
381
+ function auditSession(event) {
382
+ const context = event.context;
383
+ const principal = context?.principal;
384
+ const request = event.request;
385
+ log(`SESSION ${JSON.stringify({
386
+ event: event.event,
387
+ outcome: event.outcome,
388
+ principalId: principal?.clientId || "local",
389
+ principalLabel: principal?.label || "local",
390
+ peer: context?.socket?.remoteAddress || "local",
391
+ requestId: request?.id,
392
+ tool: request?.tool,
393
+ elapsedMs: event.elapsedMs,
394
+ })}`);
395
+ }
396
+
397
+ aiQueue = new BoundedAiQueue({
398
+ maxQueued: 8,
399
+ audit: (event) => auditSession(event),
400
+ run: (handler, request) => request
401
+ ? requestStorage.run(request, () => handler())
402
+ : handler(),
403
+ });
404
+
405
+ function sendSocket(socket, value, options = {}) {
406
+ const writer = socketWriters.get(socket);
407
+ return writer ? writer.send(value, options) : writeFrame(socket, value);
408
+ }
409
+
410
+ function sendOwnedExtensionMessage(request, message) {
411
+ const cleanupMessage = typeof message?.type === "string" && /(?:CLOSE_TAB|TAB_CLOSE)$/.test(message.type);
412
+ if (request?.hardBoundary) throwIfAborted(request.signal, "Request timed out");
413
+ if (!cleanupMessage) throwIfAborted(request?.signal, "Request cancelled");
414
+ writeMessage(message);
415
+ }
416
+
417
+ function requestCallExtension(request, tool, message, timeoutMs = 30000, cleanup = false) {
418
+ cleanup = cleanup || tool === "close_tab";
419
+ if (request?.hardBoundary) throwIfAborted(request.signal, "Request timed out");
420
+ if (!cleanup) throwIfAborted(request?.signal, "Request cancelled");
421
+ return new Promise((resolve, reject) => {
422
+ const id = ++requestCounter;
423
+ const timer = setTimeout(() => {
424
+ pendingToolRequests.expire(id, new Error(`Timeout waiting for extension: ${tool}`));
425
+ }, timeoutMs);
426
+ const pending = {
427
+ request,
428
+ cleanup,
429
+ tool,
430
+ resolve: (result) => {
431
+ clearTimeout(timer);
432
+ resolve(result);
433
+ },
434
+ reject: (error) => {
435
+ clearTimeout(timer);
436
+ reject(error);
437
+ },
438
+ };
439
+ pendingToolRequests.set(id, pending);
440
+ try {
441
+ if (cleanup) writeMessage({ ...message, id });
442
+ else sendOwnedExtensionMessage(request, { ...message, id });
443
+ } catch (error) {
444
+ pendingToolRequests.delete(id);
445
+ reject(error);
446
+ }
447
+ });
448
+ }
449
+
450
+ async function executeMappedHostTool(request, tool, args, tabId) {
451
+ const extensionMsg = mapToolToMessage(tool, args, tabId);
452
+ if (!extensionMsg) throw new Error(`Unknown tool: ${tool}`);
453
+ if (extensionMsg.type === "UNSUPPORTED_ACTION") throw new Error(extensionMsg.message);
454
+ if (extensionMsg.type === "LOCAL_WAIT") {
455
+ await abortableDelay(extensionMsg.seconds * 1000, request.signal);
456
+ return { success: true };
310
457
  }
311
-
312
- try {
313
- socket.write(JSON.stringify(response) + "\n");
314
- } catch (e) {
315
- log(`Error sending tool_response: ${e.message}`);
458
+ if (extensionMsg.type === "BATCH_EXECUTE" || extensionMsg.type.endsWith("_QUERY")) {
459
+ throw new Error(`tool ${tool} is not available inside a host-owned workflow`);
460
+ }
461
+ return requestCallExtension(request, tool, extensionMsg, resolveRequestDeadlineMs(tool, args));
462
+ }
463
+
464
+ async function executeNativePlaybook(request, handler, args, options = {}) {
465
+ if (handler !== "chatgpt.ask") throw new Error(`unknown native playbook handler: ${handler}`);
466
+ const result = await chatgptClient.query({
467
+ prompt: args.prompt,
468
+ signal: request.signal,
469
+ model: args.model,
470
+ timeout: args.timeout ? Number(args.timeout) * 1000 : undefined,
471
+ getCookies: () => requestCallExtension(request, "get_cookies", { type: "GET_CHATGPT_COOKIES" }),
472
+ createTab: () => requestCallExtension(request, "create_tab", { type: "CHATGPT_NEW_TAB" }),
473
+ closeTab: (tabId) => requestCallExtension(request, "close_tab", { type: "CHATGPT_CLOSE_TAB", tabId }, 45000, true),
474
+ cdpEvaluate: (tabId, expression) => requestCallExtension(request, "cdp_evaluate", { type: "CHATGPT_EVALUATE", tabId, expression }),
475
+ cdpCommand: (tabId, method, params) => requestCallExtension(request, "cdp_command", { type: "CHATGPT_CDP_COMMAND", tabId, method, params }),
476
+ beforeSubmit: options.markDispatched,
477
+ log: (message) => log(`[playbook:chatgpt] ${message}`),
478
+ });
479
+ return { response: result.response, model: result.model, tookMs: result.tookMs };
480
+ }
481
+
482
+ async function runHostPlaybook(msg, request) {
483
+ const params = msg.params?.args || {};
484
+ const { playbook, op } = resolveOp(params.playbook, params.op, {
485
+ cwd: request.context?.isRemote ? process.cwd() : params.projectDir || process.cwd(),
486
+ pinBuiltIn: params.pinBuiltIn === true,
487
+ });
488
+ const runArgs = resolveArgs(op, params.args || {});
489
+ if (op.effect === "write" && op.safety.authorization === "explicit" && params.write !== true) {
490
+ throw new Error(`write op ${playbook.id} ${op.id} requires --write`);
491
+ }
492
+ const receipt = reserveReceipt({
493
+ playbookId: playbook.id,
494
+ op,
495
+ args: runArgs,
496
+ repeat: params.repeat === true,
497
+ retryAttempt: params.retryAttempt,
498
+ overrideInDoubt: params.overrideInDoubt === true,
499
+ });
500
+ const report = (event) => {
501
+ appendActivity(event);
502
+ appendRecordEvent(event);
503
+ };
504
+ return runPlaybookOp({
505
+ playbook,
506
+ op,
507
+ args: runArgs,
508
+ attemptId: receipt?.attemptId,
509
+ signal: request.signal,
510
+ executeTool: (tool, args) => executeMappedHostTool(request, tool, args, msg.tabId),
511
+ executeNative: (handler, args, options) => executeNativePlaybook(request, handler, args, options),
512
+ sleep: (ms) => abortableDelay(ms, request.signal),
513
+ onEvent: report,
514
+ beforeDispatch: async () => updateReceipt(receipt, "dispatched"),
515
+ afterDispatch: async ({ status, error }) => updateReceipt(receipt, status, { error }),
516
+ });
517
+ }
518
+
519
+ async function handleRecordRequest(tool, args, msg, request) {
520
+ if (tool === "playbook.record.start") {
521
+ let record = startRecord({ ...args, tabId: msg.tabId });
522
+ try {
523
+ const context = await requestCallExtension(request, tool, {
524
+ type: "GET_PLAYBOOK_RECORD_CONTEXT",
525
+ tabId: msg.tabId,
526
+ });
527
+ record = updateRecordContext({
528
+ tabId: context._resolvedTabId || msg.tabId,
529
+ origin: context.origin,
530
+ });
531
+ if (record.capture.network) {
532
+ const result = await requestCallExtension(request, tool, { type: "START_NETWORK_CAPTURE", tabId: record.tabId, bodyMode: "text" });
533
+ record = updateRecordContext({ tabId: result._resolvedTabId || record.tabId });
534
+ }
535
+ if (record.capture.watch) {
536
+ const result = await requestCallExtension(request, tool, { type: "START_PLAYBOOK_WATCH", tabId: record.tabId || msg.tabId, includeInputValues: record.redaction.includeInputValues });
537
+ record = updateRecordContext({ tabId: result._resolvedTabId || record.tabId || msg.tabId });
538
+ }
539
+ return record;
540
+ } catch (error) {
541
+ if (record?.capture.network) await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
542
+ if (record?.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
543
+ discardRecord();
544
+ throw error;
545
+ }
546
+ }
547
+ if (tool === "playbook.record.status") return activeRecord() || { status: "idle" };
548
+ if (tool === "playbook.record.mark") return markRecord(args.label);
549
+ if (tool === "playbook.record.pause") return pauseRecord();
550
+ if (tool === "playbook.record.resume") return resumeRecord();
551
+ if (tool === "playbook.record.discard") {
552
+ const record = activeRecord();
553
+ if (record?.capture.network) await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
554
+ if (record?.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
555
+ return discardRecord();
556
+ }
557
+ if (tool === "playbook.record.stop") {
558
+ const record = activeRecord();
559
+ if (!record) throw new Error("no active playbook record");
560
+ if (record.capture.network) {
561
+ try {
562
+ const result = await requestCallExtension(request, tool, { type: "READ_NETWORK_REQUESTS", tabId: record.tabId, full: true, limit: 500 });
563
+ const cutoff = Date.parse(record.startedAt);
564
+ attachNetworkTrace(record.id, (result.entries || []).filter((entry) => entry.ts >= cutoff));
565
+ } finally {
566
+ await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
567
+ }
568
+ }
569
+ if (record.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
570
+ return stopRecord({ draft: args.draft === true });
571
+ }
572
+ throw new Error(`Unknown record command: ${tool}`);
573
+ }
574
+
575
+ const sessionManager = new HostSessionManager({
576
+ audit: auditSession,
577
+ onTimeout(context, request) {
578
+ pendingToolRequests.hardDeadline(request);
579
+ const response = {
580
+ type: "tool_response",
581
+ id: request.id,
582
+ error: { content: [{ type: "text", text: "Request timed out" }] },
583
+ };
584
+ cleanupRequestTransfers(request)
585
+ .then(() => {
586
+ sessionManager.complete(context, request.id, "hard-timeout");
587
+ if (!context.closed) return sendSocket(context.socket, response);
588
+ })
589
+ .catch((error) => log(`Error settling timed-out request: ${error.message}`));
590
+ },
591
+ });
592
+
593
+ async function discardRequestTransfers(message, state) {
594
+ if (!state) return;
595
+ const ids = [];
596
+ for (const entry of message?._surfTransfers?.uploads || []) if (typeof entry?.transferId === "string") ids.push(entry.transferId);
597
+ for (const entry of message?._surfTransfers?.downloads || []) if (typeof entry?.transferId === "string") ids.push(entry.transferId);
598
+ await Promise.all([...new Set(ids)].map((id) => state.discardCompleted(id)));
599
+ }
600
+
601
+ async function applyRequestTransfers(msg, request, transferState, getTransferState) {
602
+ if (!request.context?.isRemote) return;
603
+ const materialized = await materializeRemoteTool({
604
+ tool: request.tool,
605
+ args: msg.params?.args || {},
606
+ metadata: msg._surfTransfers,
607
+ pathRefs: msg._surfPaths || [],
608
+ transferState,
609
+ getTransferState,
610
+ });
611
+ request.transferState = materialized.transferState;
612
+ request.outputTransfers = materialized.outputTransfers;
613
+ request.pathRewrites = materialized.pathRewrites;
614
+ request.transferCleanup = materialized.transferCleanup;
615
+ msg.params = { ...msg.params, args: materialized.args };
616
+ }
617
+
618
+ async function cleanupRequestTransfers(request) {
619
+ if (!request || request.transferCleanupStarted) return request?.transferCleanupPromise;
620
+ request.transferCleanupStarted = true;
621
+ const paths = request.transferCleanup || [];
622
+ request.transferCleanup = [];
623
+ request.transferCleanupPromise = cleanupFilePaths(paths);
624
+ await request.transferCleanupPromise;
625
+ return request.transferCleanupPromise;
626
+ }
627
+
628
+ function completeOwnedRequest(context, id, outcome) {
629
+ const request = context?.activeRequest;
630
+ if (!request || request.id !== id) return Promise.resolve();
631
+ if (request.pendingEntries?.size && !request.hardBoundary) {
632
+ if (request.completionRequested) return request.completionPromise;
633
+ request.completionRequested = true;
634
+ request.completionOutcome = outcome;
635
+ request.completionPromise = new Promise((resolve) => {
636
+ pendingToolRequests.onDrain(request, () => {
637
+ sessionManager.complete(context, id, request.completionOutcome);
638
+ resolve();
639
+ });
640
+ });
641
+ return request.completionPromise;
316
642
  }
643
+ sessionManager.complete(context, id, outcome);
644
+ return Promise.resolve();
645
+ }
646
+
647
+ async function sendRequestDownloads(context, request, result) {
648
+ if (!request) return result;
649
+ let rewritten = result;
650
+ for (const output of request.outputTransfers || []) {
651
+ await streamFileDownload({
652
+ writer: { send: (frame) => sendSocket(context.socket, frame) },
653
+ state: request.transferState,
654
+ filePath: output.path,
655
+ transferId: output.transferId,
656
+ original: output.original,
657
+ });
658
+ }
659
+ rewritten = rewriteTransferPaths(rewritten, request.pathRewrites || []);
660
+ return rewritten;
661
+ }
662
+
663
+ function sendToolResponse(socket, id, result, error) {
664
+ const context = socketContexts.get(socket);
665
+ if (context && !sessionManager.canRespond(context, id)) return;
666
+ const request = context?.activeRequest;
667
+ let finalError = error;
668
+ (async () => {
669
+ let output = result;
670
+ try {
671
+ if (!error) output = await sendRequestDownloads(context, request, result);
672
+ } catch (transferFailure) {
673
+ finalError = transferFailure.message;
674
+ }
675
+ if (finalError && request) {
676
+ finalError = rewriteTransferPaths(finalError, request.pathRewrites || []);
677
+ }
678
+ if (request?.tool && !request.tool.startsWith("playbook.")) {
679
+ const metadata = commandMetadata(request.tool);
680
+ if (metadata.recordable) {
681
+ const event = {
682
+ type: finalError ? "tool.failed" : "tool.completed",
683
+ command: metadata.name,
684
+ argsRedacted: redactCommandArgs(request.tool, request.args || {}),
685
+ startedAt: request.activityStartedAt || new Date().toISOString(),
686
+ endedAt: new Date().toISOString(),
687
+ resultSummary: finalError ? "failed" : "success",
688
+ };
689
+ appendActivity(event);
690
+ appendRecordEvent(event);
691
+ }
692
+ }
693
+ await cleanupRequestTransfers(request);
694
+ if (request?.settled) return;
695
+ const outcome = request?.signal.aborted
696
+ ? (request.tombstoned ? "cleanup-settled" : "cancelled")
697
+ : finalError ? "error" : "completed";
698
+ await completeOwnedRequest(context, id, outcome);
699
+ const response = { type: "tool_response", id };
700
+ if (finalError) response.error = { content: [{ type: "text", text: finalError }] };
701
+ else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
702
+ if (!context?.closed) await sendSocket(socket, response);
703
+ })().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
704
+ }
705
+
706
+ function stopActiveStream(streamId, { notifyExtension = true } = {}) {
707
+ const stream = activeStreams.get(streamId);
708
+ if (!stream) return;
709
+ activeStreams.delete(streamId);
710
+ sessionManager.stopStream(socketContexts.get(stream.socket));
711
+ if (notifyExtension) writeMessage({ type: "STREAM_STOP", streamId });
317
712
  }
318
713
 
319
714
  function handleStreamRequest(msg, socket) {
320
715
  const { streamType, options, id: originalId } = msg;
321
716
  const tabId = msg.tabId;
322
717
  const streamId = ++requestCounter;
323
-
718
+
324
719
  activeStreams.set(streamId, {
325
720
  socket,
326
721
  originalId,
327
722
  streamType,
328
723
  });
329
-
724
+
330
725
  writeMessage({
331
726
  type: streamType,
332
727
  streamId,
333
728
  options: options || {},
334
729
  tabId,
335
730
  });
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
- }
731
+
732
+ sendSocket(socket, { type: "stream_started", streamId }, { stream: true }).catch((error) => {
733
+ log(`Error sending stream_started: ${error.message}`);
734
+ stopActiveStream(streamId);
735
+ socket.destroy(error);
736
+ });
342
737
  }
343
738
 
344
- function handleToolRequest(msg, socket) {
739
+ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore()) {
740
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
345
741
  const { method, params } = msg;
346
742
  const originalId = msg.id || null;
347
743
 
@@ -370,6 +766,22 @@ function handleToolRequest(msg, socket) {
370
766
  sendToolResponse(socket, originalId, null, "No tool specified");
371
767
  return;
372
768
  }
769
+
770
+ requestContext.args = args || {};
771
+ requestContext.activityStartedAt = new Date().toISOString();
772
+ if (!tool.startsWith("playbook.")) journalCommand(tool, args || {}, { tabId });
773
+ if (tool === "playbook.run") {
774
+ runHostPlaybook(msg, requestContext)
775
+ .then((result) => sendToolResponse(socket, originalId, { output: JSON.stringify(result) }, null))
776
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
777
+ return;
778
+ }
779
+ if (tool.startsWith("playbook.record.")) {
780
+ handleRecordRequest(tool, args || {}, msg, requestContext)
781
+ .then((result) => sendToolResponse(socket, originalId, result, null))
782
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
783
+ return;
784
+ }
373
785
 
374
786
  const extensionMsg = mapToolToMessage(tool, args, tabId);
375
787
  if (!extensionMsg) {
@@ -383,14 +795,14 @@ function handleToolRequest(msg, socket) {
383
795
  }
384
796
 
385
797
  if (extensionMsg.type === "LOCAL_WAIT") {
386
- setTimeout(() => {
387
- sendToolResponse(socket, originalId, { success: true }, null);
388
- }, extensionMsg.seconds * 1000);
798
+ require("./abort.cjs").abortableDelay(extensionMsg.seconds * 1000, requestContext.signal)
799
+ .then(() => sendToolResponse(socket, originalId, { success: true }, null))
800
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
389
801
  return;
390
802
  }
391
803
 
392
804
  if (extensionMsg.type === "BATCH_EXECUTE") {
393
- executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId);
805
+ executeBatch(extensionMsg.actions, extensionMsg.tabId, socket, originalId, requestContext);
394
806
  return;
395
807
  }
396
808
 
@@ -406,46 +818,23 @@ function handleToolRequest(msg, socket) {
406
818
  return;
407
819
  }
408
820
 
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
- }
821
+ requestCallExtension(
822
+ requestContext,
823
+ "read_page",
824
+ { type: "READ_PAGE", options: { filter: "interactive" }, tabId: extensionMsg.tabId },
825
+ 45000,
826
+ ).then(async (pageResult) => {
827
+ if (pageResult.error) throw new Error(`Failed to read page: ${pageResult.error}`);
828
+ const pageContent = pageResult.pageContent || "";
829
+ if (!pageContent) throw new Error("No page content available");
830
+ const gemini = getGeminiClient(apiKey);
831
+ const result = await gemini.analyze(extensionMsg.query, pageContent, { mode: extensionMsg.mode, signal: requestContext.signal });
832
+ return result.mode === "find"
833
+ ? { ref: result.content === "NOT_FOUND" ? null : result.content, mode: result.mode, aiResult: true }
834
+ : { content: result.content, mode: result.mode, aiResult: true };
835
+ }).then((result) => sendToolResponse(socket, originalId, result, null)).catch((err) => {
836
+ sendToolResponse(socket, originalId, null, err.message);
447
837
  });
448
- writeMessage({ type: "READ_PAGE", options: { filter: "interactive" }, tabId: extensionMsg.tabId, id: pageRequestId });
449
838
  return;
450
839
  }
451
840
 
@@ -455,16 +844,12 @@ function handleToolRequest(msg, socket) {
455
844
  queueAiRequest(async () => {
456
845
  let pageContext = null;
457
846
  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
- });
847
+ const pageResult = await requestCallExtension(
848
+ requestContext,
849
+ "read_page",
850
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
851
+ 45000,
852
+ );
468
853
  if (pageResult && !pageResult.error) {
469
854
  pageContext = {
470
855
  url: pageResult.url,
@@ -480,69 +865,36 @@ function handleToolRequest(msg, socket) {
480
865
 
481
866
  const result = await chatgptClient.query({
482
867
  prompt: fullPrompt,
868
+ signal: requestContext.signal,
483
869
  model,
484
870
  file,
485
871
  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
- }),
872
+ getCookies: () => requestCallExtension(
873
+ requestContext,
874
+ "get_cookies",
875
+ { type: "GET_CHATGPT_COOKIES" },
876
+ ),
877
+ createTab: () => requestCallExtension(
878
+ requestContext,
879
+ "create_tab",
880
+ { type: "CHATGPT_NEW_TAB" },
881
+ ),
882
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "CHATGPT_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
883
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
884
+ requestContext,
885
+ "cdp_evaluate",
886
+ { type: "CHATGPT_EVALUATE", tabId, expression },
887
+ ),
888
+ cdpCommand: (tabId, method, params) => requestCallExtension(
889
+ requestContext,
890
+ "cdp_command",
891
+ { type: "CHATGPT_CDP_COMMAND", tabId, method, params },
892
+ ),
893
+ uploadFile: (tabId, filePaths) => requestCallExtension(
894
+ requestContext,
895
+ "upload_file",
896
+ buildProviderUploadMessage("chatgpt", tabId, filePaths),
897
+ ),
546
898
  log: (msg) => log(`[chatgpt] ${msg}`)
547
899
  });
548
900
 
@@ -566,16 +918,12 @@ function handleToolRequest(msg, socket) {
566
918
  queueAiRequest(async () => {
567
919
  let pageContext = null;
568
920
  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
- });
921
+ const pageResult = await requestCallExtension(
922
+ requestContext,
923
+ "read_page",
924
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
925
+ 45000,
926
+ );
579
927
  if (pageResult && !pageResult.error) {
580
928
  pageContext = {
581
929
  url: pageResult.url,
@@ -591,49 +939,26 @@ function handleToolRequest(msg, socket) {
591
939
 
592
940
  const result = await perplexityClient.query({
593
941
  prompt: fullPrompt,
942
+ signal: requestContext.signal,
594
943
  mode: mode || 'search',
595
944
  model,
596
945
  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
- }),
946
+ createTab: () => requestCallExtension(
947
+ requestContext,
948
+ "create_tab",
949
+ { type: "PERPLEXITY_NEW_TAB" },
950
+ ),
951
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "PERPLEXITY_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
952
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
953
+ requestContext,
954
+ "cdp_evaluate",
955
+ { type: "PERPLEXITY_EVALUATE", tabId, expression },
956
+ ),
957
+ cdpCommand: (tabId, method, params) => requestCallExtension(
958
+ requestContext,
959
+ "cdp_command",
960
+ { type: "PERPLEXITY_CDP_COMMAND", tabId, method, params },
961
+ ),
637
962
  log: (msg) => log(`[perplexity] ${msg}`)
638
963
  });
639
964
 
@@ -661,16 +986,12 @@ function handleToolRequest(msg, socket) {
661
986
  // 1. Get page context if requested
662
987
  let pageContext = null;
663
988
  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
- });
989
+ const pageResult = await requestCallExtension(
990
+ requestContext,
991
+ "get_page_text",
992
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
993
+ 45000,
994
+ );
674
995
  if (pageResult && !pageResult.error) {
675
996
  pageContext = {
676
997
  url: pageResult.url,
@@ -688,7 +1009,8 @@ function handleToolRequest(msg, socket) {
688
1009
  // 3. Call Gemini client
689
1010
  const result = await geminiClient.query({
690
1011
  prompt: fullPrompt,
691
- model: model || "gemini-3-pro",
1012
+ signal: requestContext.signal,
1013
+ model: model || "gemini-3.1-pro",
692
1014
  file,
693
1015
  generateImage,
694
1016
  editImage,
@@ -696,67 +1018,32 @@ function handleToolRequest(msg, socket) {
696
1018
  youtube,
697
1019
  aspectRatio,
698
1020
  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
- }),
1021
+ getCookies: () => requestCallExtension(
1022
+ requestContext,
1023
+ "get_cookies",
1024
+ { type: "GET_GOOGLE_COOKIES" },
1025
+ ),
1026
+ createTab: () => requestCallExtension(
1027
+ requestContext,
1028
+ "create_tab",
1029
+ { type: "GEMINI_NEW_TAB" },
1030
+ ),
1031
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "GEMINI_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
1032
+ jsEval: (tabId, code) => requestCallExtension(
1033
+ requestContext,
1034
+ "js_eval",
1035
+ { type: "EXECUTE_JAVASCRIPT", tabId, code },
1036
+ ),
1037
+ uploadFile: (tabId, filePaths) => requestCallExtension(
1038
+ requestContext,
1039
+ "upload_file",
1040
+ buildProviderUploadMessage("gemini", tabId, filePaths),
1041
+ ),
1042
+ fetchUrl: (url) => requestCallExtension(
1043
+ requestContext,
1044
+ "fetch_url",
1045
+ { type: "GEMINI_FETCH_URL", url },
1046
+ ),
760
1047
  log: (msg) => log(`[gemini] ${msg}`)
761
1048
  });
762
1049
 
@@ -785,16 +1072,12 @@ function handleToolRequest(msg, socket) {
785
1072
  // 1. Get page context if requested
786
1073
  let pageContext = null;
787
1074
  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
- });
1075
+ const pageResult = await requestCallExtension(
1076
+ requestContext,
1077
+ "get_page_text",
1078
+ { type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId },
1079
+ 45000,
1080
+ );
798
1081
  if (pageResult && !pageResult.error) {
799
1082
  pageContext = {
800
1083
  url: pageResult.url,
@@ -812,59 +1095,31 @@ function handleToolRequest(msg, socket) {
812
1095
  // 3. Call Grok client
813
1096
  const result = await grokClient.query({
814
1097
  prompt: fullPrompt,
1098
+ signal: requestContext.signal,
815
1099
  model: model,
816
1100
  deepSearch: deepSearch || false,
817
1101
  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
- }),
1102
+ getCookies: () => requestCallExtension(
1103
+ requestContext,
1104
+ "get_cookies",
1105
+ { type: "GET_TWITTER_COOKIES" },
1106
+ ),
1107
+ createTab: () => requestCallExtension(
1108
+ requestContext,
1109
+ "create_tab",
1110
+ { type: "GROK_NEW_TAB" },
1111
+ ),
1112
+ closeTab: (tabIdToClose) => requestCallExtension(requestContext, "close_tab", { type: "GROK_CLOSE_TAB", tabId: tabIdToClose }, 45000, true),
1113
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
1114
+ requestContext,
1115
+ "cdp_evaluate",
1116
+ { type: "GROK_EVALUATE", tabId, expression },
1117
+ ),
1118
+ cdpCommand: (tabId, method, params) => requestCallExtension(
1119
+ requestContext,
1120
+ "cdp_command",
1121
+ { type: "GROK_CDP_COMMAND", tabId, method, params },
1122
+ ),
868
1123
  log: (msg) => log(`[grok] ${msg}`)
869
1124
  });
870
1125
 
@@ -903,46 +1158,29 @@ function handleToolRequest(msg, socket) {
903
1158
 
904
1159
  queueAiRequest(async () => {
905
1160
  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
- }),
1161
+ signal: requestContext.signal,
1162
+ getCookies: () => requestCallExtension(
1163
+ requestContext,
1164
+ "get_cookies",
1165
+ { type: "GET_TWITTER_COOKIES" },
1166
+ ),
1167
+ createTab: () => requestCallExtension(
1168
+ requestContext,
1169
+ "create_tab",
1170
+ { type: "GROK_NEW_TAB" },
1171
+ ),
1172
+ closeTab: (tabIdToClose) => requestCallExtension(
1173
+ requestContext,
1174
+ "close_tab",
1175
+ { type: "GROK_CLOSE_TAB", tabId: tabIdToClose },
1176
+ 45000,
1177
+ true,
1178
+ ),
1179
+ cdpEvaluate: (tabId, expression) => requestCallExtension(
1180
+ requestContext,
1181
+ "cdp_evaluate",
1182
+ { type: "GROK_EVALUATE", tabId, expression },
1183
+ ),
946
1184
  log: (msg) => log(`[grok:validate] ${msg}`)
947
1185
  });
948
1186
 
@@ -987,30 +1225,12 @@ function handleToolRequest(msg, socket) {
987
1225
  queueAiRequest(async () => {
988
1226
  const EXT_CALL_TIMEOUT_MS = 30000;
989
1227
 
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") {
1228
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1229
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
994
1230
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/prompts/new_chat")}`);
995
1231
  }
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
- });
1232
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1233
+ };
1014
1234
 
1015
1235
  // 1. Get page context if requested
1016
1236
  let pageContext = null;
@@ -1044,6 +1264,7 @@ function handleToolRequest(msg, socket) {
1044
1264
  // 3. Call AI Studio client
1045
1265
  const result = await aistudioClient.query({
1046
1266
  prompt: fullPrompt,
1267
+ signal: requestContext.signal,
1047
1268
  model: model || undefined,
1048
1269
  timeout: timeout || 300000,
1049
1270
  getCookies: () => callExtension("get_cookies", { type: "GET_GOOGLE_COOKIES" }, 45000),
@@ -1102,33 +1323,16 @@ function handleToolRequest(msg, socket) {
1102
1323
  queueAiRequest(async () => {
1103
1324
  const EXT_CALL_TIMEOUT_MS = 30000;
1104
1325
 
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") {
1326
+ const callExtension = (toolName, msg, timeoutMs = EXT_CALL_TIMEOUT_MS) => {
1327
+ if (msg?.type === "AISTUDIO_NEW_TAB") {
1109
1328
  log(`[aistudio] Opening tab: ${(msg.url || "https://aistudio.google.com/apps")}`);
1110
1329
  }
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
- });
1330
+ return requestCallExtension(requestContext, toolName, msg, timeoutMs);
1331
+ };
1129
1332
 
1130
1333
  const result = await aistudioBuild.build({
1131
1334
  prompt: query,
1335
+ signal: requestContext.signal,
1132
1336
  model: model || undefined,
1133
1337
  output,
1134
1338
  keepOpen,
@@ -1179,6 +1383,7 @@ function handleToolRequest(msg, socket) {
1179
1383
  let lastError = null;
1180
1384
 
1181
1385
  const sendNextKey = () => {
1386
+ if (requestContext.signal.aborted) return;
1182
1387
  if (completed >= repeat) {
1183
1388
  if (lastError) {
1184
1389
  sendToolResponse(socket, originalId, null, `Key repeat failed: ${lastError}`);
@@ -1187,18 +1392,15 @@ function handleToolRequest(msg, socket) {
1187
1392
  }
1188
1393
  return;
1189
1394
  }
1190
- const id = ++requestCounter;
1191
- pendingToolRequests.set(id, {
1192
- socket: null,
1193
- originalId: null,
1395
+ requestCallExtension(
1396
+ requestContext,
1194
1397
  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 });
1398
+ { type: "EXECUTE_KEY", key, tabId: tid },
1399
+ ).then((result) => {
1400
+ if (result.error) lastError = result.error;
1401
+ completed++;
1402
+ return require("./abort.cjs").abortableDelay(50, requestContext.signal);
1403
+ }).then(sendNextKey).catch((error) => sendToolResponse(socket, originalId, null, error.message));
1202
1404
  };
1203
1405
  sendNextKey();
1204
1406
  return;
@@ -1206,23 +1408,25 @@ function handleToolRequest(msg, socket) {
1206
1408
 
1207
1409
  if (extensionMsg.type === "NAMED_TAB_SWITCH" || extensionMsg.type === "NAMED_TAB_CLOSE") {
1208
1410
  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 });
1411
+ requestCallExtension(
1412
+ requestContext,
1413
+ "tabs_get_by_name",
1414
+ { type: "TABS_GET_BY_NAME", name },
1415
+ ).then((result) => {
1416
+ if (result.error || !result.tabId) {
1417
+ throw new Error(result.error || `No tab found with name "${name}"`);
1223
1418
  }
1224
- });
1225
- writeMessage({ type: "TABS_GET_BY_NAME", name, id: lookupId });
1419
+ const actionType = opType === "NAMED_TAB_SWITCH" ? "SWITCH_TAB" : "CLOSE_TAB";
1420
+ const actionTool = opType === "NAMED_TAB_SWITCH" ? "switch_tab" : "close_tab";
1421
+ return requestCallExtension(
1422
+ requestContext,
1423
+ actionTool,
1424
+ { type: actionType, tabId: result.tabId },
1425
+ 30000,
1426
+ actionTool === "close_tab",
1427
+ );
1428
+ }).then((result) => sendToolResponse(socket, originalId, result, result?.error || null))
1429
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1226
1430
  return;
1227
1431
  }
1228
1432
 
@@ -1232,7 +1436,13 @@ function handleToolRequest(msg, socket) {
1232
1436
  originalId,
1233
1437
  tool,
1234
1438
  savePath: extensionMsg.savePath || args?.savePath,
1235
- autoScreenshot: args?.autoScreenshot,
1439
+ autoScreenshot: args?.autoScreenshot === true,
1440
+ autoScreenshotOutput: args?.autoScreenshotOutput,
1441
+ networkExport: extensionMsg.type === "EXPORT_NETWORK_REQUESTS",
1442
+ persistNetwork: extensionMsg.type === "READ_NETWORK_REQUESTS" && extensionMsg.full && args?.["no-save"] !== true,
1443
+ networkExportPath: args?.output,
1444
+ networkPath: args?.["network-path"],
1445
+ networkExportFormat: extensionMsg.har ? "har" : extensionMsg.jsonl ? "jsonl" : "json",
1236
1446
  fullRes: extensionMsg.fullRes || args?.fullRes,
1237
1447
  maxSize: extensionMsg.maxSize || args?.maxSize,
1238
1448
  tabId: extensionMsg.tabId || tabId
@@ -1245,12 +1455,14 @@ function handleToolRequest(msg, socket) {
1245
1455
  writeMessage(finalMsg);
1246
1456
  }
1247
1457
 
1248
- function executeBatch(actions, tabId, socket, originalId) {
1458
+ function executeBatch(actions, tabId, socket, originalId, requestContext = requestStorage.getStore()) {
1459
+ const writeMessage = (message) => sendOwnedExtensionMessage(requestContext, message);
1249
1460
  const results = [];
1250
1461
  const DELAY_MS = 100;
1251
1462
  let currentIndex = 0;
1252
1463
 
1253
1464
  function executeNextAction() {
1465
+ if (requestContext.signal.aborted) return;
1254
1466
  if (currentIndex >= actions.length) {
1255
1467
  sendToolResponse(socket, originalId, {
1256
1468
  success: true,
@@ -1281,16 +1493,14 @@ function executeBatch(actions, tabId, socket, originalId) {
1281
1493
  if (extensionMsg.type === "LOCAL_WAIT") {
1282
1494
  results.push({ index: currentIndex, type: action.type, success: true });
1283
1495
  currentIndex++;
1284
- setTimeout(executeNextAction, extensionMsg.seconds * 1000);
1496
+ require("./abort.cjs").abortableDelay(extensionMsg.seconds * 1000, requestContext.signal)
1497
+ .then(executeNextAction)
1498
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1285
1499
  return;
1286
1500
  }
1287
1501
 
1288
- const id = ++requestCounter;
1289
- pendingToolRequests.set(id, {
1290
- socket: null,
1291
- originalId: null,
1292
- tool: toolName,
1293
- onComplete: (result) => {
1502
+ requestCallExtension(requestContext, toolName, extensionMsg, 30000)
1503
+ .then((result) => {
1294
1504
  if (result.error) {
1295
1505
  results.push({ index: currentIndex, type: action.type, success: false, error: result.error });
1296
1506
  sendToolResponse(socket, originalId, {
@@ -1302,15 +1512,12 @@ function executeBatch(actions, tabId, socket, originalId) {
1302
1512
  }, null);
1303
1513
  return;
1304
1514
  }
1305
-
1306
1515
  results.push({ index: currentIndex, type: action.type, success: true });
1307
1516
  currentIndex++;
1308
-
1309
- setTimeout(executeNextAction, DELAY_MS);
1310
- }
1311
- });
1312
-
1313
- writeMessage({ ...extensionMsg, id });
1517
+ return require("./abort.cjs").abortableDelay(DELAY_MS, requestContext.signal)
1518
+ .then(executeNextAction);
1519
+ })
1520
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1314
1521
  }
1315
1522
 
1316
1523
  executeNextAction();
@@ -1371,7 +1578,7 @@ function processInput() {
1371
1578
 
1372
1579
  try {
1373
1580
  const msg = JSON.parse(jsonStr);
1374
- log(`Received from extension: ${JSON.stringify(msg)}`);
1581
+ log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
1375
1582
 
1376
1583
  if (msg.type === "GET_AUTH") {
1377
1584
  log("Handling GET_AUTH from extension");
@@ -1401,28 +1608,41 @@ function processInput() {
1401
1608
  handleApiRequest(msg, writeMessage);
1402
1609
  return;
1403
1610
  }
1611
+
1612
+ if (msg.type === "PLAYBOOK_WATCH_EVENT") {
1613
+ appendRecordEvent({
1614
+ type: "browser.event",
1615
+ event: msg.event,
1616
+ selector: msg.selector,
1617
+ value: msg.value,
1618
+ url: redactUrlSecrets(msg.url),
1619
+ tabId: msg.tabId,
1620
+ timestamp: msg.timestamp || new Date().toISOString(),
1621
+ });
1622
+ return;
1623
+ }
1404
1624
 
1405
1625
  if (msg.type === "STREAM_EVENT") {
1406
1626
  const stream = activeStreams.get(msg.streamId);
1407
1627
  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
- }
1628
+ sendSocket(stream.socket, msg.event, { stream: true }).catch((error) => {
1629
+ log(`Error forwarding stream event: ${error.message}`);
1630
+ stopActiveStream(msg.streamId);
1631
+ stream.socket.destroy(error);
1632
+ });
1415
1633
  }
1416
1634
  return;
1417
1635
  }
1418
-
1636
+
1419
1637
  if (msg.type === "STREAM_ERROR") {
1420
1638
  const stream = activeStreams.get(msg.streamId);
1421
1639
  if (stream) {
1422
- try {
1423
- stream.socket.write(JSON.stringify({ error: msg.error }) + "\n");
1424
- } catch (e) {}
1425
- activeStreams.delete(msg.streamId);
1640
+ sendSocket(stream.socket, { error: msg.error }, { stream: true })
1641
+ .catch((error) => {
1642
+ log(`Error forwarding stream error: ${error.message}`);
1643
+ stream.socket.destroy(error);
1644
+ })
1645
+ .finally(() => stopActiveStream(msg.streamId));
1426
1646
  }
1427
1647
  return;
1428
1648
  }
@@ -1430,19 +1650,49 @@ function processInput() {
1430
1650
 
1431
1651
  if (msg.id && pendingToolRequests.has(msg.id)) {
1432
1652
  const pending = pendingToolRequests.get(msg.id);
1653
+ if (pending.request?.signal.aborted || pending.request?.tombstoned) {
1654
+ const request = pending.request;
1655
+ const topLevelResponse = !pending.resolve && !pending.onComplete;
1656
+ pendingToolRequests.resolve(msg.id, msg);
1657
+ if (topLevelResponse && request?.context) {
1658
+ completeOwnedRequest(request.context, request.id, "cleanup-settled");
1659
+ }
1660
+ return;
1661
+ }
1662
+ if (pending.resolve || pending.onComplete) {
1663
+ pendingToolRequests.resolve(msg.id, msg);
1664
+ return;
1665
+ }
1433
1666
  pendingToolRequests.delete(msg.id);
1434
-
1435
- if (pending.onComplete) {
1436
- pending.onComplete(msg);
1437
- } else {
1667
+ {
1668
+
1438
1669
  const { socket, originalId, savePath, autoScreenshot, tabId: storedTabId } = pending;
1439
1670
  const tabId = storedTabId || msg._resolvedTabId;
1671
+ const failAutoScreenshot = (message) => pending.autoScreenshotOutput
1672
+ ? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
1673
+ : sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: message }, null);
1440
1674
 
1441
- if (savePath && msg.base64) {
1675
+ if (pending.networkExport && Array.isArray(msg.entries)) {
1676
+ try {
1677
+ const exportResult = writeNetworkExport(pending.networkExportPath, msg.entries, pending.networkExportFormat);
1678
+ sendToolResponse(socket, originalId, exportResult, null);
1679
+ } catch (error) {
1680
+ sendToolResponse(socket, originalId, null, `Failed to export network requests: ${error.message}`);
1681
+ }
1682
+ } else if (pending.persistNetwork && Array.isArray(msg.entries)) {
1683
+ try {
1684
+ for (const entry of msg.entries) networkStore.appendEntrySync(entry, pending.networkPath);
1685
+ networkStore.maybeAutoCleanup();
1686
+ sendToolResponse(socket, originalId, msg, null);
1687
+ } catch (error) {
1688
+ sendToolResponse(socket, originalId, null, `Failed to persist network requests: ${error.message}`);
1689
+ }
1690
+ } else if (savePath && msg.base64) {
1442
1691
  try {
1443
1692
  const dir = path.dirname(savePath);
1444
1693
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1445
- fs.writeFileSync(savePath, Buffer.from(msg.base64, "base64"));
1694
+ fs.writeFileSync(savePath, Buffer.from(msg.base64, "base64"), { mode: 0o600 });
1695
+ try { fs.chmodSync(savePath, 0o600); } catch {}
1446
1696
  const origWidth = msg.width || 0;
1447
1697
  const origHeight = msg.height || 0;
1448
1698
  const maxSize = pending.maxSize || 1200;
@@ -1465,8 +1715,7 @@ function processInput() {
1465
1715
  }
1466
1716
  } else if (autoScreenshot && tabId && !msg.error && !msg.base64) {
1467
1717
 
1468
- const screenshotId = ++requestCounter;
1469
- const screenshotPath = path.join(SURF_TMP, `pi-auto-${Date.now()}.png`);
1718
+ const screenshotPath = pending.autoScreenshotOutput || path.join(SURF_TMP, `pi-auto-${Date.now()}.png`);
1470
1719
 
1471
1720
  const autoFiles = fs.readdirSync(SURF_TMP)
1472
1721
  .filter(f => f.startsWith("pi-auto-") && f.endsWith(".png"))
@@ -1477,14 +1726,17 @@ function processInput() {
1477
1726
  try { fs.unlinkSync(path.join(SURF_TMP, f.name)); } catch (e) {}
1478
1727
  });
1479
1728
  }
1480
- pendingToolRequests.set(screenshotId, {
1481
- socket: null,
1482
- originalId: null,
1483
- tool: "screenshot",
1484
- onComplete: (screenshotMsg) => {
1729
+ require("./abort.cjs").abortableDelay(500, pending.request?.signal)
1730
+ .then(() => requestCallExtension(
1731
+ pending.request,
1732
+ "screenshot",
1733
+ { type: "EXECUTE_SCREENSHOT", tabId },
1734
+ ))
1735
+ .then((screenshotMsg) => {
1485
1736
  if (screenshotMsg.base64) {
1486
1737
  try {
1487
- fs.writeFileSync(screenshotPath, Buffer.from(screenshotMsg.base64, "base64"));
1738
+ fs.writeFileSync(screenshotPath, Buffer.from(screenshotMsg.base64, "base64"), { mode: 0o600 });
1739
+ try { fs.chmodSync(screenshotPath, 0o600); } catch {}
1488
1740
  const origW = screenshotMsg.width || 0;
1489
1741
  const origH = screenshotMsg.height || 0;
1490
1742
  let finalW = origW, finalH = origH;
@@ -1501,16 +1753,17 @@ function processInput() {
1501
1753
  autoScreenshot: { path: screenshotPath, width: finalW, height: finalH, originalWidth: origW, originalHeight: origH }
1502
1754
  }, null);
1503
1755
  } catch (e) {
1504
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: e.message }, null);
1756
+ failAutoScreenshot(e.message);
1505
1757
  }
1506
1758
  } else {
1507
1759
  const errMsg = screenshotMsg.error || "Failed to capture";
1508
- sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: errMsg }, null);
1760
+ failAutoScreenshot(errMsg);
1509
1761
  }
1510
- }
1511
- });
1512
- setTimeout(() => writeMessage({ type: "EXECUTE_SCREENSHOT", tabId, id: screenshotId }), 500);
1762
+ })
1763
+ .catch((error) => failAutoScreenshot(error.message));
1513
1764
  return;
1765
+ } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
1766
+ failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
1514
1767
  } else if (msg.results && msg.savePath) {
1515
1768
  try {
1516
1769
  const dir = msg.savePath;
@@ -1544,11 +1797,7 @@ function processInput() {
1544
1797
  }
1545
1798
  } else if (msg.id && pendingRequests.has(msg.id)) {
1546
1799
  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
- }
1800
+ sendSocket(socket, msg).catch((error) => log(`Error writing to CLI socket: ${error.message}`));
1552
1801
  pendingRequests.delete(msg.id);
1553
1802
  }
1554
1803
  } catch (e) {
@@ -1571,17 +1820,12 @@ const connectedSockets = new Set();
1571
1820
  process.stdin.on("end", () => {
1572
1821
  log("stdin ended (extension disconnected), notifying clients");
1573
1822
  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
- }
1823
+ sendSocket(socket, {
1824
+ type: "extension_disconnected",
1825
+ message: "Surf extension was reloaded. Restart your command."
1826
+ }).finally(() => socket.end()).catch(() => socket.end());
1583
1827
  }
1584
- process.exit(0);
1828
+ shutdown(0);
1585
1829
  });
1586
1830
 
1587
1831
  process.stdin.on("error", (err) => {
@@ -1592,142 +1836,276 @@ process.stdout.on("error", (err) => {
1592
1836
  log(`stdout error: ${err.message}`);
1593
1837
  });
1594
1838
 
1595
- const server = net.createServer((socket) => {
1839
+ const handleClient = (socket) => {
1840
+ const isRemote = Boolean(socket.remoteAddress && socket.remotePort);
1841
+ let transferState;
1842
+ let transferReady;
1843
+ let transferCleanupPromise;
1844
+ const cleanupTransfers = () => {
1845
+ if (!transferCleanupPromise) {
1846
+ transferCleanupPromise = transferReady
1847
+ ? transferReady.then((state) => state?.cleanup())
1848
+ : Promise.resolve();
1849
+ }
1850
+ return transferCleanupPromise;
1851
+ };
1852
+ socket.transferCleanup = cleanupTransfers;
1853
+ const ensureTransferState = () => {
1854
+ if (context?.closed || socket.destroyed) throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
1855
+ if (!transferReady) {
1856
+ transferCleanupPromise = undefined;
1857
+ transferReady = createStagingDirectory(SURF_TMP)
1858
+ .then(async (directory) => {
1859
+ if (context?.closed || socket.destroyed) {
1860
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(() => {});
1861
+ throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
1862
+ }
1863
+ try {
1864
+ return createTransferState({ directory, writer: { send: (frame) => sendSocket(socket, frame) }, onActivity: () => sessionManager.touch(socketContexts.get(socket)) });
1865
+ } catch (error) {
1866
+ return fs.promises.rm(directory, { recursive: true, force: true }).catch(() => {}).then(() => { throw error; });
1867
+ }
1868
+ })
1869
+ .catch((error) => { transferReady = undefined; throw error; });
1870
+ }
1871
+ return transferReady;
1872
+ };
1873
+ let context;
1874
+ try {
1875
+ context = sessionManager.admit(socket, isRemote);
1876
+ } catch (error) {
1877
+ sendSocket(socket, { error: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
1878
+ return;
1879
+ }
1880
+ const writer = createSocketWriter(socket, {
1881
+ maxPendingBytes: 4 * 1024 * 1024,
1882
+ onOverflow: ({ stream, error }) => {
1883
+ auditSession({ event: stream ? "stream" : "writer", context, outcome: "overflow", request: context.activeRequest });
1884
+ sessionManager.stopStream(context);
1885
+ socket.destroy(error);
1886
+ },
1887
+ });
1888
+ socketContexts.set(socket, context);
1889
+ socketWriters.set(socket, writer);
1596
1890
  log("CLI client connected");
1597
1891
  connectedSockets.add(socket);
1598
1892
  socket.on("close", () => connectedSockets.delete(socket));
1599
1893
 
1600
- let dataBuffer = "";
1894
+ const stateDir = getStateDir();
1895
+ let principal = null;
1896
+ const authSession = isRemote ? createServerAuthSession({
1897
+ socket,
1898
+ stateDir,
1899
+ send: (value) => sendSocket(socket, value),
1900
+ async onAuthenticated(authenticatedPrincipal) {
1901
+ sessionManager.authenticate(context, authenticatedPrincipal);
1902
+ principal = authenticatedPrincipal;
1903
+ log(`Remote client authenticated: ${authenticatedPrincipal.label} (${authenticatedPrincipal.clientId})`);
1904
+ },
1905
+ onError(error) {
1906
+ log(`Remote authentication rejected: ${error.message}`);
1907
+ sendSocket(socket, { type: "auth_error", message: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
1908
+ },
1909
+ }) : null;
1910
+ let messageChain = Promise.resolve();
1911
+ const processMessage = async (msg) => {
1912
+ if (context.closed) return;
1913
+ if (isRemote && !authSession.authenticated) {
1914
+ await authSession.handle(msg);
1915
+ return;
1916
+ }
1917
+ if (isRemote) {
1918
+ let authorized = false;
1919
+ try {
1920
+ authorized = Boolean(principal && isClientAuthorized(stateDir, principal.clientId));
1921
+ } catch (error) {
1922
+ log(`Remote authorization registry check failed: ${error.message}`);
1923
+ }
1924
+ if (!authorized) {
1925
+ await sendSocket(socket, { error: "remote client authorization is unavailable or revoked" }).catch(() => {});
1926
+ socket.destroy();
1927
+ return;
1928
+ }
1929
+ }
1601
1930
 
1602
- socket.on("data", (data) => {
1603
- dataBuffer += data.toString();
1604
- const lines = dataBuffer.split("\n");
1605
- dataBuffer = lines.pop() || "";
1931
+ if (isRemote && msg.type && msg.type.startsWith("transfer_")) {
1932
+ transferState ||= await ensureTransferState();
1933
+ await transferState.handle(msg);
1934
+ return;
1935
+ }
1606
1936
 
1607
- for (const line of lines) {
1608
- if (!line.trim()) continue;
1937
+ if (msg.type === "tool_request") {
1938
+ const tool = msg.params?.tool || "unknown";
1939
+ let request;
1609
1940
  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;
1941
+ const deadlineMs = TEST_REQUEST_DEADLINE_MS || resolveRequestDeadlineMs(tool, msg.params?.args);
1942
+ request = await sessionManager.beginRequest(context, { id: msg.id, tool, deadlineMs });
1943
+ request.context = context;
1944
+ } catch (error) {
1945
+ if (transferState) await discardRequestTransfers(msg, transferState);
1946
+ await sendSocket(socket, { type: "tool_response", id: msg.id || null, error: { content: [{ type: "text", text: error.message }] } }).catch(() => {});
1947
+ return;
1948
+ }
1949
+ log(`Handling tool_request: ${msg.method} ${tool}${principal ? ` for ${principal.label}` : ""}`);
1950
+ try {
1951
+ if (isRemote) {
1952
+ await applyRequestTransfers(msg, request, transferState, ensureTransferState);
1665
1953
  }
1666
-
1667
- const id = ++requestCounter;
1668
- log(`Forwarding to extension: id=${id} type=${msg.type}`);
1669
- pendingRequests.set(id, { socket });
1670
- writeMessage({ ...msg, id });
1954
+ throwIfAborted(request.signal, "Request cancelled");
1955
+ requestStorage.run(request, () => handleToolRequest(msg, socket, request));
1671
1956
  } catch (e) {
1672
- log(`Error parsing CLI request: ${e.message}`);
1673
- socket.write(JSON.stringify({ error: "Invalid request" }) + "\n");
1957
+ await discardRequestTransfers(msg, transferState);
1958
+ sendToolResponse(socket, msg.id || null, null, e.message || "Request failed");
1674
1959
  }
1960
+ return;
1675
1961
  }
1962
+
1963
+ if (msg.type === "stream_request") {
1964
+ if (msg.streamType !== "STREAM_CONSOLE" && msg.streamType !== "STREAM_NETWORK") {
1965
+ log(`Rejecting unsupported stream type: ${msg.streamType}`);
1966
+ await sendSocket(socket, { error: `Unsupported stream type: ${msg.streamType}` }).catch(() => {});
1967
+ return;
1968
+ }
1969
+ if (!sessionManager.canStartStream(context)) {
1970
+ await sendSocket(socket, { error: "stream limit reached or connection is not stream-only" }).catch(() => {});
1971
+ socket.destroy();
1972
+ return;
1973
+ }
1974
+ log(`Handling stream_request: ${msg.streamType}`);
1975
+ handleStreamRequest(msg, socket);
1976
+ return;
1977
+ }
1978
+
1979
+ if (msg.type === "stream_stop") {
1980
+ log("Handling stream_stop");
1981
+ for (const [streamId, stream] of activeStreams.entries()) {
1982
+ if (stream.socket === socket) stopActiveStream(streamId);
1983
+ }
1984
+ return;
1985
+ }
1986
+
1987
+ log(`Rejecting unsupported socket request type: ${msg.type}`);
1988
+ await sendSocket(socket, { error: `Unsupported request type: ${msg.type}` }).catch(() => {});
1989
+ };
1990
+
1991
+ const parser = createFrameParser({
1992
+ onFrame(msg) {
1993
+ messageChain = messageChain.then(() => processMessage(msg)).catch((error) => {
1994
+ log(`Error handling CLI request: ${error.message}`);
1995
+ if (isRemote && msg.type && msg.type.startsWith("transfer_")) {
1996
+ sendSocket(socket, { type: "transfer_error", version: 1, transferId: msg.transferId, error: error.message || "Transfer failed" })
1997
+ .finally(() => socket.destroy()).catch(() => socket.destroy());
1998
+ } else {
1999
+ sendSocket(socket, { error: error.message || "Request failed" }).catch(() => {});
2000
+ }
2001
+ });
2002
+ },
2003
+ onError(error) {
2004
+ log(`CLI frame rejected: ${error.message}`);
2005
+ if (isRemote && !authSession.authenticated) {
2006
+ sendSocket(socket, { type: "auth_error", message: error.message }).finally(() => socket.destroy()).catch(() => socket.destroy());
2007
+ } else {
2008
+ socket.destroy();
2009
+ }
2010
+ },
2011
+ maxFrameBytes: MAX_CLIENT_FRAME_BYTES,
1676
2012
  });
1677
2013
 
2014
+ socket.on("data", (data) => parser.push(data));
2015
+
1678
2016
  socket.on("error", (err) => {
1679
2017
  log(`CLI socket error: ${err.message}`);
1680
2018
  });
1681
2019
 
1682
2020
  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
- }
2021
+ parser.close();
2022
+ authSession?.close();
2023
+ const activeRequest = context.activeRequest;
2024
+ let cleanupPendingId;
2025
+ if (activeRequest && !activeRequest.queued) {
2026
+ cleanupPendingId = `transfer-cleanup-${++requestCounter}`;
2027
+ pendingToolRequests.set(cleanupPendingId, {
2028
+ request: activeRequest,
2029
+ cleanup: true,
2030
+ tool: "transfer_cleanup",
2031
+ resolve: () => {},
2032
+ reject: () => {},
2033
+ });
2034
+ completeOwnedRequest(context, activeRequest.id, "cleanup-settled");
1693
2035
  }
2036
+ const cleanupPromise = cleanupTransfers();
2037
+ cleanupPromise.finally(() => {
2038
+ if (cleanupPendingId) pendingToolRequests.delete(cleanupPendingId);
2039
+ }).catch(() => {});
2040
+ writer.close();
2041
+ sessionManager.close(context);
2042
+ if (activeRequest) pendingToolRequests.tombstoneAfterAbort(activeRequest);
2043
+ log("CLI client disconnected");
1694
2044
  for (const [streamId, stream] of activeStreams.entries()) {
1695
- if (stream.socket === socket) {
1696
- writeMessage({ type: "STREAM_STOP", streamId });
1697
- activeStreams.delete(streamId);
1698
- }
2045
+ if (stream.socket === socket) stopActiveStream(streamId);
1699
2046
  }
1700
2047
  });
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
- });
2048
+ };
1709
2049
 
1710
- server.on("error", (err) => {
1711
- log(`Server error: ${err.message}`);
1712
- });
2050
+ let listenerLifecycle = null;
2051
+ let shuttingDown = false;
2052
+ let exitCode = 0;
2053
+ let exitScheduled = false;
2054
+ function scheduleExit() {
2055
+ if (exitScheduled) return;
2056
+ exitScheduled = true;
2057
+ setTimeout(() => process.exit(exitCode), 50);
2058
+ }
2059
+ function shutdown(code = 0) {
2060
+ exitCode = Math.max(exitCode, code);
2061
+ if (shuttingDown) return;
2062
+ shuttingDown = true;
2063
+ const cleanupPromises = [...connectedSockets].map((socket) => socket.transferCleanup?.() || Promise.resolve());
2064
+ for (const socket of connectedSockets) socket.destroy();
2065
+ pendingRequests.clear(); pendingToolRequests.clear(); activeStreams.clear();
2066
+ Promise.allSettled([...cleanupPromises, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
2067
+ }
2068
+ function failStartup(error, endpoint) {
2069
+ log(`Listener startup failed (${endpoint}): ${error.message}`);
2070
+ shutdown(1);
2071
+ }
2072
+ async function startListeners() {
2073
+ let endpoint;
2074
+ try {
2075
+ endpoint = process.env.SURF_LISTEN ? parseListenEndpoint(process.env.SURF_LISTEN) : null;
2076
+ listenerLifecycle = createListenerLifecycle({
2077
+ localPath: SOCKET_PATH,
2078
+ tcpEndpoint: endpoint && { host: endpoint.host, port: endpoint.port },
2079
+ handler: handleClient,
2080
+ onReady: () => {
2081
+ if (endpoint) log(`TCP listener listening on ${endpoint.display}`);
2082
+ writeMessage({ type: "HOST_READY" });
2083
+ log("Sent HOST_READY to extension");
2084
+ },
2085
+ onFatal: (error) => failStartup(error, endpoint?.display || process.env.SURF_LISTEN || SOCKET_PATH),
2086
+ });
2087
+ if (shuttingDown) await listenerLifecycle.shutdown();
2088
+ await listenerLifecycle.start();
2089
+ } catch (error) { failStartup(error, endpoint?.display || process.env.SURF_LISTEN || SOCKET_PATH); }
2090
+ }
2091
+ startListeners();
1713
2092
 
1714
2093
  process.on("SIGTERM", () => {
1715
2094
  log("SIGTERM received");
1716
- server.close();
1717
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1718
- process.exit(0);
2095
+ shutdown();
1719
2096
  });
1720
2097
 
1721
2098
  process.on("SIGINT", () => {
1722
2099
  log("SIGINT received");
1723
- server.close();
1724
- if (!IS_WIN) { try { fs.unlinkSync(SOCKET_PATH); } catch {} }
1725
- process.exit(0);
2100
+ shutdown();
1726
2101
  });
1727
2102
 
1728
2103
  process.on("uncaughtException", (err) => {
1729
2104
  log(`Uncaught exception: ${err.message}\n${err.stack}`);
1730
- process.exit(1);
2105
+ shutdown(1);
1731
2106
  });
1732
2107
 
1733
2108
  log("Host initialization complete, waiting for connections...");
2109
+ } else {
2110
+ module.exports = { createListenerLifecycle, MAX_CLIENT_FRAME_BYTES };
2111
+ }