surf-cli 2.17.0 → 2.19.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.
package/native/host.cjs CHANGED
@@ -20,6 +20,7 @@ const { createOracleHost } = require("./oracle-host.cjs");
20
20
 
21
21
  const IS_WIN = process.platform === "win32";
22
22
  const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
23
+ const { takeFrames } = require("./stdin-frames.cjs");
23
24
  const { parseListenEndpoint } = require("./listener.cjs");
24
25
  const { getStateDir } = require("./remote-auth.cjs");
25
26
  const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
@@ -49,9 +50,17 @@ const { resolveArgs, runPlaybookOp } = require("./playbook-runtime.cjs");
49
50
  const { resolveOp } = require("./playbooks.cjs");
50
51
  const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
51
52
  const { BrowserScheduler } = require("./browser-scheduler.cjs");
52
- const { BrowserSessionStore, validateSessionName } = require("./browser-session-store.cjs");
53
+ const { BrowserSessionStore, parseDurationMs, validateSessionName } = require("./browser-session-store.cjs");
54
+ const { applySocketPermissions, resolveSocketPermissions } = require("./socket-permissions.cjs");
53
55
  const { classifyTool } = require("./tool-scope.cjs");
54
56
  const { fromExtensionError, surfError } = require("./surf-error.cjs");
57
+ const {
58
+ DEFAULT_VIDEO_FPS,
59
+ VideoRecorder,
60
+ VideoRecorderError,
61
+ parseVideoFps,
62
+ validateVideoOutputPath,
63
+ } = require("./video-recorder.cjs");
55
64
  const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
56
65
  const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
57
66
  ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
@@ -61,7 +70,15 @@ if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
61
70
  // The endpoint passed here is already validated by the caller. Keeping this
62
71
  // lifecycle separate lets tests use an ephemeral loopback port without adding
63
72
  // a localhost escape hatch to SURF_LISTEN parsing.
64
- function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onFatal }) {
73
+ function createListenerLifecycle({
74
+ localPath,
75
+ tcpEndpoint,
76
+ handler,
77
+ onReady,
78
+ onFatal,
79
+ socketMode = process.env.SURF_SOCKET_MODE,
80
+ socketGroup = process.env.SURF_SOCKET_GROUP,
81
+ }) {
65
82
  const localServer = net.createServer(handler);
66
83
  const tcpServer = tcpEndpoint ? net.createServer(handler) : null;
67
84
  let shuttingDown = false;
@@ -85,9 +102,10 @@ function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onF
85
102
  if (startPromise) return startPromise;
86
103
  startPromise = (async () => {
87
104
  try {
105
+ const socketPermissions = IS_WIN ? null : resolveSocketPermissions(socketMode, socketGroup);
88
106
  await listen(localServer, localPath);
89
107
  if (shuttingDown) return false;
90
- if (!IS_WIN) { try { fs.chmodSync(localPath, 0o600); } catch {} }
108
+ if (!IS_WIN) applySocketPermissions(localPath, socketPermissions);
91
109
  if (tcpServer) {
92
110
  await listen(tcpServer, tcpEndpoint);
93
111
  if (shuttingDown) return false;
@@ -382,6 +400,11 @@ const pendingToolRequests = new RequestPendingMap({ getRequest: () => requestSto
382
400
  const activeStreams = new Map();
383
401
  const socketContexts = new WeakMap();
384
402
  const socketWriters = new WeakMap();
403
+ let activeVideoRecorder = null;
404
+ let videoStopPromise = null;
405
+ let videoStopFailure = false;
406
+ let lastVideoError = null;
407
+ let lastVideoResult = null;
385
408
  let requestCounter = 0;
386
409
  const browserSessionStore = new BrowserSessionStore();
387
410
  let browserIdentity = null;
@@ -394,7 +417,12 @@ function setBrowserIdentity(value) {
394
417
  browserIdentity.browserInstanceId !== value.browserInstanceId ||
395
418
  browserIdentity.browserEpoch !== value.browserEpoch
396
419
  );
397
- if (identityChanged) transientFrameContexts.clear();
420
+ if (identityChanged) {
421
+ transientFrameContexts.clear();
422
+ if (activeVideoRecorder) {
423
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_extension_reloaded", "Surf extension identity changed while video recording was active"));
424
+ }
425
+ }
398
426
  browserIdentity = {
399
427
  browserInstanceId: value.browserInstanceId,
400
428
  browserEpoch: value.browserEpoch,
@@ -429,6 +457,9 @@ function handleTargetEvent(message) {
429
457
  if (message.event === "tab-removed" && Number.isInteger(message.tabId)) {
430
458
  clearTransientFrameContextsByTab(message.tabId);
431
459
  browserSessionStore.invalidateByTab(browserIdentity, message.tabId, "tab_gone");
460
+ if (activeVideoRecorder?.tabId === message.tabId) {
461
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_tab_gone", `Recorded tab ${message.tabId} was closed`));
462
+ }
432
463
  for (const [streamId, stream] of activeStreams) {
433
464
  if (stream.tabId === message.tabId) stopActiveStream(streamId, { notifyExtension: false });
434
465
  }
@@ -437,6 +468,9 @@ function handleTargetEvent(message) {
437
468
  if (message.event === "window-removed" && Number.isInteger(message.windowId)) {
438
469
  clearTransientFrameContextsByWindow(message.windowId);
439
470
  browserSessionStore.invalidateByWindow(browserIdentity, message.windowId, "window_gone");
471
+ if (activeVideoRecorder?.windowId === message.windowId) {
472
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_window_gone", `Recorded window ${message.windowId} was closed`));
473
+ }
440
474
  for (const [streamId, stream] of activeStreams) {
441
475
  if (stream.windowId === message.windowId) stopActiveStream(streamId, { notifyExtension: false });
442
476
  }
@@ -550,6 +584,196 @@ async function requestExtensionOrThrow(request, tool, message, timeoutMs = 30000
550
584
  return result;
551
585
  }
552
586
 
587
+ function videoOptions(args = {}) {
588
+ const fps = parseVideoFps(args.fps, DEFAULT_VIDEO_FPS);
589
+ const output = validateVideoOutputPath(args.output, { createParent: false });
590
+ return { fps, output };
591
+ }
592
+
593
+ function videoStatus() {
594
+ if (activeVideoRecorder) return activeVideoRecorder.recorder.status();
595
+ return {
596
+ status: "idle",
597
+ ...(lastVideoError
598
+ ? { error: lastVideoError.message, errorCode: lastVideoError.code }
599
+ : {}),
600
+ ...(lastVideoResult ? { lastResult: lastVideoResult } : {}),
601
+ };
602
+ }
603
+
604
+ function rememberVideoFailure(error) {
605
+ const normalized = error instanceof VideoRecorderError
606
+ ? error
607
+ : new VideoRecorderError(
608
+ typeof error?.code === "string" ? error.code : "video_failed",
609
+ error?.message || String(error),
610
+ );
611
+ lastVideoError = normalized;
612
+ lastVideoResult = null;
613
+ return normalized;
614
+ }
615
+
616
+ async function settleVideoFailure(entry, error) {
617
+ if (!entry || activeVideoRecorder !== entry) return;
618
+ rememberVideoFailure(error);
619
+ if (videoStopPromise) return videoStopPromise;
620
+
621
+ videoStopFailure = true;
622
+ const promise = (async () => {
623
+ if (entry.extensionStarted) {
624
+ try {
625
+ writeMessage({ type: "VIDEO_STOP", recorderId: entry.recorderId, tabId: entry.tabId });
626
+ } catch {}
627
+ }
628
+ await entry.recorder.dispose();
629
+ })();
630
+ let settled;
631
+ settled = promise.finally(() => {
632
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
633
+ if (videoStopPromise === settled) {
634
+ videoStopPromise = null;
635
+ videoStopFailure = false;
636
+ }
637
+ });
638
+ videoStopPromise = settled;
639
+ await settled.catch(() => {});
640
+ }
641
+
642
+ async function startVideoRecording(args, msg, request) {
643
+ const { fps, output } = videoOptions(args);
644
+ if (activeVideoRecorder || videoStopPromise) {
645
+ throw new VideoRecorderError("video_active", "A video recording is already active for this native host");
646
+ }
647
+ const tabId = request.target?.tabId || msg.tabId;
648
+ if (!tabId) throw new VideoRecorderError("video_target_required", "video start requires a selected tab");
649
+
650
+ const recorderId = `video_${Date.now()}_${++requestCounter}`;
651
+ let entry;
652
+ const recorder = new VideoRecorder({
653
+ output,
654
+ fps,
655
+ tabId,
656
+ recorderId,
657
+ onFailure: (error, failedRecorder) => {
658
+ if (entry?.recorder === failedRecorder) void settleVideoFailure(entry, error);
659
+ },
660
+ });
661
+ entry = {
662
+ recorder,
663
+ recorderId,
664
+ tabId,
665
+ windowId: request.target?.windowId || msg.windowId,
666
+ extensionStarted: false,
667
+ };
668
+ activeVideoRecorder = entry;
669
+
670
+ try {
671
+ await recorder.start();
672
+ await requestExtensionOrThrow(request, "video.start", {
673
+ type: "VIDEO_START",
674
+ tabId,
675
+ recorderId,
676
+ fps,
677
+ quality: 80,
678
+ everyNthFrame: 1,
679
+ strictTarget: request.target?.strict === true,
680
+ });
681
+ entry.extensionStarted = true;
682
+ if (recorder.state === "failed" || activeVideoRecorder !== entry) {
683
+ throw recorder.failure || new VideoRecorderError("video_failed", "Video recorder failed while starting");
684
+ }
685
+ return { ...recorder.status(), status: "active" };
686
+ } catch (error) {
687
+ rememberVideoFailure(error);
688
+ if (entry.extensionStarted) {
689
+ try { writeMessage({ type: "VIDEO_STOP", recorderId, tabId }); } catch {}
690
+ }
691
+ await recorder.dispose().catch(() => {});
692
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
693
+ if (videoStopPromise) {
694
+ await videoStopPromise.catch(() => {});
695
+ videoStopPromise = null;
696
+ videoStopFailure = false;
697
+ }
698
+ throw error;
699
+ }
700
+ }
701
+
702
+ async function stopVideoRecording(request) {
703
+ if (videoStopPromise) {
704
+ const pending = videoStopPromise;
705
+ const failed = videoStopFailure;
706
+ const result = await pending;
707
+ if (failed) throw lastVideoError || new VideoRecorderError("video_failed", "Video recording failed");
708
+ return result;
709
+ }
710
+ const entry = activeVideoRecorder;
711
+ if (!entry) throw new VideoRecorderError("video_not_active", "No active video recording");
712
+
713
+ videoStopFailure = false;
714
+ const promise = (async () => {
715
+ let extensionError = null;
716
+ if (entry.extensionStarted) {
717
+ try {
718
+ await requestExtensionOrThrow(request, "video.stop", {
719
+ type: "VIDEO_STOP",
720
+ recorderId: entry.recorderId,
721
+ tabId: entry.tabId,
722
+ }, 30000, true);
723
+ } catch (error) {
724
+ // The tab may disappear between the stop request and its response. The
725
+ // native encoder can still finalize the file, so preserve that result.
726
+ extensionError = error;
727
+ }
728
+ }
729
+
730
+ let result;
731
+ try {
732
+ result = await entry.recorder.stop();
733
+ } catch (error) {
734
+ rememberVideoFailure(error);
735
+ throw error;
736
+ }
737
+ lastVideoError = null;
738
+ lastVideoResult = { ...result, status: "stopped" };
739
+ // A detached/gone page should not turn an otherwise finalized local file
740
+ // into a failed stop. Keep the extension detail available as a warning.
741
+ if (extensionError) lastVideoResult.warning = extensionError.message;
742
+ return lastVideoResult;
743
+ })();
744
+ let settled;
745
+ settled = promise.finally(() => {
746
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
747
+ if (videoStopPromise === settled) {
748
+ videoStopPromise = null;
749
+ videoStopFailure = false;
750
+ }
751
+ });
752
+ videoStopPromise = settled;
753
+ return settled;
754
+ }
755
+
756
+ async function handleVideoRequest(tool, args, msg, request) {
757
+ if (tool === "video.start") return startVideoRecording(args, msg, request);
758
+ if (tool === "video.stop") return stopVideoRecording(request);
759
+ if (tool === "video.status") return videoStatus();
760
+ if (tool === "video.restart") {
761
+ const options = videoOptions(args);
762
+ const existing = activeVideoRecorder;
763
+ if (!existing) throw new VideoRecorderError("video_not_active", "No active video recording to restart");
764
+ const stopped = await stopVideoRecording(request);
765
+ const result = await startVideoRecording({ output: options.output, fps: options.fps }, {
766
+ ...msg,
767
+ tabId: existing.tabId,
768
+ }, {
769
+ ...request,
770
+ target: { ...(request.target || {}), tabId: existing.tabId, strict: true },
771
+ });
772
+ return { ...result, previous: stopped.path };
773
+ }
774
+ throw new VideoRecorderError("video_command_unknown", `Unknown video command: ${tool}`);
775
+ }
776
+
553
777
  function positiveId(value, name) {
554
778
  if (value === undefined || value === null || value === "") return undefined;
555
779
  const parsed = Number(value);
@@ -756,11 +980,13 @@ async function resolveSessionTarget(request, identity, name) {
756
980
  recoveryCommand: `surf session.rebind ${record.name} --tab-id ${record.tabId} --replace`,
757
981
  });
758
982
  }
983
+ const accessedAt = new Date().toISOString();
759
984
  const updated = browserSessionStore.replace(identity, record.name, {
760
985
  ...record,
761
986
  lastUrl: inspected.url || record.lastUrl,
762
987
  lastTitle: inspected.title || record.lastTitle,
763
- lastValidatedAt: new Date().toISOString(),
988
+ lastAccessedAt: accessedAt,
989
+ lastValidatedAt: accessedAt,
764
990
  });
765
991
  return {
766
992
  source: "session",
@@ -847,6 +1073,7 @@ async function resolveRequestTarget(msg, request, classification) {
847
1073
 
848
1074
  async function prepareToolRequest(msg, request) {
849
1075
  const args = msg.params?.args || {};
1076
+ if (request.tool === "video.start" || request.tool === "video.restart") videoOptions(args);
850
1077
  const classification = classifyTool(request.tool, args);
851
1078
  request.scope = classification.scope;
852
1079
  request.classification = classification;
@@ -891,6 +1118,7 @@ async function sessionRecordStatus(identity, request, record, refresh = false) {
891
1118
  ...record,
892
1119
  lastUrl: inspected.url || record.lastUrl,
893
1120
  lastTitle: inspected.title || record.lastTitle,
1121
+ lastAccessedAt: record.lastAccessedAt || record.updatedAt || record.createdAt,
894
1122
  lastValidatedAt: new Date().toISOString(),
895
1123
  });
896
1124
  }
@@ -910,6 +1138,158 @@ async function sessionRecordStatus(identity, request, record, refresh = false) {
910
1138
  };
911
1139
  }
912
1140
 
1141
+ function sessionActivity(record) {
1142
+ const value = record.lastAccessedAt || record.updatedAt || record.createdAt;
1143
+ if (typeof value !== "string" || !value) return null;
1144
+ const timestamp = Date.parse(value);
1145
+ return Number.isFinite(timestamp) ? { value, timestamp } : null;
1146
+ }
1147
+
1148
+ function cleanupEntry(record, { reason, targetAction, idleMs, lastAccessedAt, ...details }) {
1149
+ return {
1150
+ name: record.name,
1151
+ tabId: record.tabId,
1152
+ windowId: record.windowId,
1153
+ ownership: record.ownership || "adopted",
1154
+ reason,
1155
+ targetAction,
1156
+ targetClosed: targetAction === "close",
1157
+ ...(lastAccessedAt ? { lastAccessedAt } : {}),
1158
+ ...(idleMs !== undefined ? { idleMs } : {}),
1159
+ ...details,
1160
+ };
1161
+ }
1162
+
1163
+ async function cleanupBrowserSessions(identity, request, args) {
1164
+ const rawIdleAfter = args["idle-after"];
1165
+ const idleAfterMs = parseDurationMs(rawIdleAfter);
1166
+ const dryRun = args["dry-run"] === true;
1167
+ const now = Date.now();
1168
+ const records = browserSessionStore.list(identity);
1169
+ const operations = [];
1170
+ const retained = [];
1171
+ let inspected = 0;
1172
+
1173
+ for (const record of records) {
1174
+ const activity = sessionActivity(record);
1175
+ const common = {
1176
+ lastAccessedAt: activity?.value || record.lastAccessedAt || record.updatedAt || record.createdAt,
1177
+ };
1178
+
1179
+ if (record.browserEpoch !== identity.browserEpoch) {
1180
+ operations.push({
1181
+ record,
1182
+ closeTarget: false,
1183
+ entry: cleanupEntry(record, { ...common, reason: "epoch-stale", targetAction: "already-gone" }),
1184
+ });
1185
+ continue;
1186
+ }
1187
+
1188
+ if (record.invalidReason === "tab_gone" || record.invalidReason === "window_gone") {
1189
+ operations.push({
1190
+ record,
1191
+ closeTarget: false,
1192
+ entry: cleanupEntry(record, { ...common, reason: "target-gone", targetAction: "already-gone" }),
1193
+ });
1194
+ continue;
1195
+ }
1196
+
1197
+ if (record.invalidReason) {
1198
+ retained.push(cleanupEntry(record, { ...common, reason: "invalid", targetAction: "kept" }));
1199
+ continue;
1200
+ }
1201
+
1202
+ let inspectedTarget;
1203
+ try {
1204
+ inspectedTarget = await inspectBrowserTab(request, record.tabId);
1205
+ inspected += 1;
1206
+ } catch (error) {
1207
+ if (error?.code === "tab_gone") {
1208
+ operations.push({
1209
+ record,
1210
+ closeTarget: false,
1211
+ entry: cleanupEntry(record, { ...common, reason: "target-gone", targetAction: "already-gone" }),
1212
+ });
1213
+ continue;
1214
+ }
1215
+ throw error;
1216
+ }
1217
+
1218
+ if (record.windowId && inspectedTarget.windowId !== record.windowId) {
1219
+ retained.push(cleanupEntry(record, {
1220
+ ...common,
1221
+ reason: "binding-mismatch",
1222
+ targetAction: "kept",
1223
+ currentWindowId: inspectedTarget.windowId,
1224
+ }));
1225
+ continue;
1226
+ }
1227
+
1228
+ if (inspectedTarget.active !== false) {
1229
+ retained.push(cleanupEntry(record, { ...common, reason: "active", targetAction: "kept" }));
1230
+ continue;
1231
+ }
1232
+
1233
+ if (!activity || now <= activity.timestamp) {
1234
+ retained.push(cleanupEntry(record, { ...common, reason: "activity-unknown", targetAction: "kept" }));
1235
+ continue;
1236
+ }
1237
+
1238
+ const idleMs = now - activity.timestamp;
1239
+ if (idleMs <= idleAfterMs) {
1240
+ retained.push(cleanupEntry(record, { ...common, reason: "not-idle", targetAction: "kept", idleMs }));
1241
+ continue;
1242
+ }
1243
+
1244
+ const closeTarget = record.ownership === "surf-created";
1245
+ operations.push({
1246
+ record,
1247
+ closeTarget,
1248
+ entry: cleanupEntry(record, {
1249
+ ...common,
1250
+ reason: "idle",
1251
+ targetAction: closeTarget ? "close" : "keep",
1252
+ idleMs,
1253
+ }),
1254
+ });
1255
+ }
1256
+
1257
+ if (!dryRun) {
1258
+ for (const operation of operations) {
1259
+ if (operation.closeTarget) {
1260
+ try {
1261
+ const result = await requestExtensionOrThrow(request, "session.cleanup", {
1262
+ type: "SESSION_CLOSE_TARGET",
1263
+ tabId: operation.record.tabId,
1264
+ }, 30000, true);
1265
+ if (result?.alreadyGone === true) {
1266
+ operation.entry.targetAction = "already-gone";
1267
+ operation.entry.targetClosed = false;
1268
+ operation.entry.reason = "target-gone";
1269
+ }
1270
+ } catch (error) {
1271
+ if (error?.code !== "tab_gone") throw error;
1272
+ operation.entry.targetAction = "already-gone";
1273
+ operation.entry.targetClosed = false;
1274
+ operation.entry.reason = "target-gone";
1275
+ }
1276
+ }
1277
+ browserSessionStore.remove(identity, operation.record.name);
1278
+ }
1279
+ }
1280
+
1281
+ return {
1282
+ success: true,
1283
+ dryRun,
1284
+ idleAfter: String(rawIdleAfter).trim(),
1285
+ idleAfterMs,
1286
+ scanned: records.length,
1287
+ inspected,
1288
+ removed: operations.map(({ entry }) => entry),
1289
+ retained,
1290
+ };
1291
+ }
1292
+
913
1293
  async function createSessionBinding(request, identity, name, args, previous = null) {
914
1294
  const mode = args.tab === true ? "tab" : args.window === true ? "window" : previous?.mode || "window";
915
1295
  if (args.tab === true && args.window === true) {
@@ -931,6 +1311,7 @@ async function createSessionBinding(request, identity, name, args, previous = nu
931
1311
  browserEpoch: identity.browserEpoch,
932
1312
  mode,
933
1313
  ownership: "surf-created",
1314
+ lastAccessedAt: new Date().toISOString(),
934
1315
  lastUrl: created.url || url,
935
1316
  lastTitle: created.title,
936
1317
  groupId: created.groupId,
@@ -953,7 +1334,7 @@ async function createSessionBinding(request, identity, name, args, previous = nu
953
1334
  async function handleBrowserSessionCommand(tool, args, request) {
954
1335
  const identity = await requireBrowserIdentity();
955
1336
  const name = args.name;
956
- if (tool !== "session.list") validateSessionName(name);
1337
+ if (tool !== "session.list" && tool !== "session.cleanup") validateSessionName(name);
957
1338
 
958
1339
  if (tool === "session.new") {
959
1340
  if (browserSessionStore.get(identity, name)) {
@@ -992,6 +1373,10 @@ async function handleBrowserSessionCommand(tool, args, request) {
992
1373
  return { sessions, browser: identity, scheduler: browserScheduler.stats() };
993
1374
  }
994
1375
 
1376
+ if (tool === "session.cleanup") {
1377
+ return cleanupBrowserSessions(identity, request, args);
1378
+ }
1379
+
995
1380
  const existing = browserSessionStore.get(identity, name);
996
1381
  if (!existing) {
997
1382
  throw surfError("session_unknown", `Unknown session: ${name}`, {
@@ -1047,6 +1432,7 @@ async function handleBrowserSessionCommand(tool, args, request) {
1047
1432
  browserEpoch: identity.browserEpoch,
1048
1433
  mode: "tab",
1049
1434
  ownership: "adopted",
1435
+ lastAccessedAt: new Date().toISOString(),
1050
1436
  lastUrl: inspected.url,
1051
1437
  lastTitle: inspected.title,
1052
1438
  frameContext: null,
@@ -1380,7 +1766,12 @@ function sendToolResponse(socket, id, result, error) {
1380
1766
  }
1381
1767
  if (request?.notice) response.notice = request.notice;
1382
1768
  if (formattedError) response.error = formattedError;
1383
- else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1769
+ else {
1770
+ response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1771
+ if (request?.tool === "tab.new" && Number.isInteger(output?.tabId) && output.tabId > 0) {
1772
+ response.result.tabId = output.tabId;
1773
+ }
1774
+ }
1384
1775
  if (!context?.closed) await sendSocket(socket, response);
1385
1776
  })().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
1386
1777
  }
@@ -1500,6 +1891,12 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1500
1891
  .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1501
1892
  return;
1502
1893
  }
1894
+ if (tool.startsWith("video.")) {
1895
+ handleVideoRequest(tool, args || {}, msg, requestContext)
1896
+ .then((result) => sendToolResponse(socket, originalId, result, null))
1897
+ .catch((error) => sendToolResponse(socket, originalId, null, error));
1898
+ return;
1899
+ }
1503
1900
 
1504
1901
  const extensionMsg = mapToolToMessage(tool, args, tabId);
1505
1902
  if (!extensionMsg) {
@@ -2414,25 +2811,23 @@ function writeMessage(msg) {
2414
2811
  let inputBuffer = Buffer.alloc(0);
2415
2812
 
2416
2813
  function processInput() {
2417
- while (inputBuffer.length >= 4) {
2418
- const msgLen = inputBuffer.readUInt32LE(0);
2419
- if (inputBuffer.length < 4 + msgLen) break;
2420
-
2421
- const jsonStr = inputBuffer.slice(4, 4 + msgLen).toString("utf8");
2422
- inputBuffer = inputBuffer.slice(4 + msgLen);
2423
-
2814
+ // Take every complete frame out of the buffer before dispatching: one
2815
+ // chunk routinely carries a TARGET_EVENT and the reply to a tool request.
2816
+ const { frames, rest } = takeFrames(inputBuffer);
2817
+ inputBuffer = rest;
2818
+ for (const jsonStr of frames) {
2424
2819
  try {
2425
2820
  const msg = JSON.parse(jsonStr);
2426
2821
  log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
2427
2822
 
2428
2823
  if (msg.type === "EXTENSION_HELLO") {
2429
2824
  setBrowserIdentity(msg);
2430
- return;
2825
+ continue;
2431
2826
  }
2432
2827
 
2433
2828
  if (msg.type === "TARGET_EVENT") {
2434
2829
  handleTargetEvent(msg);
2435
- return;
2830
+ continue;
2436
2831
  }
2437
2832
 
2438
2833
  if (msg.type === "GET_AUTH") {
@@ -2456,12 +2851,12 @@ function processInput() {
2456
2851
  hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
2457
2852
  });
2458
2853
  }
2459
- return;
2854
+ continue;
2460
2855
  }
2461
2856
 
2462
2857
  if (msg.type === "API_REQUEST") {
2463
2858
  handleApiRequest(msg, writeMessage);
2464
- return;
2859
+ continue;
2465
2860
  }
2466
2861
 
2467
2862
  if (msg.type === "PLAYBOOK_WATCH_EVENT") {
@@ -2474,7 +2869,24 @@ function processInput() {
2474
2869
  tabId: msg.tabId,
2475
2870
  timestamp: msg.timestamp || new Date().toISOString(),
2476
2871
  });
2477
- return;
2872
+ continue;
2873
+ }
2874
+
2875
+ if (msg.type === "VIDEO_FRAME") {
2876
+ if (activeVideoRecorder && msg.recorderId === activeVideoRecorder.recorderId && msg.tabId === activeVideoRecorder.tabId) {
2877
+ activeVideoRecorder.recorder.addFrame(msg.data, Number.isFinite(msg.receivedAt) ? msg.receivedAt : Date.now());
2878
+ }
2879
+ continue;
2880
+ }
2881
+
2882
+ if (msg.type === "VIDEO_ERROR") {
2883
+ if (activeVideoRecorder && (!msg.recorderId || msg.recorderId === activeVideoRecorder.recorderId)) {
2884
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError(
2885
+ typeof msg.errorCode === "string" ? msg.errorCode : "video_extension_error",
2886
+ msg.error || "Video screencast failed",
2887
+ ));
2888
+ }
2889
+ continue;
2478
2890
  }
2479
2891
 
2480
2892
  if (msg.type === "STREAM_EVENT") {
@@ -2486,7 +2898,7 @@ function processInput() {
2486
2898
  stream.socket.destroy(error);
2487
2899
  });
2488
2900
  }
2489
- return;
2901
+ continue;
2490
2902
  }
2491
2903
 
2492
2904
  if (msg.type === "STREAM_ERROR") {
@@ -2499,7 +2911,7 @@ function processInput() {
2499
2911
  })
2500
2912
  .finally(() => stopActiveStream(msg.streamId));
2501
2913
  }
2502
- return;
2914
+ continue;
2503
2915
  }
2504
2916
 
2505
2917
 
@@ -2512,13 +2924,13 @@ function processInput() {
2512
2924
  if (topLevelResponse && request?.context) {
2513
2925
  completeOwnedRequest(request.context, request.id, "cleanup-settled");
2514
2926
  }
2515
- return;
2927
+ continue;
2516
2928
  }
2517
2929
  handleFrameContextFailure(pending.request, msg);
2518
2930
  updateFrameContextFromResult(pending.request, pending.tool, msg);
2519
2931
  if (pending.resolve || pending.onComplete) {
2520
2932
  pendingToolRequests.resolve(msg.id, msg);
2521
- return;
2933
+ continue;
2522
2934
  }
2523
2935
  pendingToolRequests.delete(msg.id);
2524
2936
  {
@@ -2527,7 +2939,11 @@ function processInput() {
2527
2939
  const tabId = storedTabId || msg._resolvedTabId;
2528
2940
  const failAutoScreenshot = (message) => pending.autoScreenshotOutput
2529
2941
  ? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
2530
- : sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: message }, null);
2942
+ : sendToolResponse(socket, originalId, {
2943
+ ...msg,
2944
+ screenshotError: message,
2945
+ autoScreenshotError: message,
2946
+ }, null);
2531
2947
 
2532
2948
  if (pending.networkExport && Array.isArray(msg.entries)) {
2533
2949
  try {
@@ -2618,7 +3034,7 @@ function processInput() {
2618
3034
  }
2619
3035
  })
2620
3036
  .catch((error) => failAutoScreenshot(error.message));
2621
- return;
3037
+ continue;
2622
3038
  } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
2623
3039
  failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
2624
3040
  } else if (msg.results && msg.savePath) {
@@ -2936,9 +3352,18 @@ function shutdown(code = 0) {
2936
3352
  if (shuttingDown) return;
2937
3353
  shuttingDown = true;
2938
3354
  const cleanupPromises = [...connectedSockets].map((socket) => socket.transferCleanup?.() || Promise.resolve());
3355
+ const videoEntry = activeVideoRecorder;
3356
+ const videoCleanup = videoStopPromise || (videoEntry
3357
+ ? (async () => {
3358
+ if (videoEntry.extensionStarted) {
3359
+ try { writeMessage({ type: "VIDEO_STOP", recorderId: videoEntry.recorderId, tabId: videoEntry.tabId }); } catch {}
3360
+ }
3361
+ await videoEntry.recorder.dispose().catch(() => {});
3362
+ })()
3363
+ : Promise.resolve());
2939
3364
  for (const socket of connectedSockets) socket.destroy();
2940
3365
  pendingRequests.clear(); pendingToolRequests.clear(); activeStreams.clear();
2941
- Promise.allSettled([...cleanupPromises, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
3366
+ Promise.allSettled([...cleanupPromises, videoCleanup, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
2942
3367
  }
2943
3368
  function failStartup(error, endpoint) {
2944
3369
  log(`Listener startup failed (${endpoint}): ${error.message}`);