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/README.md +135 -9
- 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 +443 -13
- package/native/doctor.cjs +11 -2
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/file-transfer.cjs +5 -1
- package/native/host-helpers.cjs +40 -3
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +451 -26
- package/native/mcp-server.cjs +26 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/script-options.cjs +33 -0
- package/native/socket-permissions.cjs +114 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +8 -4
- package/native/video-recorder.cjs +444 -0
- package/native/workflow-definition.cjs +5 -0
- package/package.json +6 -6
- package/scripts/install-native-host.cjs +53 -6
- package/skills/surf/SKILL.md +25 -3
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({
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
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
|
-
|
|
2825
|
+
continue;
|
|
2431
2826
|
}
|
|
2432
2827
|
|
|
2433
2828
|
if (msg.type === "TARGET_EVENT") {
|
|
2434
2829
|
handleTargetEvent(msg);
|
|
2435
|
-
|
|
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
|
-
|
|
2854
|
+
continue;
|
|
2460
2855
|
}
|
|
2461
2856
|
|
|
2462
2857
|
if (msg.type === "API_REQUEST") {
|
|
2463
2858
|
handleApiRequest(msg, writeMessage);
|
|
2464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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}`);
|