surf-cli 2.17.0 → 2.18.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
@@ -49,9 +49,17 @@ const { resolveArgs, runPlaybookOp } = require("./playbook-runtime.cjs");
49
49
  const { resolveOp } = require("./playbooks.cjs");
50
50
  const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
51
51
  const { BrowserScheduler } = require("./browser-scheduler.cjs");
52
- const { BrowserSessionStore, validateSessionName } = require("./browser-session-store.cjs");
52
+ const { BrowserSessionStore, parseDurationMs, validateSessionName } = require("./browser-session-store.cjs");
53
+ const { applySocketPermissions, resolveSocketPermissions } = require("./socket-permissions.cjs");
53
54
  const { classifyTool } = require("./tool-scope.cjs");
54
55
  const { fromExtensionError, surfError } = require("./surf-error.cjs");
56
+ const {
57
+ DEFAULT_VIDEO_FPS,
58
+ VideoRecorder,
59
+ VideoRecorderError,
60
+ parseVideoFps,
61
+ validateVideoOutputPath,
62
+ } = require("./video-recorder.cjs");
55
63
  const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
56
64
  const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
57
65
  ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
@@ -61,7 +69,15 @@ if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
61
69
  // The endpoint passed here is already validated by the caller. Keeping this
62
70
  // lifecycle separate lets tests use an ephemeral loopback port without adding
63
71
  // a localhost escape hatch to SURF_LISTEN parsing.
64
- function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onFatal }) {
72
+ function createListenerLifecycle({
73
+ localPath,
74
+ tcpEndpoint,
75
+ handler,
76
+ onReady,
77
+ onFatal,
78
+ socketMode = process.env.SURF_SOCKET_MODE,
79
+ socketGroup = process.env.SURF_SOCKET_GROUP,
80
+ }) {
65
81
  const localServer = net.createServer(handler);
66
82
  const tcpServer = tcpEndpoint ? net.createServer(handler) : null;
67
83
  let shuttingDown = false;
@@ -85,9 +101,10 @@ function createListenerLifecycle({ localPath, tcpEndpoint, handler, onReady, onF
85
101
  if (startPromise) return startPromise;
86
102
  startPromise = (async () => {
87
103
  try {
104
+ const socketPermissions = IS_WIN ? null : resolveSocketPermissions(socketMode, socketGroup);
88
105
  await listen(localServer, localPath);
89
106
  if (shuttingDown) return false;
90
- if (!IS_WIN) { try { fs.chmodSync(localPath, 0o600); } catch {} }
107
+ if (!IS_WIN) applySocketPermissions(localPath, socketPermissions);
91
108
  if (tcpServer) {
92
109
  await listen(tcpServer, tcpEndpoint);
93
110
  if (shuttingDown) return false;
@@ -382,6 +399,11 @@ const pendingToolRequests = new RequestPendingMap({ getRequest: () => requestSto
382
399
  const activeStreams = new Map();
383
400
  const socketContexts = new WeakMap();
384
401
  const socketWriters = new WeakMap();
402
+ let activeVideoRecorder = null;
403
+ let videoStopPromise = null;
404
+ let videoStopFailure = false;
405
+ let lastVideoError = null;
406
+ let lastVideoResult = null;
385
407
  let requestCounter = 0;
386
408
  const browserSessionStore = new BrowserSessionStore();
387
409
  let browserIdentity = null;
@@ -394,7 +416,12 @@ function setBrowserIdentity(value) {
394
416
  browserIdentity.browserInstanceId !== value.browserInstanceId ||
395
417
  browserIdentity.browserEpoch !== value.browserEpoch
396
418
  );
397
- if (identityChanged) transientFrameContexts.clear();
419
+ if (identityChanged) {
420
+ transientFrameContexts.clear();
421
+ if (activeVideoRecorder) {
422
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_extension_reloaded", "Surf extension identity changed while video recording was active"));
423
+ }
424
+ }
398
425
  browserIdentity = {
399
426
  browserInstanceId: value.browserInstanceId,
400
427
  browserEpoch: value.browserEpoch,
@@ -429,6 +456,9 @@ function handleTargetEvent(message) {
429
456
  if (message.event === "tab-removed" && Number.isInteger(message.tabId)) {
430
457
  clearTransientFrameContextsByTab(message.tabId);
431
458
  browserSessionStore.invalidateByTab(browserIdentity, message.tabId, "tab_gone");
459
+ if (activeVideoRecorder?.tabId === message.tabId) {
460
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_tab_gone", `Recorded tab ${message.tabId} was closed`));
461
+ }
432
462
  for (const [streamId, stream] of activeStreams) {
433
463
  if (stream.tabId === message.tabId) stopActiveStream(streamId, { notifyExtension: false });
434
464
  }
@@ -437,6 +467,9 @@ function handleTargetEvent(message) {
437
467
  if (message.event === "window-removed" && Number.isInteger(message.windowId)) {
438
468
  clearTransientFrameContextsByWindow(message.windowId);
439
469
  browserSessionStore.invalidateByWindow(browserIdentity, message.windowId, "window_gone");
470
+ if (activeVideoRecorder?.windowId === message.windowId) {
471
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError("video_window_gone", `Recorded window ${message.windowId} was closed`));
472
+ }
440
473
  for (const [streamId, stream] of activeStreams) {
441
474
  if (stream.windowId === message.windowId) stopActiveStream(streamId, { notifyExtension: false });
442
475
  }
@@ -550,6 +583,196 @@ async function requestExtensionOrThrow(request, tool, message, timeoutMs = 30000
550
583
  return result;
551
584
  }
552
585
 
586
+ function videoOptions(args = {}) {
587
+ const fps = parseVideoFps(args.fps, DEFAULT_VIDEO_FPS);
588
+ const output = validateVideoOutputPath(args.output, { createParent: false });
589
+ return { fps, output };
590
+ }
591
+
592
+ function videoStatus() {
593
+ if (activeVideoRecorder) return activeVideoRecorder.recorder.status();
594
+ return {
595
+ status: "idle",
596
+ ...(lastVideoError
597
+ ? { error: lastVideoError.message, errorCode: lastVideoError.code }
598
+ : {}),
599
+ ...(lastVideoResult ? { lastResult: lastVideoResult } : {}),
600
+ };
601
+ }
602
+
603
+ function rememberVideoFailure(error) {
604
+ const normalized = error instanceof VideoRecorderError
605
+ ? error
606
+ : new VideoRecorderError(
607
+ typeof error?.code === "string" ? error.code : "video_failed",
608
+ error?.message || String(error),
609
+ );
610
+ lastVideoError = normalized;
611
+ lastVideoResult = null;
612
+ return normalized;
613
+ }
614
+
615
+ async function settleVideoFailure(entry, error) {
616
+ if (!entry || activeVideoRecorder !== entry) return;
617
+ rememberVideoFailure(error);
618
+ if (videoStopPromise) return videoStopPromise;
619
+
620
+ videoStopFailure = true;
621
+ const promise = (async () => {
622
+ if (entry.extensionStarted) {
623
+ try {
624
+ writeMessage({ type: "VIDEO_STOP", recorderId: entry.recorderId, tabId: entry.tabId });
625
+ } catch {}
626
+ }
627
+ await entry.recorder.dispose();
628
+ })();
629
+ let settled;
630
+ settled = promise.finally(() => {
631
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
632
+ if (videoStopPromise === settled) {
633
+ videoStopPromise = null;
634
+ videoStopFailure = false;
635
+ }
636
+ });
637
+ videoStopPromise = settled;
638
+ await settled.catch(() => {});
639
+ }
640
+
641
+ async function startVideoRecording(args, msg, request) {
642
+ const { fps, output } = videoOptions(args);
643
+ if (activeVideoRecorder || videoStopPromise) {
644
+ throw new VideoRecorderError("video_active", "A video recording is already active for this native host");
645
+ }
646
+ const tabId = request.target?.tabId || msg.tabId;
647
+ if (!tabId) throw new VideoRecorderError("video_target_required", "video start requires a selected tab");
648
+
649
+ const recorderId = `video_${Date.now()}_${++requestCounter}`;
650
+ let entry;
651
+ const recorder = new VideoRecorder({
652
+ output,
653
+ fps,
654
+ tabId,
655
+ recorderId,
656
+ onFailure: (error, failedRecorder) => {
657
+ if (entry?.recorder === failedRecorder) void settleVideoFailure(entry, error);
658
+ },
659
+ });
660
+ entry = {
661
+ recorder,
662
+ recorderId,
663
+ tabId,
664
+ windowId: request.target?.windowId || msg.windowId,
665
+ extensionStarted: false,
666
+ };
667
+ activeVideoRecorder = entry;
668
+
669
+ try {
670
+ await recorder.start();
671
+ await requestExtensionOrThrow(request, "video.start", {
672
+ type: "VIDEO_START",
673
+ tabId,
674
+ recorderId,
675
+ fps,
676
+ quality: 80,
677
+ everyNthFrame: 1,
678
+ strictTarget: request.target?.strict === true,
679
+ });
680
+ entry.extensionStarted = true;
681
+ if (recorder.state === "failed" || activeVideoRecorder !== entry) {
682
+ throw recorder.failure || new VideoRecorderError("video_failed", "Video recorder failed while starting");
683
+ }
684
+ return { ...recorder.status(), status: "active" };
685
+ } catch (error) {
686
+ rememberVideoFailure(error);
687
+ if (entry.extensionStarted) {
688
+ try { writeMessage({ type: "VIDEO_STOP", recorderId, tabId }); } catch {}
689
+ }
690
+ await recorder.dispose().catch(() => {});
691
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
692
+ if (videoStopPromise) {
693
+ await videoStopPromise.catch(() => {});
694
+ videoStopPromise = null;
695
+ videoStopFailure = false;
696
+ }
697
+ throw error;
698
+ }
699
+ }
700
+
701
+ async function stopVideoRecording(request) {
702
+ if (videoStopPromise) {
703
+ const pending = videoStopPromise;
704
+ const failed = videoStopFailure;
705
+ const result = await pending;
706
+ if (failed) throw lastVideoError || new VideoRecorderError("video_failed", "Video recording failed");
707
+ return result;
708
+ }
709
+ const entry = activeVideoRecorder;
710
+ if (!entry) throw new VideoRecorderError("video_not_active", "No active video recording");
711
+
712
+ videoStopFailure = false;
713
+ const promise = (async () => {
714
+ let extensionError = null;
715
+ if (entry.extensionStarted) {
716
+ try {
717
+ await requestExtensionOrThrow(request, "video.stop", {
718
+ type: "VIDEO_STOP",
719
+ recorderId: entry.recorderId,
720
+ tabId: entry.tabId,
721
+ }, 30000, true);
722
+ } catch (error) {
723
+ // The tab may disappear between the stop request and its response. The
724
+ // native encoder can still finalize the file, so preserve that result.
725
+ extensionError = error;
726
+ }
727
+ }
728
+
729
+ let result;
730
+ try {
731
+ result = await entry.recorder.stop();
732
+ } catch (error) {
733
+ rememberVideoFailure(error);
734
+ throw error;
735
+ }
736
+ lastVideoError = null;
737
+ lastVideoResult = { ...result, status: "stopped" };
738
+ // A detached/gone page should not turn an otherwise finalized local file
739
+ // into a failed stop. Keep the extension detail available as a warning.
740
+ if (extensionError) lastVideoResult.warning = extensionError.message;
741
+ return lastVideoResult;
742
+ })();
743
+ let settled;
744
+ settled = promise.finally(() => {
745
+ if (activeVideoRecorder === entry) activeVideoRecorder = null;
746
+ if (videoStopPromise === settled) {
747
+ videoStopPromise = null;
748
+ videoStopFailure = false;
749
+ }
750
+ });
751
+ videoStopPromise = settled;
752
+ return settled;
753
+ }
754
+
755
+ async function handleVideoRequest(tool, args, msg, request) {
756
+ if (tool === "video.start") return startVideoRecording(args, msg, request);
757
+ if (tool === "video.stop") return stopVideoRecording(request);
758
+ if (tool === "video.status") return videoStatus();
759
+ if (tool === "video.restart") {
760
+ const options = videoOptions(args);
761
+ const existing = activeVideoRecorder;
762
+ if (!existing) throw new VideoRecorderError("video_not_active", "No active video recording to restart");
763
+ const stopped = await stopVideoRecording(request);
764
+ const result = await startVideoRecording({ output: options.output, fps: options.fps }, {
765
+ ...msg,
766
+ tabId: existing.tabId,
767
+ }, {
768
+ ...request,
769
+ target: { ...(request.target || {}), tabId: existing.tabId, strict: true },
770
+ });
771
+ return { ...result, previous: stopped.path };
772
+ }
773
+ throw new VideoRecorderError("video_command_unknown", `Unknown video command: ${tool}`);
774
+ }
775
+
553
776
  function positiveId(value, name) {
554
777
  if (value === undefined || value === null || value === "") return undefined;
555
778
  const parsed = Number(value);
@@ -756,11 +979,13 @@ async function resolveSessionTarget(request, identity, name) {
756
979
  recoveryCommand: `surf session.rebind ${record.name} --tab-id ${record.tabId} --replace`,
757
980
  });
758
981
  }
982
+ const accessedAt = new Date().toISOString();
759
983
  const updated = browserSessionStore.replace(identity, record.name, {
760
984
  ...record,
761
985
  lastUrl: inspected.url || record.lastUrl,
762
986
  lastTitle: inspected.title || record.lastTitle,
763
- lastValidatedAt: new Date().toISOString(),
987
+ lastAccessedAt: accessedAt,
988
+ lastValidatedAt: accessedAt,
764
989
  });
765
990
  return {
766
991
  source: "session",
@@ -847,6 +1072,7 @@ async function resolveRequestTarget(msg, request, classification) {
847
1072
 
848
1073
  async function prepareToolRequest(msg, request) {
849
1074
  const args = msg.params?.args || {};
1075
+ if (request.tool === "video.start" || request.tool === "video.restart") videoOptions(args);
850
1076
  const classification = classifyTool(request.tool, args);
851
1077
  request.scope = classification.scope;
852
1078
  request.classification = classification;
@@ -891,6 +1117,7 @@ async function sessionRecordStatus(identity, request, record, refresh = false) {
891
1117
  ...record,
892
1118
  lastUrl: inspected.url || record.lastUrl,
893
1119
  lastTitle: inspected.title || record.lastTitle,
1120
+ lastAccessedAt: record.lastAccessedAt || record.updatedAt || record.createdAt,
894
1121
  lastValidatedAt: new Date().toISOString(),
895
1122
  });
896
1123
  }
@@ -910,6 +1137,158 @@ async function sessionRecordStatus(identity, request, record, refresh = false) {
910
1137
  };
911
1138
  }
912
1139
 
1140
+ function sessionActivity(record) {
1141
+ const value = record.lastAccessedAt || record.updatedAt || record.createdAt;
1142
+ if (typeof value !== "string" || !value) return null;
1143
+ const timestamp = Date.parse(value);
1144
+ return Number.isFinite(timestamp) ? { value, timestamp } : null;
1145
+ }
1146
+
1147
+ function cleanupEntry(record, { reason, targetAction, idleMs, lastAccessedAt, ...details }) {
1148
+ return {
1149
+ name: record.name,
1150
+ tabId: record.tabId,
1151
+ windowId: record.windowId,
1152
+ ownership: record.ownership || "adopted",
1153
+ reason,
1154
+ targetAction,
1155
+ targetClosed: targetAction === "close",
1156
+ ...(lastAccessedAt ? { lastAccessedAt } : {}),
1157
+ ...(idleMs !== undefined ? { idleMs } : {}),
1158
+ ...details,
1159
+ };
1160
+ }
1161
+
1162
+ async function cleanupBrowserSessions(identity, request, args) {
1163
+ const rawIdleAfter = args["idle-after"];
1164
+ const idleAfterMs = parseDurationMs(rawIdleAfter);
1165
+ const dryRun = args["dry-run"] === true;
1166
+ const now = Date.now();
1167
+ const records = browserSessionStore.list(identity);
1168
+ const operations = [];
1169
+ const retained = [];
1170
+ let inspected = 0;
1171
+
1172
+ for (const record of records) {
1173
+ const activity = sessionActivity(record);
1174
+ const common = {
1175
+ lastAccessedAt: activity?.value || record.lastAccessedAt || record.updatedAt || record.createdAt,
1176
+ };
1177
+
1178
+ if (record.browserEpoch !== identity.browserEpoch) {
1179
+ operations.push({
1180
+ record,
1181
+ closeTarget: false,
1182
+ entry: cleanupEntry(record, { ...common, reason: "epoch-stale", targetAction: "already-gone" }),
1183
+ });
1184
+ continue;
1185
+ }
1186
+
1187
+ if (record.invalidReason === "tab_gone" || record.invalidReason === "window_gone") {
1188
+ operations.push({
1189
+ record,
1190
+ closeTarget: false,
1191
+ entry: cleanupEntry(record, { ...common, reason: "target-gone", targetAction: "already-gone" }),
1192
+ });
1193
+ continue;
1194
+ }
1195
+
1196
+ if (record.invalidReason) {
1197
+ retained.push(cleanupEntry(record, { ...common, reason: "invalid", targetAction: "kept" }));
1198
+ continue;
1199
+ }
1200
+
1201
+ let inspectedTarget;
1202
+ try {
1203
+ inspectedTarget = await inspectBrowserTab(request, record.tabId);
1204
+ inspected += 1;
1205
+ } catch (error) {
1206
+ if (error?.code === "tab_gone") {
1207
+ operations.push({
1208
+ record,
1209
+ closeTarget: false,
1210
+ entry: cleanupEntry(record, { ...common, reason: "target-gone", targetAction: "already-gone" }),
1211
+ });
1212
+ continue;
1213
+ }
1214
+ throw error;
1215
+ }
1216
+
1217
+ if (record.windowId && inspectedTarget.windowId !== record.windowId) {
1218
+ retained.push(cleanupEntry(record, {
1219
+ ...common,
1220
+ reason: "binding-mismatch",
1221
+ targetAction: "kept",
1222
+ currentWindowId: inspectedTarget.windowId,
1223
+ }));
1224
+ continue;
1225
+ }
1226
+
1227
+ if (inspectedTarget.active !== false) {
1228
+ retained.push(cleanupEntry(record, { ...common, reason: "active", targetAction: "kept" }));
1229
+ continue;
1230
+ }
1231
+
1232
+ if (!activity || now <= activity.timestamp) {
1233
+ retained.push(cleanupEntry(record, { ...common, reason: "activity-unknown", targetAction: "kept" }));
1234
+ continue;
1235
+ }
1236
+
1237
+ const idleMs = now - activity.timestamp;
1238
+ if (idleMs <= idleAfterMs) {
1239
+ retained.push(cleanupEntry(record, { ...common, reason: "not-idle", targetAction: "kept", idleMs }));
1240
+ continue;
1241
+ }
1242
+
1243
+ const closeTarget = record.ownership === "surf-created";
1244
+ operations.push({
1245
+ record,
1246
+ closeTarget,
1247
+ entry: cleanupEntry(record, {
1248
+ ...common,
1249
+ reason: "idle",
1250
+ targetAction: closeTarget ? "close" : "keep",
1251
+ idleMs,
1252
+ }),
1253
+ });
1254
+ }
1255
+
1256
+ if (!dryRun) {
1257
+ for (const operation of operations) {
1258
+ if (operation.closeTarget) {
1259
+ try {
1260
+ const result = await requestExtensionOrThrow(request, "session.cleanup", {
1261
+ type: "SESSION_CLOSE_TARGET",
1262
+ tabId: operation.record.tabId,
1263
+ }, 30000, true);
1264
+ if (result?.alreadyGone === true) {
1265
+ operation.entry.targetAction = "already-gone";
1266
+ operation.entry.targetClosed = false;
1267
+ operation.entry.reason = "target-gone";
1268
+ }
1269
+ } catch (error) {
1270
+ if (error?.code !== "tab_gone") throw error;
1271
+ operation.entry.targetAction = "already-gone";
1272
+ operation.entry.targetClosed = false;
1273
+ operation.entry.reason = "target-gone";
1274
+ }
1275
+ }
1276
+ browserSessionStore.remove(identity, operation.record.name);
1277
+ }
1278
+ }
1279
+
1280
+ return {
1281
+ success: true,
1282
+ dryRun,
1283
+ idleAfter: String(rawIdleAfter).trim(),
1284
+ idleAfterMs,
1285
+ scanned: records.length,
1286
+ inspected,
1287
+ removed: operations.map(({ entry }) => entry),
1288
+ retained,
1289
+ };
1290
+ }
1291
+
913
1292
  async function createSessionBinding(request, identity, name, args, previous = null) {
914
1293
  const mode = args.tab === true ? "tab" : args.window === true ? "window" : previous?.mode || "window";
915
1294
  if (args.tab === true && args.window === true) {
@@ -931,6 +1310,7 @@ async function createSessionBinding(request, identity, name, args, previous = nu
931
1310
  browserEpoch: identity.browserEpoch,
932
1311
  mode,
933
1312
  ownership: "surf-created",
1313
+ lastAccessedAt: new Date().toISOString(),
934
1314
  lastUrl: created.url || url,
935
1315
  lastTitle: created.title,
936
1316
  groupId: created.groupId,
@@ -953,7 +1333,7 @@ async function createSessionBinding(request, identity, name, args, previous = nu
953
1333
  async function handleBrowserSessionCommand(tool, args, request) {
954
1334
  const identity = await requireBrowserIdentity();
955
1335
  const name = args.name;
956
- if (tool !== "session.list") validateSessionName(name);
1336
+ if (tool !== "session.list" && tool !== "session.cleanup") validateSessionName(name);
957
1337
 
958
1338
  if (tool === "session.new") {
959
1339
  if (browserSessionStore.get(identity, name)) {
@@ -992,6 +1372,10 @@ async function handleBrowserSessionCommand(tool, args, request) {
992
1372
  return { sessions, browser: identity, scheduler: browserScheduler.stats() };
993
1373
  }
994
1374
 
1375
+ if (tool === "session.cleanup") {
1376
+ return cleanupBrowserSessions(identity, request, args);
1377
+ }
1378
+
995
1379
  const existing = browserSessionStore.get(identity, name);
996
1380
  if (!existing) {
997
1381
  throw surfError("session_unknown", `Unknown session: ${name}`, {
@@ -1047,6 +1431,7 @@ async function handleBrowserSessionCommand(tool, args, request) {
1047
1431
  browserEpoch: identity.browserEpoch,
1048
1432
  mode: "tab",
1049
1433
  ownership: "adopted",
1434
+ lastAccessedAt: new Date().toISOString(),
1050
1435
  lastUrl: inspected.url,
1051
1436
  lastTitle: inspected.title,
1052
1437
  frameContext: null,
@@ -1500,6 +1885,12 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1500
1885
  .catch((error) => sendToolResponse(socket, originalId, null, error.message));
1501
1886
  return;
1502
1887
  }
1888
+ if (tool.startsWith("video.")) {
1889
+ handleVideoRequest(tool, args || {}, msg, requestContext)
1890
+ .then((result) => sendToolResponse(socket, originalId, result, null))
1891
+ .catch((error) => sendToolResponse(socket, originalId, null, error));
1892
+ return;
1893
+ }
1503
1894
 
1504
1895
  const extensionMsg = mapToolToMessage(tool, args, tabId);
1505
1896
  if (!extensionMsg) {
@@ -2476,6 +2867,23 @@ function processInput() {
2476
2867
  });
2477
2868
  return;
2478
2869
  }
2870
+
2871
+ if (msg.type === "VIDEO_FRAME") {
2872
+ if (activeVideoRecorder && msg.recorderId === activeVideoRecorder.recorderId && msg.tabId === activeVideoRecorder.tabId) {
2873
+ activeVideoRecorder.recorder.addFrame(msg.data, Number.isFinite(msg.receivedAt) ? msg.receivedAt : Date.now());
2874
+ }
2875
+ return;
2876
+ }
2877
+
2878
+ if (msg.type === "VIDEO_ERROR") {
2879
+ if (activeVideoRecorder && (!msg.recorderId || msg.recorderId === activeVideoRecorder.recorderId)) {
2880
+ void settleVideoFailure(activeVideoRecorder, new VideoRecorderError(
2881
+ typeof msg.errorCode === "string" ? msg.errorCode : "video_extension_error",
2882
+ msg.error || "Video screencast failed",
2883
+ ));
2884
+ }
2885
+ return;
2886
+ }
2479
2887
 
2480
2888
  if (msg.type === "STREAM_EVENT") {
2481
2889
  const stream = activeStreams.get(msg.streamId);
@@ -2936,9 +3344,18 @@ function shutdown(code = 0) {
2936
3344
  if (shuttingDown) return;
2937
3345
  shuttingDown = true;
2938
3346
  const cleanupPromises = [...connectedSockets].map((socket) => socket.transferCleanup?.() || Promise.resolve());
3347
+ const videoEntry = activeVideoRecorder;
3348
+ const videoCleanup = videoStopPromise || (videoEntry
3349
+ ? (async () => {
3350
+ if (videoEntry.extensionStarted) {
3351
+ try { writeMessage({ type: "VIDEO_STOP", recorderId: videoEntry.recorderId, tabId: videoEntry.tabId }); } catch {}
3352
+ }
3353
+ await videoEntry.recorder.dispose().catch(() => {});
3354
+ })()
3355
+ : Promise.resolve());
2939
3356
  for (const socket of connectedSockets) socket.destroy();
2940
3357
  pendingRequests.clear(); pendingToolRequests.clear(); activeStreams.clear();
2941
- Promise.allSettled([...cleanupPromises, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
3358
+ Promise.allSettled([...cleanupPromises, videoCleanup, Promise.resolve(listenerLifecycle?.shutdown())]).finally(scheduleExit);
2942
3359
  }
2943
3360
  function failStartup(error, endpoint) {
2944
3361
  log(`Listener startup failed (${endpoint}): ${error.message}`);
@@ -269,7 +269,7 @@ const TOOL_SCHEMAS = {
269
269
  desc: "Ask ChatGPT through the browser session",
270
270
  schema: {
271
271
  query: z.string().describe("Question or prompt"),
272
- model: z.string().optional().describe("ChatGPT model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label"),
272
+ model: z.string().optional().describe("ChatGPT model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5"),
273
273
  "with-page": z.boolean().optional().describe("Include current page context"),
274
274
  file: z.string().optional().describe("One attachment path"),
275
275
  timeout: z.number().optional().describe("Timeout in seconds")
@@ -45,8 +45,8 @@ Commands:
45
45
  Ask/follow options:
46
46
  --files <glob> Add context files (repeatable)
47
47
  --file <path> Attach one local file
48
- --model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
49
- --effort <effort> Select effort: light, standard, extended, heavy, pro
48
+ --model <model> Select model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5
49
+ --effort <effort> Select effort: instant, medium, high, xhigh, pro
50
50
  --github Require the ChatGPT Chat tab and GitHub tool
51
51
  --detach Return after dispatch
52
52
  --allow-sensitive Allow deny-listed context files