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/README.md +28 -4
- package/agents/gpt-pro.md +2 -2
- package/native/browser-session-store.cjs +30 -2
- package/native/chatgpt-client-selection.cjs +79 -35
- package/native/chatgpt-client-ui.cjs +313 -198
- package/native/chatgpt-client.cjs +7 -2
- package/native/cli.cjs +121 -4
- package/native/file-transfer.cjs +5 -1
- package/native/host-helpers.cjs +1 -1
- package/native/host.cjs +424 -7
- package/native/mcp-server.cjs +1 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/socket-permissions.cjs +114 -0
- package/native/tool-scope.cjs +6 -2
- package/native/video-recorder.cjs +444 -0
- package/native/workflow-definition.cjs +5 -0
- package/package.json +3 -3
- package/scripts/install-native-host.cjs +53 -6
- package/skills/surf/SKILL.md +7 -3
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({
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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}`);
|
package/native/mcp-server.cjs
CHANGED
|
@@ -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:
|
|
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")
|
package/native/oracle-cli.cjs
CHANGED
|
@@ -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:
|
|
49
|
-
--effort <effort> Select effort:
|
|
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
|